authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-26 23:57:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-26 23:57:46-07:00
logead4ce52a1624d9c55461f61524580978fc8b719
treefe89b8bc51157ff03f7e9371644411814b5425e3
parentd3c24a14b92ccd8d832466dc7f0f51d979a8a21d
signature Signed by PGP key B5690EEEBB952194

fix(grok): improve skills, plans, usage, and turn reliability (#8358)

Co-authored-by: Dara Adedeji <daraadedeji07@gmail.com> Co-authored-by: Ahmed Besic <ahmed-besic@users.noreply.github.com> Co-authored-by: Michael Brown <michaeltbrown.mtb@gmail.com> Co-authored-by: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Co-authored-by: Guilherme Barros <gbarros1095@gmail.com> Co-authored-by: PC <pc@localhost> Co-authored-by: 1xpixi <157762409+1xpixi@users.noreply.github.com>

33 files changed, 3859 insertions(+), 145 deletions(-)

apps/mobile/src/features/usage/usageProviders.ts+5-3
......@@ -5,21 +5,23 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe
55 * Series and table order. The chart stacks providers from the bottom in this
66 * order, so it also fixes which band sits on top of the bars.
77 */
8export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];
8export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"];
99
1010export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {
1111 claude: "Claude Code",
1212 codex: "Codex",
13 grok: "Grok Build",
1314};
1415
1516/**
16 * Claude's brand orange holds in both themes; Codex is neutral and must flip
17 * with the theme or its bars vanish against the matching background.
17 * Claude's brand orange holds in both themes; Codex and Grok are neutrals and
18 * must flip with the theme or their bars vanish against the matching background.
1819 */
1920export function useProviderColors(): Record<UsageProviderKind, string> {
2021 const { themeAppearance: scheme } = useAppearancePreferences();
2122 return {
2223 claude: "#d97757",
2324 codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",
25 grok: scheme === "dark" ? "#a1a1aa" : "#52525b",
2426 };
2527}
apps/server/scripts/acp-mock-agent.ts+212-30
......@@ -19,7 +19,15 @@ const emitInterleavedAssistantToolCalls =
1919const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
2020const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
2121const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";
22const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1";
23const emitXAiPlanMdWrite = process.env.T3_ACP_EMIT_XAI_PLAN_MD_WRITE === "1";
2224const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1";
25const emitXAiRateLimitThenHang = process.env.T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG === "1";
26const emitXAiAskUserQuestionThenHang =
27 process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG === "1";
28const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1";
29const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1";
30const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1";
2331const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1";
2432const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1";
2533const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1";
......@@ -39,12 +47,19 @@ const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1";
3947const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
4048const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
4149const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
50const initialGrokReasoningEffort =
51 process.env.T3_ACP_INITIAL_GROK_REASONING_EFFORT?.trim() || undefined;
4252const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0");
4353const permissionOptionIds = {
4454 allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once",
4555 allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",
4656 rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once",
4757};
58const omitAllowAlways = process.env.T3_ACP_OMIT_ALLOW_ALWAYS === "1";
59const permissionRequestCount = Math.max(
60 1,
61 Number(process.env.T3_ACP_PERMISSION_REQUEST_COUNT ?? "1") || 1,
62);
4863const sessionId = "mock-session-1";
4964
5065let currentModeId = "ask";
......@@ -279,7 +294,13 @@ function modeState(): AcpSchema.SessionModeState {
279294}
280295
281296const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [
282 { modelId: "grok-build", name: "Grok Build" },
297 {
298 modelId: "grok-build",
299 name: "Grok Build",
300 ...(initialGrokReasoningEffort
301 ? { _meta: { reasoningEffort: initialGrokReasoningEffort } }
302 : {}),
303 },
283304 { modelId: "grok-mock-alt", name: "Grok Mock Alt" },
284305];
285306
......@@ -522,6 +543,68 @@ const program = Effect.gen(function* () {
522543 return yield* Effect.never;
523544 }
524545
546 if (emitXAiRateLimitThenHang) {
547 writeJsonRpcNotification("_x.ai/session/prompt_complete", {
548 sessionId: requestedSessionId,
549 promptId: promptIdFromRequestMeta(request) ?? "mock-xai-rate-limit-prompt-1",
550 stopReason: "rate_limit",
551 agentResult: null,
552 });
553 return yield* Effect.never;
554 }
555
556 if (emitContentThenHang) {
557 yield* agent.client.sessionUpdate({
558 sessionId: requestedSessionId,
559 update: {
560 sessionUpdate: "agent_message_chunk",
561 content: { type: "text", text: "partial before stall" },
562 },
563 });
564 return yield* Effect.never;
565 }
566
567 if (emitPlanThenHang) {
568 yield* agent.client.sessionUpdate({
569 sessionId: requestedSessionId,
570 update: {
571 sessionUpdate: "plan",
572 entries: [
573 {
574 content: "Wait for more ACP progress",
575 priority: "high",
576 status: "in_progress",
577 },
578 ],
579 },
580 });
581 return yield* Effect.never;
582 }
583
584 if (emitActiveToolThenHang) {
585 const toolCallId = "tool-call-long-running-1";
586 yield* agent.client.sessionUpdate({
587 sessionId: requestedSessionId,
588 update: {
589 sessionUpdate: "tool_call",
590 toolCallId,
591 title: "Long-running tool",
592 kind: "execute",
593 status: "pending",
594 rawInput: { command: ["long-running-tool"] },
595 },
596 });
597 yield* agent.client.sessionUpdate({
598 sessionId: requestedSessionId,
599 update: {
600 sessionUpdate: "tool_call_update",
601 toolCallId,
602 status: "in_progress",
603 },
604 });
605 return yield* Effect.never;
606 }
607
525608 if (emitXAiPromptCompleteThenHang) {
526609 writeJsonRpcNotification("session/update", {
527610 sessionId: requestedSessionId,
......@@ -656,37 +739,58 @@ const program = Effect.gen(function* () {
656739 },
657740 });
658741
659 const permission = yield* agent.client.requestPermission({
660 sessionId: requestedSessionId,
661 toolCall: {
662 toolCallId,
663 title: "`cat server/package.json`",
664 kind: "execute",
665 status: "pending",
666 content: [
667 {
668 type: "content",
669 content: {
670 type: "text",
671 text: "Not in allowlist: cat server/package.json",
742 const permissionOptions: Array<AcpSchema.PermissionOption> = [
743 { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" },
744 ...(omitAllowAlways
745 ? []
746 : [
747 {
748 optionId: permissionOptionIds.allowAlways,
749 name: "Allow always",
750 kind: "allow_always" as const,
672751 },
752 ]),
753 { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" },
754 ];
755
756 let cancelled = cancelledSessions.delete(requestedSessionId);
757 for (let index = 0; index < permissionRequestCount; index++) {
758 const command =
759 index > 0
760 ? (process.env.T3_ACP_SECOND_PERMISSION_COMMAND ?? "cat server/package.json")
761 : "cat server/package.json";
762 const permission = yield* agent.client.requestPermission({
763 sessionId: requestedSessionId,
764 toolCall: {
765 toolCallId: index === 0 ? toolCallId : `${toolCallId}-${index + 1}`,
766 title: process.env.T3_ACP_PERMISSION_TITLE ?? `\`${command}\``,
767 kind: "execute",
768 status: "pending",
769 rawInput: {
770 variant: "Bash",
771 command,
772 description: index === 0 ? "Read package metadata" : "Read it again",
673773 },
674 ],
675 },
676 options: [
677 { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" },
678 {
679 optionId: permissionOptionIds.allowAlways,
680 name: "Allow always",
681 kind: "allow_always",
774 content: [
775 {
776 type: "content",
777 content: {
778 type: "text",
779 text: `Not in allowlist: ${command}`,
780 },
781 },
782 ],
682783 },
683 { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" },
684 ],
685 });
686
687 const cancelled =
688 cancelledSessions.delete(requestedSessionId) ||
689 permission.outcome.outcome === "cancelled";
784 options: permissionOptions,
785 });
786 cancelled =
787 cancelled ||
788 cancelledSessions.delete(requestedSessionId) ||
789 permission.outcome.outcome === "cancelled";
790 if (cancelled) {
791 break;
792 }
793 }
690794
691795 yield* agent.client.sessionUpdate({
692796 sessionId: requestedSessionId,
......@@ -773,7 +877,7 @@ const program = Effect.gen(function* () {
773877 return { stopReason: "end_turn" };
774878 }
775879
776 if (emitXAiAskUserQuestion) {
880 if (emitXAiAskUserQuestion || emitXAiAskUserQuestionThenHang) {
777881 const result = yield* agent.client.extRequest("_x.ai/ask_user_question", {
778882 method: "x.ai/ask_user_question",
779883 params: {
......@@ -807,6 +911,84 @@ const program = Effect.gen(function* () {
807911 throw new Error("Expected accepted _x.ai/ask_user_question response answers.");
808912 }
809913
914 if (emitXAiAskUserQuestionThenHang) {
915 return yield* Effect.never;
916 }
917
918 return { stopReason: "end_turn" };
919 }
920
921 if (emitXAiPlanMdWrite) {
922 // Match Grok's real session layout so isGrokPlanMarkdownPath accepts it.
923 const planRoot = process.env.T3_ACP_PLAN_ROOT ?? "/tmp/mock-home/.grok";
924 const planPath = `${planRoot}/sessions/${requestedSessionId}/plan.md`;
925 const planBody = "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it\n";
926 // enter_plan_mode first so the adapter arms planModeActive.
927 yield* agent.client.sessionUpdate({
928 sessionId: requestedSessionId,
929 update: {
930 sessionUpdate: "tool_call",
931 toolCallId: "enter-plan-mode-1",
932 title: "enter_plan_mode",
933 kind: "other",
934 status: "completed",
935 rawInput: { variant: "EnterPlanMode" },
936 },
937 });
938 yield* agent.client.sessionUpdate({
939 sessionId: requestedSessionId,
940 update: {
941 sessionUpdate: "tool_call",
942 toolCallId: "plan-md-write-1",
943 title: "write",
944 kind: "edit",
945 status: "pending",
946 rawInput: { file_path: planPath, content: planBody },
947 },
948 });
949 yield* agent.client.sessionUpdate({
950 sessionId: requestedSessionId,
951 update: {
952 sessionUpdate: "tool_call_update",
953 toolCallId: "plan-md-write-1",
954 kind: "edit",
955 status: "completed",
956 title: `Write \`${planPath}\``,
957 rawInput: { file_path: planPath, content: planBody },
958 content: [
959 {
960 type: "diff",
961 path: planPath,
962 oldText: "",
963 newText: planBody,
964 },
965 ],
966 },
967 });
968 return { stopReason: "end_turn" };
969 }
970
971 if (emitXAiExitPlanMode) {
972 const result = yield* agent.client.extRequest("_x.ai/exit_plan_mode", {
973 method: "x.ai/exit_plan_mode",
974 params: {
975 sessionId: requestedSessionId,
976 toolCallId: "exit-plan-mode-tool-call-1",
977 planContent: "# Exit plan\n\n- Step one\n- Step two\n",
978 },
979 });
980 if (typeof result !== "object" || result === null || !("outcome" in result)) {
981 throw new Error("Expected _x.ai/exit_plan_mode response outcome.");
982 }
983 if (
984 result.outcome !== "abandoned" &&
985 result.outcome !== "approved" &&
986 result.outcome !== "request_changes"
987 ) {
988 throw new Error(
989 `Expected exit_plan_mode outcome abandoned|approved|request_changes, got ${String(result.outcome)}`,
990 );
991 }
810992 return { stopReason: "end_turn" };
811993 }
812994
apps/server/src/provider/Drivers/GrokDriver.ts+2-1
......@@ -88,6 +88,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
8888 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
8989 const httpClient = yield* HttpClient.HttpClient;
9090 const serverSettings = yield* ServerSettingsService;
91 const { cwd } = yield* ServerConfig;
9192 const eventLoggers = yield* ProviderEventLoggers;
9293 const processEnv = mergeProviderInstanceEnvironment(environment);
9394 const continuationIdentity = defaultProviderContinuationIdentity({
......@@ -113,7 +114,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
113114 });
114115 const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv);
115116
116 const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe(
117 const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe(
117118 Effect.map(stampIdentity),
118119 Effect.provideService(Crypto.Crypto, crypto),
119120 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
apps/server/src/provider/Drivers/GrokSkills.test.ts created+135
......@@ -0,0 +1,135 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Sink from "effect/Sink";
5import * as Stream from "effect/Stream";
6import { ChildProcessSpawner } from "effect/unstable/process";
7
8import { discoverGrokSkills, parseGrokInspectSkills } from "./GrokSkills.ts";
9
10const inspectPayload = (skills: ReadonlyArray<unknown>) => JSON.stringify({ skills });
11
12describe("parseGrokInspectSkills", () => {
13 it("maps inspect entries onto provider skills, sorted by name", () => {
14 const skills = parseGrokInspectSkills(
15 inspectPayload([
16 {
17 name: "writing-docs",
18 description: "Write user docs.",
19 source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" },
20 userInvocable: true,
21 },
22 {
23 name: "deploy",
24 description: "Deploy the app.",
25 source: {
26 type: "plugin",
27 path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md",
28 },
29 userInvocable: true,
30 },
31 ]),
32 );
33
34 expect(skills).toEqual([
35 {
36 name: "deploy",
37 description: "Deploy the app.",
38 path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md",
39 scope: "plugin",
40 enabled: true,
41 },
42 {
43 name: "writing-docs",
44 description: "Write user docs.",
45 path: "/home/dev/.grok/skills/writing-docs/SKILL.md",
46 scope: "user",
47 enabled: true,
48 },
49 ]);
50 });
51
52 it("disables skills the CLI marks as not user-invocable", () => {
53 const skills = parseGrokInspectSkills(
54 inspectPayload([
55 {
56 name: "internal-helper",
57 source: { type: "bundled", path: "/opt/grok/bundled/skills/internal-helper/SKILL.md" },
58 userInvocable: false,
59 },
60 ]),
61 );
62
63 expect(skills).toEqual([
64 {
65 name: "internal-helper",
66 path: "/opt/grok/bundled/skills/internal-helper/SKILL.md",
67 scope: "bundled",
68 enabled: false,
69 },
70 ]);
71 });
72
73 it("skips entries without a name or a filesystem path", () => {
74 const skills = parseGrokInspectSkills(
75 inspectPayload([
76 { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } },
77 { name: "no-path", source: { type: "user" } },
78 { name: "no-source" },
79 "not-an-object",
80 { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } },
81 ]),
82 );
83
84 expect(skills.map((skill) => skill.name)).toEqual(["kept"]);
85 });
86
87 it("returns an empty list for malformed or unexpected output", () => {
88 expect(parseGrokInspectSkills("not json")).toEqual([]);
89 expect(parseGrokInspectSkills("null")).toEqual([]);
90 expect(parseGrokInspectSkills(JSON.stringify({ skills: "nope" }))).toEqual([]);
91 expect(parseGrokInspectSkills(JSON.stringify({}))).toEqual([]);
92 });
93});
94
95describe("discoverGrokSkills", () => {
96 it.effect("spawns the inspect probe in the configured cwd", () => {
97 const spawnCwds: Array<string | undefined> = [];
98 const spawner = ChildProcessSpawner.make((command) => {
99 spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined);
100 return Effect.succeed(
101 ChildProcessSpawner.makeHandle({
102 pid: ChildProcessSpawner.ProcessId(1),
103 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
104 isRunning: Effect.succeed(false),
105 kill: () => Effect.void,
106 unref: Effect.succeed(Effect.void),
107 stdin: Sink.drain,
108 stdout: Stream.encodeText(
109 Stream.make(
110 inspectPayload([
111 {
112 name: "kept",
113 source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" },
114 },
115 ]),
116 ),
117 ),
118 stderr: Stream.empty,
119 all: Stream.empty,
120 getInputFd: () => Sink.drain,
121 getOutputFd: () => Stream.empty,
122 }),
123 );
124 });
125
126 return Effect.gen(function* () {
127 const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}, "/workspaces/demo").pipe(
128 Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
129 );
130
131 expect(spawnCwds).toEqual(["/workspaces/demo"]);
132 expect(skills.map((skill) => skill.name)).toEqual(["kept"]);
133 });
134 });
135});
apps/server/src/provider/Drivers/GrokSkills.ts created+119
......@@ -0,0 +1,119 @@
1/**
2 * GrokSkills — skill discovery for the `$` picker via `grok inspect --json`.
3 *
4 * Unlike Claude Code, the Grok CLI reports its full skill catalog itself:
5 * `grok inspect --json` returns `skills[]` with `name`, `description`,
6 * `source.type` (`user` / `project` / `bundled` / `plugin`), `source.path`
7 * (the absolute `SKILL.md` path), and `userInvocable`. Asking the CLI beats
8 * scanning the filesystem because the catalog honors Grok's own skill config
9 * (ignore lists, disabled skills) and includes plugin skills, which live
10 * three levels deep under `~/.grok/installed-plugins/` where a flat scan
11 * cannot see them. This mirrors how the Codex app-server reports skills over
12 * `skills/list`. Discovery is best-effort: an older CLI without `inspect`,
13 * a timeout, or malformed output yields an empty list, never a degraded
14 * provider snapshot.
15 *
16 * @module provider/Drivers/GrokSkills
17 */
18import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts";
19import * as Effect from "effect/Effect";
20import * as Option from "effect/Option";
21import * as Result from "effect/Result";
22import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
23import { resolveSpawnCommand } from "@t3tools/shared/shell";
24
25import { spawnAndCollect } from "../providerSnapshot.ts";
26
27const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000;
28
29/**
30 * Map `grok inspect --json` output onto provider skills. Entries without a
31 * name or a filesystem path are skipped; `userInvocable: false` skills are
32 * kept but disabled so pickers that filter on `enabled` hide them.
33 */
34export function parseGrokInspectSkills(stdout: string): ReadonlyArray<ServerProviderSkill> {
35 let parsed: unknown;
36 try {
37 parsed = JSON.parse(stdout);
38 } catch {
39 return [];
40 }
41 if (typeof parsed !== "object" || parsed === null) {
42 return [];
43 }
44 const entries = (parsed as Record<string, unknown>).skills;
45 if (!Array.isArray(entries)) {
46 return [];
47 }
48
49 const skillsByName = new Map<string, ServerProviderSkill>();
50 for (const entry of entries) {
51 if (typeof entry !== "object" || entry === null) {
52 continue;
53 }
54 const record = entry as Record<string, unknown>;
55 const name = typeof record.name === "string" ? record.name.trim() : "";
56 const source =
57 typeof record.source === "object" && record.source !== null
58 ? (record.source as Record<string, unknown>)
59 : undefined;
60 const path = typeof source?.path === "string" ? source.path.trim() : "";
61 if (!name || !path) {
62 continue;
63 }
64 const scope = typeof source?.type === "string" ? source.type.trim() : "";
65 const description = typeof record.description === "string" ? record.description.trim() : "";
66 skillsByName.set(name, {
67 name,
68 path,
69 enabled: record.userInvocable !== false,
70 ...(scope ? { scope } : {}),
71 ...(description ? { description } : {}),
72 });
73 }
74
75 return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name));
76}
77
78/**
79 * Run `grok inspect --json` and map the reported catalog onto provider
80 * skills. Never fails: any spawn error, non-zero exit, or timeout resolves
81 * to an empty list.
82 */
83export const discoverGrokSkills = Effect.fn("discoverGrokSkills")(function* (
84 grokSettings: Pick<GrokSettings, "binaryPath">,
85 environment: NodeJS.ProcessEnv = process.env,
86 cwd?: string,
87): Effect.fn.Return<
88 ReadonlyArray<ServerProviderSkill>,
89 never,
90 ChildProcessSpawner.ChildProcessSpawner
91> {
92 const command = grokSettings.binaryPath || "grok";
93 const inspectResult = yield* Effect.gen(function* () {
94 const spawnCommand = yield* resolveSpawnCommand(command, ["inspect", "--json"], {
95 env: environment,
96 });
97 return yield* spawnAndCollect(
98 command,
99 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
100 ...(cwd ? { cwd } : {}),
101 env: environment,
102 shell: spawnCommand.shell,
103 }),
104 );
105 }).pipe(Effect.timeoutOption(GROK_SKILLS_PROBE_TIMEOUT_MS), Effect.result);
106
107 if (Result.isFailure(inspectResult) || Option.isNone(inspectResult.success)) {
108 yield* Effect.logDebug("Grok skill discovery failed; continuing without skills.");
109 return [];
110 }
111 const output = inspectResult.success.value;
112 if (output.code !== 0) {
113 yield* Effect.logDebug("Grok skill discovery exited non-zero; continuing without skills.", {
114 exitCode: output.code,
115 });
116 return [];
117 }
118 return parseGrokInspectSkills(output.stdout);
119});
apps/server/src/provider/Layers/GrokAdapter.test.ts+822-1
......@@ -26,7 +26,13 @@ import {
2626} from "@t3tools/contracts";
2727
2828import { ServerConfig } from "../../config.ts";
29import { grokPromptSettlementBelongsToContext, makeGrokAdapter } from "./GrokAdapter.ts";
29import {
30 grokPromptSettlementBelongsToContext,
31 isGrokEnterPlanModeToolCall,
32 makeGrokAdapter,
33 nextGrokPlanModeActive,
34 selectGrokPermissionOptionId,
35} from "./GrokAdapter.ts";
3036const decodeGrokSettings = Schema.decodeSync(GrokSettings);
3137
3238const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
......@@ -89,6 +95,90 @@ const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), {
8995const makeTestAdapter = (binaryPath: string, options?: Parameters<typeof makeGrokAdapter>[1]) =>
9096 makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie);
9197
98it("detects enter_plan_mode tool calls from title and rawInput", () => {
99 assert.isTrue(
100 isGrokEnterPlanModeToolCall({
101 title: "enter_plan_mode",
102 data: { toolCallId: "1" },
103 }),
104 );
105 assert.isTrue(
106 isGrokEnterPlanModeToolCall({
107 title: "Plan mode entered",
108 data: { toolCallId: "1", rawInput: { variant: "EnterPlanMode" } },
109 }),
110 );
111 assert.isFalse(
112 isGrokEnterPlanModeToolCall({
113 title: "write",
114 data: { toolCallId: "1", rawInput: { file_path: "/tmp/x", content: "y" } },
115 }),
116 );
117});
118
119it("only sets planModeActive after a successful enter_plan_mode", () => {
120 const enter = {
121 title: "enter_plan_mode",
122 data: { toolCallId: "1" },
123 };
124 assert.isFalse(nextGrokPlanModeActive(false, { ...enter, status: "pending" }));
125 assert.isTrue(nextGrokPlanModeActive(false, { ...enter, status: "inProgress" }));
126 assert.isTrue(nextGrokPlanModeActive(false, { ...enter, status: "completed" }));
127 assert.isFalse(nextGrokPlanModeActive(false, { ...enter, status: "failed" }));
128 assert.isFalse(nextGrokPlanModeActive(true, { ...enter, status: "failed" }));
129 assert.isTrue(
130 nextGrokPlanModeActive(true, {
131 title: "write",
132 status: "completed",
133 data: { toolCallId: "2" },
134 }),
135 );
136});
137
138function grokPermissionRequest(
139 options: ReadonlyArray<{
140 readonly optionId: string;
141 readonly kind: "allow_once" | "allow_always" | "reject_once" | "reject_always";
142 }>,
143) {
144 return {
145 sessionId: "mock-session-1",
146 toolCall: {
147 toolCallId: "tool-call-1",
148 title: "cat package.json",
149 kind: "execute" as const,
150 status: "pending" as const,
151 },
152 options: options.map((option) => ({
153 optionId: option.optionId,
154 name: option.kind,
155 kind: option.kind,
156 })),
157 };
158}
159
160it("maps Always allow to allow_once when Grok omits allow_always", () => {
161 const request = grokPermissionRequest([
162 { optionId: "allow-once", kind: "allow_once" },
163 { optionId: "reject-once", kind: "reject_once" },
164 ]);
165
166 assert.equal(selectGrokPermissionOptionId(request, "acceptForSession"), "allow-once");
167 assert.equal(selectGrokPermissionOptionId(request, "accept"), "allow-once");
168 assert.equal(selectGrokPermissionOptionId(request, "decline"), "reject-once");
169});
170
171it("prefers allow_always when Grok offers it", () => {
172 const request = grokPermissionRequest([
173 { optionId: "allow-once", kind: "allow_once" },
174 { optionId: "allow-always", kind: "allow_always" },
175 { optionId: "reject-once", kind: "reject_once" },
176 ]);
177
178 assert.equal(selectGrokPermissionOptionId(request, "acceptForSession"), "allow-always");
179 assert.equal(selectGrokPermissionOptionId(request, "accept"), "allow-once");
180});
181
92182it("requires a settlement to match the live Grok turn", () => {
93183 const staleTurnId = TurnId.make("stale-turn");
94184 const replacementTurnId = TurnId.make("replacement-turn");
......@@ -418,6 +508,362 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
418508 }),
419509 );
420510
511 it.effect("does not time out a Grok turn before ACP emits progress", () =>
512 Effect.gen(function* () {
513 const threadId = ThreadId.make("grok-watchdog-silent-turn");
514 const wrapperPath = yield* Effect.promise(() =>
515 makeMockGrokWrapper({
516 T3_ACP_HANG_PROMPT_FOREVER: "1",
517 }),
518 );
519 const adapter = yield* makeTestAdapter(wrapperPath, {
520 turnInactivityTimeoutMs: 1_000,
521 });
522 const runtimeEvents: ProviderRuntimeEvent[] = [];
523 const turnStarted = yield* Deferred.make<void>();
524 const turnCompleted =
525 yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "turn.completed" }>>();
526 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
527 Effect.gen(function* () {
528 if (String(event.threadId) !== String(threadId)) {
529 return;
530 }
531 runtimeEvents.push(event);
532 if (event.type === "turn.started") {
533 yield* Deferred.succeed(turnStarted, undefined).pipe(Effect.ignore);
534 }
535 if (event.type === "turn.completed") {
536 yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore);
537 }
538 }),
539 ).pipe(Effect.forkChild);
540
541 yield* adapter.startSession({
542 threadId,
543 provider: ProviderDriverKind.make("grok"),
544 cwd: process.cwd(),
545 runtimeMode: "full-access",
546 });
547
548 const sendTurnFiber = yield* adapter
549 .sendTurn({ threadId, input: "silence forever", attachments: [] })
550 .pipe(Effect.forkChild);
551 yield* Deferred.await(turnStarted);
552
553 yield* TestClock.adjust("5 seconds");
554 yield* Effect.yieldNow;
555 const steerSendTurnFiber = yield* adapter
556 .sendTurn({ threadId, input: "keep reasoning", attachments: [] })
557 .pipe(Effect.forkChild);
558 for (let yieldAttempt = 0; yieldAttempt < 12; yieldAttempt += 1) {
559 yield* Effect.yieldNow;
560 }
561 yield* TestClock.adjust("5 seconds");
562 yield* Effect.yieldNow;
563 assert.lengthOf(
564 runtimeEvents.filter(
565 (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId),
566 ),
567 0,
568 );
569
570 yield* adapter.interruptTurn(threadId);
571 const completed = yield* Deferred.await(turnCompleted).pipe(
572 Effect.timeout("2 seconds"),
573 TestClock.withLive,
574 );
575 yield* Fiber.join(sendTurnFiber);
576 yield* Fiber.interrupt(steerSendTurnFiber);
577
578 assert.equal(completed.payload.state, "cancelled");
579 const session = (yield* adapter.listSessions()).find(
580 (candidate) => candidate.threadId === threadId,
581 );
582 assert.equal(session?.status, "ready");
583 assert.isUndefined(session?.activeTurnId);
584
585 yield* Fiber.interrupt(runtimeEventsFiber);
586 yield* adapter.stopSession(threadId);
587 }),
588 );
589
590 it.effect("fails a Grok turn that stalls after ACP content begins", () =>
591 Effect.gen(function* () {
592 const threadId = ThreadId.make("grok-watchdog-content-stall");
593 const wrapperPath = yield* Effect.promise(() =>
594 makeMockGrokWrapper({
595 T3_ACP_EMIT_CONTENT_THEN_HANG: "1",
596 }),
597 );
598 const adapter = yield* makeTestAdapter(wrapperPath, {
599 turnInactivityTimeoutMs: 1_000,
600 });
601 const contentDelta = yield* Deferred.make<void>();
602 const turnCompleted =
603 yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "turn.completed" }>>();
604 const runtimeEvents: ProviderRuntimeEvent[] = [];
605 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
606 Effect.gen(function* () {
607 if (String(event.threadId) !== String(threadId)) {
608 return;
609 }
610 runtimeEvents.push(event);
611 if (event.type === "content.delta") {
612 yield* Deferred.succeed(contentDelta, undefined).pipe(Effect.ignore);
613 }
614 if (event.type === "turn.completed") {
615 yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore);
616 }
617 }),
618 ).pipe(Effect.forkChild);
619
620 yield* adapter.startSession({
621 threadId,
622 provider: ProviderDriverKind.make("grok"),
623 cwd: process.cwd(),
624 runtimeMode: "full-access",
625 });
626 const sendTurnFiber = yield* adapter
627 .sendTurn({ threadId, input: "start then stall", attachments: [] })
628 .pipe(Effect.forkChild);
629 yield* Deferred.await(contentDelta).pipe(Effect.timeout("2 seconds"), TestClock.withLive);
630
631 yield* TestClock.adjust("999 millis");
632 yield* Effect.yieldNow;
633 assert.lengthOf(
634 runtimeEvents.filter(
635 (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId),
636 ),
637 0,
638 );
639
640 yield* TestClock.adjust("1 millis");
641 for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) {
642 yield* Effect.yieldNow;
643 }
644 const completed = yield* Deferred.await(turnCompleted).pipe(
645 Effect.timeout("2 seconds"),
646 TestClock.withLive,
647 );
648 yield* Fiber.join(sendTurnFiber);
649
650 assert.equal(completed.payload.state, "failed");
651 assert.equal(
652 runtimeEvents.filter(
653 (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId),
654 ).length,
655 1,
656 );
657
658 yield* Fiber.interrupt(runtimeEventsFiber);
659 yield* adapter.stopSession(threadId);
660 }),
661 );
662
663 it.effect("refreshes Grok liveness when a turn is steered", () =>
664 Effect.gen(function* () {
665 const threadId = ThreadId.make("grok-watchdog-steer");
666 const wrapperPath = yield* Effect.promise(() =>
667 makeMockGrokWrapper({
668 T3_ACP_EMIT_CONTENT_THEN_HANG: "1",
669 }),
670 );
671 const adapter = yield* makeTestAdapter(wrapperPath, {
672 turnInactivityTimeoutMs: 1_000,
673 });
674 const contentDelta = yield* Deferred.make<void>();
675 const turnCompleted =
676 yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "turn.completed" }>>();
677 const runtimeEvents: ProviderRuntimeEvent[] = [];
678 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
679 Effect.gen(function* () {
680 if (String(event.threadId) !== String(threadId)) {
681 return;
682 }
683 runtimeEvents.push(event);
684 if (event.type === "content.delta") {
685 yield* Deferred.succeed(contentDelta, undefined).pipe(Effect.ignore);
686 }
687 if (event.type === "turn.completed") {
688 yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore);
689 }
690 }),
691 ).pipe(Effect.forkChild);
692
693 yield* adapter.startSession({
694 threadId,
695 provider: ProviderDriverKind.make("grok"),
696 cwd: process.cwd(),
697 runtimeMode: "full-access",
698 });
699 const firstSendTurnFiber = yield* adapter
700 .sendTurn({ threadId, input: "start then steer", attachments: [] })
701 .pipe(Effect.forkChild);
702 yield* Deferred.await(contentDelta).pipe(Effect.timeout("2 seconds"), TestClock.withLive);
703
704 yield* TestClock.adjust("999 millis");
705 const steerSendTurnFiber = yield* adapter
706 .sendTurn({ threadId, input: "continue working", attachments: [] })
707 .pipe(Effect.forkChild);
708 for (let yieldAttempt = 0; yieldAttempt < 12; yieldAttempt += 1) {
709 yield* Effect.yieldNow;
710 }
711
712 yield* TestClock.adjust("1 millis");
713 for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) {
714 yield* Effect.yieldNow;
715 }
716 assert.lengthOf(
717 runtimeEvents.filter(
718 (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId),
719 ),
720 0,
721 );
722
723 yield* Fiber.interrupt(steerSendTurnFiber);
724 yield* adapter.interruptTurn(threadId);
725 const completed = yield* Deferred.await(turnCompleted).pipe(
726 Effect.timeout("2 seconds"),
727 TestClock.withLive,
728 );
729 yield* Fiber.join(firstSendTurnFiber);
730 assert.equal(completed.payload.state, "cancelled");
731
732 yield* Fiber.interrupt(runtimeEventsFiber);
733 yield* adapter.stopSession(threadId);
734 }),
735 );
736
737 it.effect("refreshes Grok liveness when ACP updates its plan", () =>
738 Effect.gen(function* () {
739 const threadId = ThreadId.make("grok-watchdog-plan-stall");
740 const wrapperPath = yield* Effect.promise(() =>
741 makeMockGrokWrapper({
742 T3_ACP_EMIT_PLAN_THEN_HANG: "1",
743 }),
744 );
745 const adapter = yield* makeTestAdapter(wrapperPath, {
746 turnInactivityTimeoutMs: 1_000,
747 });
748 const planUpdated = yield* Deferred.make<void>();
749 const turnCompleted =
750 yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "turn.completed" }>>();
751 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
752 Effect.gen(function* () {
753 if (String(event.threadId) !== String(threadId)) {
754 return;
755 }
756 if (event.type === "turn.plan.updated") {
757 yield* Deferred.succeed(planUpdated, undefined).pipe(Effect.ignore);
758 }
759 if (event.type === "turn.completed") {
760 yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore);
761 }
762 }),
763 ).pipe(Effect.forkChild);
764
765 yield* adapter.startSession({
766 threadId,
767 provider: ProviderDriverKind.make("grok"),
768 cwd: process.cwd(),
769 runtimeMode: "full-access",
770 });
771 const sendTurnFiber = yield* adapter
772 .sendTurn({ threadId, input: "update plan then stall", attachments: [] })
773 .pipe(Effect.forkChild);
774 yield* Deferred.await(planUpdated).pipe(Effect.timeout("2 seconds"), TestClock.withLive);
775
776 yield* TestClock.adjust("1 second");
777 for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) {
778 yield* Effect.yieldNow;
779 }
780 const completed = yield* Deferred.await(turnCompleted).pipe(
781 Effect.timeout("2 seconds"),
782 TestClock.withLive,
783 );
784 yield* Fiber.join(sendTurnFiber);
785
786 assert.equal(completed.payload.state, "failed");
787 yield* Fiber.interrupt(runtimeEventsFiber);
788 yield* adapter.stopSession(threadId);
789 }),
790 );
791
792 it.effect("settles a stalled Grok turn after the active-tool deadline", () =>
793 Effect.gen(function* () {
794 const threadId = ThreadId.make("grok-watchdog-active-tool");
795 const wrapperPath = yield* Effect.promise(() =>
796 makeMockGrokWrapper({
797 T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG: "1",
798 }),
799 );
800 const adapter = yield* makeTestAdapter(wrapperPath, {
801 turnInactivityTimeoutMs: 1_000,
802 activeToolInactivityTimeoutMs: 5_000,
803 });
804 const activeTool = yield* Deferred.make<void>();
805 const turnCompleted =
806 yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "turn.completed" }>>();
807 const runtimeEvents: ProviderRuntimeEvent[] = [];
808 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
809 Effect.gen(function* () {
810 if (String(event.threadId) !== String(threadId)) {
811 return;
812 }
813 runtimeEvents.push(event);
814 if (event.type === "item.updated") {
815 yield* Deferred.succeed(activeTool, undefined).pipe(Effect.ignore);
816 }
817 if (event.type === "turn.completed") {
818 yield* Deferred.succeed(turnCompleted, event).pipe(Effect.ignore);
819 }
820 }),
821 ).pipe(Effect.forkChild);
822
823 yield* adapter.startSession({
824 threadId,
825 provider: ProviderDriverKind.make("grok"),
826 cwd: process.cwd(),
827 runtimeMode: "full-access",
828 });
829 const sendTurnFiber = yield* adapter
830 .sendTurn({ threadId, input: "run a long tool", attachments: [] })
831 .pipe(Effect.forkChild);
832 yield* Deferred.await(activeTool).pipe(Effect.timeout("2 seconds"), TestClock.withLive);
833 for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) {
834 yield* Effect.yieldNow;
835 }
836
837 yield* TestClock.adjust("4999 millis");
838 yield* Effect.yieldNow;
839 assert.lengthOf(
840 runtimeEvents.filter(
841 (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId),
842 ),
843 0,
844 );
845 assert.equal(
846 (yield* adapter.listSessions()).find((candidate) => candidate.threadId === threadId)
847 ?.status,
848 "running",
849 );
850
851 yield* TestClock.adjust("1 millis");
852 for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) {
853 yield* Effect.yieldNow;
854 }
855 const completed = yield* Deferred.await(turnCompleted).pipe(
856 Effect.timeout("2 seconds"),
857 TestClock.withLive,
858 );
859 yield* Fiber.join(sendTurnFiber);
860 assert.equal(completed.payload.state, "failed");
861
862 yield* Fiber.interrupt(runtimeEventsFiber);
863 yield* adapter.stopSession(threadId);
864 }),
865 );
866
421867 it.effect("retains turn transcript when sendTurn is interrupted after prompt success", () =>
422868 Effect.gen(function* () {
423869 const threadId = ThreadId.make("grok-send-turn-interrupt-after-prompt");
......@@ -943,6 +1389,64 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
9431389 }),
9441390 );
9451391
1392 it.effect("surfaces Grok usage limits without clearing the selected model", () =>
1393 Effect.gen(function* () {
1394 const threadId = ThreadId.make("grok-usage-limit-error");
1395 const wrapperPath = yield* Effect.promise(() =>
1396 makeMockGrokWrapper({
1397 T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG: "1",
1398 }),
1399 );
1400 const adapter = yield* makeTestAdapter(wrapperPath);
1401 const runtimeEvents: ProviderRuntimeEvent[] = [];
1402 const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
1403 Effect.sync(() => {
1404 runtimeEvents.push(event);
1405 }),
1406 ).pipe(Effect.forkChild);
1407
1408 yield* adapter.startSession({
1409 threadId,
1410 provider: ProviderDriverKind.make("grok"),
1411 cwd: process.cwd(),
1412 runtimeMode: "full-access",
1413 modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" },
1414 });
1415
1416 const error = yield* Effect.flip(
1417 adapter.sendTurn({
1418 threadId,
1419 input: "hit the usage limit",
1420 attachments: [],
1421 }),
1422 );
1423 const readySessions = yield* adapter.listSessions();
1424 const readySession = readySessions.find((session) => session.threadId === threadId);
1425 const terminalEvents = runtimeEvents.filter(
1426 (event) => event.type === "turn.completed" && event.threadId === threadId,
1427 );
1428
1429 assert.equal(error._tag, "ProviderAdapterRequestError");
1430 assert.include(error.message, "Grok usage limit reached. Try again later.");
1431 assert.equal(readySession?.status, "ready");
1432 assert.equal(readySession?.model, "grok-build");
1433 assert.isUndefined(readySession?.activeTurnId);
1434 assert.lengthOf(terminalEvents, 1);
1435 const [terminalEvent] = terminalEvents;
1436 assert.equal(terminalEvent?.type, "turn.completed");
1437 if (terminalEvent?.type === "turn.completed") {
1438 assert.equal(terminalEvent.payload.state, "failed");
1439 assert.include(
1440 terminalEvent.payload.errorMessage ?? "",
1441 "Grok usage limit reached. Try again later.",
1442 );
1443 }
1444
1445 yield* Fiber.interrupt(runtimeEventsFiber);
1446 yield* adapter.stopSession(threadId);
1447 }),
1448 );
1449
9461450 it.effect("ignores replayed session/load updates when resuming a Grok session", () =>
9471451 Effect.gen(function* () {
9481452 const threadId = ThreadId.make("grok-load-replay-filter");
......@@ -1097,6 +1601,247 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
10971601 }),
10981602 );
10991603
1604 it.effect("captures xAI exit_plan_mode as a proposed plan and unblocks the turn", () =>
1605 Effect.gen(function* () {
1606 const threadId = ThreadId.make("grok-xai-exit-plan-mode");
1607 const wrapperPath = yield* Effect.promise(() =>
1608 makeMockGrokWrapper({ T3_ACP_EMIT_XAI_EXIT_PLAN_MODE: "1" }),
1609 );
1610 const adapter = yield* makeTestAdapter(wrapperPath);
1611 const proposed =
1612 yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "turn.proposed.completed" }>>();
1613 const turnCompleted = yield* Deferred.make<void>();
1614
1615 const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => {
1616 if (String(event.threadId) !== String(threadId)) {
1617 return Effect.void;
1618 }
1619 if (event.type === "turn.proposed.completed") {
1620 return Deferred.succeed(proposed, event).pipe(Effect.ignore);
1621 }
1622 if (event.type === "turn.completed") {
1623 return Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore);
1624 }
1625 return Effect.void;
1626 }).pipe(Effect.forkChild);
1627
1628 yield* adapter.startSession({
1629 threadId,
1630 provider: ProviderDriverKind.make("grok"),
1631 cwd: process.cwd(),
1632 runtimeMode: "full-access",
1633 });
1634
1635 yield* adapter.sendTurn({ threadId, input: "present the plan", attachments: [] });
1636
1637 const proposedEvent = yield* Deferred.await(proposed);
1638 assert.equal(proposedEvent.type, "turn.proposed.completed");
1639 assert.equal(proposedEvent.payload.planMarkdown, "# Exit plan\n\n- Step one\n- Step two");
1640 assert.equal(proposedEvent.raw?.method, "_x.ai/exit_plan_mode");
1641 yield* Deferred.await(turnCompleted);
1642
1643 yield* Fiber.interrupt(eventsFiber);
1644 yield* adapter.stopSession(threadId);
1645 }),
1646 );
1647
1648 it.effect("surfaces plan.md writes as a proposed plan while plan mode is active", () =>
1649 Effect.gen(function* () {
1650 const threadId = ThreadId.make("grok-xai-plan-md-write");
1651 const wrapperPath = yield* Effect.promise(() =>
1652 makeMockGrokWrapper({ T3_ACP_EMIT_XAI_PLAN_MD_WRITE: "1" }),
1653 );
1654 const adapter = yield* makeTestAdapter(wrapperPath);
1655 const proposed =
1656 yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "turn.proposed.completed" }>>();
1657
1658 const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => {
1659 if (String(event.threadId) !== String(threadId)) {
1660 return Effect.void;
1661 }
1662 if (event.type === "turn.proposed.completed") {
1663 return Deferred.succeed(proposed, event).pipe(Effect.ignore);
1664 }
1665 return Effect.void;
1666 }).pipe(Effect.forkChild);
1667
1668 yield* adapter.startSession({
1669 threadId,
1670 provider: ProviderDriverKind.make("grok"),
1671 cwd: process.cwd(),
1672 runtimeMode: "full-access",
1673 });
1674
1675 yield* adapter.sendTurn({ threadId, input: "write the plan", attachments: [] });
1676
1677 const proposedEvent = yield* Deferred.await(proposed);
1678 assert.equal(
1679 proposedEvent.payload.planMarkdown,
1680 "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it",
1681 );
1682 assert.equal(proposedEvent.raw?.method, "session/update");
1683
1684 yield* Fiber.interrupt(eventsFiber);
1685 yield* adapter.stopSession(threadId);
1686 }),
1687 );
1688
1689 it.effect("keeps a Grok turn running when Always allow has no allow_always option", () =>
1690 Effect.gen(function* () {
1691 const threadId = ThreadId.make("grok-always-allow-without-allow-always");
1692 const tempDir = yield* Effect.promise(() =>
1693 NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-")),
1694 );
1695 const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
1696 const wrapperPath = yield* Effect.promise(() =>
1697 makeMockGrokWrapper({
1698 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
1699 T3_ACP_EMIT_TOOL_CALLS: "1",
1700 T3_ACP_OMIT_ALLOW_ALWAYS: "1",
1701 T3_ACP_PERMISSION_REQUEST_COUNT: "2",
1702 }),
1703 );
1704 const adapter = yield* makeTestAdapter(wrapperPath);
1705 const openedCount = yield* Ref.make(0);
1706 const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
1707 event.type === "request.opened"
1708 ? Effect.gen(function* () {
1709 yield* Ref.update(openedCount, (count) => count + 1);
1710 yield* adapter.respondToRequest(
1711 threadId,
1712 ApprovalRequestId.make(String(event.requestId)),
1713 "acceptForSession",
1714 );
1715 })
1716 : Effect.void,
1717 ).pipe(Effect.forkChild);
1718
1719 yield* adapter.startSession({
1720 threadId,
1721 provider: ProviderDriverKind.make("grok"),
1722 cwd: process.cwd(),
1723 runtimeMode: "approval-required",
1724 });
1725 yield* adapter.sendTurn({
1726 threadId,
1727 input: "approve this session",
1728 attachments: [],
1729 });
1730
1731 assert.equal(yield* Ref.get(openedCount), 1);
1732
1733 const requests = yield* Effect.promise(() => readJsonLines(requestLogPath));
1734 const permissionResults = requests.filter(
1735 (entry) =>
1736 !("method" in entry) &&
1737 typeof entry.result === "object" &&
1738 entry.result !== null &&
1739 "outcome" in entry.result &&
1740 typeof entry.result.outcome === "object" &&
1741 entry.result.outcome !== null &&
1742 "optionId" in entry.result.outcome,
1743 );
1744 assert.equal(permissionResults.length, 2);
1745 assert.isTrue(
1746 permissionResults.every(
1747 (entry) =>
1748 typeof entry.result === "object" &&
1749 entry.result !== null &&
1750 "outcome" in entry.result &&
1751 typeof entry.result.outcome === "object" &&
1752 entry.result.outcome !== null &&
1753 "optionId" in entry.result.outcome &&
1754 entry.result.outcome.optionId === "allow-once",
1755 ),
1756 );
1757
1758 yield* Fiber.interrupt(eventsFiber);
1759 yield* adapter.stopSession(threadId);
1760 }),
1761 );
1762
1763 it.effect("asks before a different command after Always allow this session", () =>
1764 Effect.gen(function* () {
1765 const threadId = ThreadId.make("grok-session-approval-scope");
1766 const wrapperPath = yield* Effect.promise(() =>
1767 makeMockGrokWrapper({
1768 T3_ACP_EMIT_TOOL_CALLS: "1",
1769 T3_ACP_OMIT_ALLOW_ALWAYS: "1",
1770 T3_ACP_PERMISSION_REQUEST_COUNT: "2",
1771 T3_ACP_PERMISSION_TITLE: "Terminal",
1772 T3_ACP_SECOND_PERMISSION_COMMAND: "rm server/package.json",
1773 }),
1774 );
1775 const adapter = yield* makeTestAdapter(wrapperPath);
1776 const openedCount = yield* Ref.make(0);
1777 const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
1778 event.type === "request.opened"
1779 ? Effect.gen(function* () {
1780 const count = yield* Ref.updateAndGet(openedCount, (value) => value + 1);
1781 yield* adapter.respondToRequest(
1782 threadId,
1783 ApprovalRequestId.make(String(event.requestId)),
1784 count === 1 ? "acceptForSession" : "decline",
1785 );
1786 })
1787 : Effect.void,
1788 ).pipe(Effect.forkChild);
1789
1790 yield* adapter.startSession({
1791 threadId,
1792 provider: ProviderDriverKind.make("grok"),
1793 cwd: process.cwd(),
1794 runtimeMode: "approval-required",
1795 });
1796 yield* adapter.sendTurn({ threadId, input: "check approval scope", attachments: [] });
1797 assert.equal(yield* Ref.get(openedCount), 2);
1798 yield* Fiber.interrupt(eventsFiber);
1799 yield* adapter.stopSession(threadId);
1800 }),
1801 );
1802
1803 it.effect("captures a plan under the provider instance GROK_HOME", () =>
1804 Effect.gen(function* () {
1805 const threadId = ThreadId.make("grok-instance-plan-home");
1806 const grokHome = yield* Effect.promise(() =>
1807 NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-instance-home-")),
1808 );
1809 const wrapperPath = yield* Effect.promise(() =>
1810 makeMockGrokWrapper({
1811 T3_ACP_EMIT_XAI_PLAN_MD_WRITE: "1",
1812 T3_ACP_PLAN_ROOT: grokHome,
1813 }),
1814 );
1815 const adapter = yield* makeTestAdapter(wrapperPath, {
1816 environment: { ...process.env, GROK_HOME: grokHome },
1817 });
1818 const plans = yield* Ref.make<ReadonlyArray<string>>([]);
1819 const completed = yield* Deferred.make<void>();
1820 const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => {
1821 if (event.type === "turn.proposed.completed") {
1822 return Ref.update(plans, (current) => [...current, event.payload.planMarkdown]);
1823 }
1824 return event.type === "turn.completed"
1825 ? Deferred.succeed(completed, undefined).pipe(Effect.asVoid)
1826 : Effect.void;
1827 }).pipe(Effect.forkChild);
1828
1829 yield* adapter.startSession({
1830 threadId,
1831 provider: ProviderDriverKind.make("grok"),
1832 cwd: process.cwd(),
1833 runtimeMode: "full-access",
1834 });
1835 yield* adapter.sendTurn({ threadId, input: "write the plan", attachments: [] });
1836 yield* Deferred.await(completed);
1837 assert.deepEqual(yield* Ref.get(plans), [
1838 "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it",
1839 ]);
1840 yield* Fiber.interrupt(eventsFiber);
1841 yield* adapter.stopSession(threadId);
1842 }),
1843 );
1844
11001845 it.effect("handles xAI ask_user_question extension requests", () =>
11011846 Effect.gen(function* () {
11021847 const threadId = ThreadId.make("grok-xai-ask-user-question");
......@@ -1159,6 +1904,82 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
11591904 }),
11601905 );
11611906
1907 it.effect("settles a stalled Grok turn after its first activity is user input", () =>
1908 Effect.gen(function* () {
1909 const threadId = ThreadId.make("grok-xai-ask-user-question");
1910 const wrapperPath = yield* Effect.promise(() =>
1911 makeMockGrokWrapper({ T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG: "1" }),
1912 );
1913 const adapter = yield* makeTestAdapter(wrapperPath, {
1914 turnInactivityTimeoutMs: 1_000,
1915 });
1916 const requested =
1917 yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "user-input.requested" }>>();
1918 const resolved =
1919 yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "user-input.resolved" }>>();
1920 const completed =
1921 yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "turn.completed" }>>();
1922
1923 const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => {
1924 if (String(event.threadId) !== String(threadId)) {
1925 return Effect.void;
1926 }
1927 if (event.type === "user-input.requested") {
1928 return Deferred.succeed(requested, event).pipe(Effect.ignore);
1929 }
1930 if (event.type === "user-input.resolved") {
1931 return Deferred.succeed(resolved, event).pipe(Effect.ignore);
1932 }
1933 if (event.type === "turn.completed") {
1934 return Deferred.succeed(completed, event).pipe(Effect.ignore);
1935 }
1936 return Effect.void;
1937 }).pipe(Effect.forkChild);
1938
1939 yield* adapter.startSession({
1940 threadId,
1941 provider: ProviderDriverKind.make("grok"),
1942 cwd: process.cwd(),
1943 runtimeMode: "full-access",
1944 });
1945
1946 const sendTurnFiber = yield* adapter
1947 .sendTurn({ threadId, input: "ask before continuing", attachments: [] })
1948 .pipe(Effect.forkChild);
1949
1950 const requestedEvent = yield* Deferred.await(requested);
1951 assert.equal(requestedEvent.payload.questions.length, 1);
1952 assert.equal(requestedEvent.payload.questions[0]?.id, "Which scope should Grok use?");
1953 assert.equal(requestedEvent.payload.questions[0]?.question, "Which scope should Grok use?");
1954 assert.equal(requestedEvent.raw?.method, "_x.ai/ask_user_question");
1955
1956 yield* adapter.respondToUserInput(
1957 threadId,
1958 ApprovalRequestId.make(String(requestedEvent.requestId)),
1959 {
1960 "Which scope should Grok use?": "Workspace",
1961 },
1962 );
1963
1964 const resolvedEvent = yield* Deferred.await(resolved);
1965 assert.deepEqual(resolvedEvent.payload.answers, {
1966 "Which scope should Grok use?": "Workspace",
1967 });
1968 assert.equal(String(resolvedEvent.turnId), String(requestedEvent.turnId));
1969
1970 yield* TestClock.adjust("1 second");
1971 const completedEvent = yield* Deferred.await(completed).pipe(
1972 Effect.timeout("2 seconds"),
1973 TestClock.withLive,
1974 );
1975 assert.equal(completedEvent.payload.state, "failed");
1976 yield* Fiber.join(sendTurnFiber);
1977
1978 yield* Fiber.interrupt(eventsFiber);
1979 yield* adapter.stopSession(threadId);
1980 }),
1981 );
1982
11621983 it.effect("continues streaming events when native notification logging fails", () =>
11631984 Effect.gen(function* () {
11641985 const threadId = ThreadId.make("grok-native-log-failure");
apps/server/src/provider/Layers/GrokAdapter.ts+597-23
......@@ -12,9 +12,14 @@ import {
1212 type ThreadId,
1313 TurnId,
1414} from "@t3tools/contracts";
15import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
16import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
17import { stableStringify } from "@t3tools/shared/relaySigning";
18import * as Clock from "effect/Clock";
1519import * as Crypto from "effect/Crypto";
1620import * as DateTime from "effect/DateTime";
1721import * as Deferred from "effect/Deferred";
22import * as Duration from "effect/Duration";
1823import * as Effect from "effect/Effect";
1924import * as Exit from "effect/Exit";
2025import * as Fiber from "effect/Fiber";
......@@ -22,6 +27,7 @@ import * as FileSystem from "effect/FileSystem";
2227import * as Option from "effect/Option";
2328import * as Path from "effect/Path";
2429import * as PubSub from "effect/PubSub";
30import * as Queue from "effect/Queue";
2531import * as Ref from "effect/Ref";
2632import * as Schema from "effect/Schema";
2733import * as Scope from "effect/Scope";
......@@ -56,15 +62,21 @@ import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";
5662import {
5763 applyGrokAcpModelSelection,
5864 currentGrokModelIdFromSessionSetup,
65 currentGrokReasoningEffortFromSessionSetup,
5966 makeGrokAcpRuntime,
67 normalizeGrokReasoningEffort,
6068 resolveGrokAcpBaseModelId,
6169} from "../acp/GrokAcpSupport.ts";
6270import {
71 extractGrokPlanMarkdownFromToolCallData,
6372 extractXAiAskUserQuestions,
73 extractXAiExitPlanMarkdown,
6474 makeXAiAskUserQuestionCancelledResponse,
6575 makeXAiAskUserQuestionResponse,
76 makeXAiExitPlanModeCapturedResponse,
6677 promptResponseHasMissingXAiStopReason,
6778 XAiAskUserQuestionRequest,
79 XAiExitPlanModeRequest,
6880} from "../acp/XAiAcpExtension.ts";
6981import { type GrokAdapterShape } from "../Services/GrokAdapter.ts";
7082import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
......@@ -73,6 +85,15 @@ const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonStri
7385
7486const PROVIDER = ProviderDriverKind.make("grok");
7587const GROK_RESUME_VERSION = 1 as const;
88const NANOS_PER_MILLI = 1_000_000n;
89// ACP does not expose Grok's private `streaming_reasoning` phase. Once it has
90// emitted standard ACP progress, ten silent minutes is long enough to avoid
91// treating legitimate reasoning as a stalled stream.
92const DEFAULT_GROK_TURN_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1_000;
93// A tool can legitimately run without emitting text for much longer than
94// reasoning. It still needs a deadline so a lost tool update cannot leave the
95// turn working forever.
96const DEFAULT_GROK_ACTIVE_TOOL_INACTIVITY_TIMEOUT_MS = 30 * 60 * 1_000;
7697
7798function encodeJsonStringForDiagnostics(input: unknown): string | undefined {
7899 const result = encodeUnknownJsonStringExit(input);
......@@ -84,6 +105,10 @@ export interface GrokAdapterLiveOptions {
84105 readonly nativeEventLogPath?: string;
85106 readonly nativeEventLogger?: EventNdjsonLogger;
86107 readonly instanceId?: ProviderInstanceId;
108 /** Override the conservative ACP turn liveness timeout in focused tests. */
109 readonly turnInactivityTimeoutMs?: number;
110 /** Override the longer active-tool liveness timeout in focused tests. */
111 readonly activeToolInactivityTimeoutMs?: number;
87112}
88113
89114interface PendingApproval {
......@@ -98,6 +123,10 @@ interface PendingUserInput {
98123 readonly resolution: Deferred.Deferred<PendingUserInputResolution>;
99124}
100125
126interface GrokTurnLivenessSignal {
127 readonly turnId: TurnId;
128}
129
101130interface GrokSessionContext {
102131 readonly threadId: ThreadId;
103132 readonly acpSessionId: string;
......@@ -109,6 +138,14 @@ interface GrokSessionContext {
109138 readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;
110139 turns: Array<{ id: TurnId; items: Array<unknown> }>;
111140 lastPlanFingerprint: string | undefined;
141 /**
142 * Latest plan.md body + turn it was emitted for. Dedupe is turn-scoped so a
143 * later turn re-proposing the same text still gets a new proposed-plan card.
144 */
145 lastKnownProposedPlanMarkdown: string | undefined;
146 lastKnownProposedPlanTurnId: TurnId | undefined;
147 /** True after enter_plan_mode until the turn ends or exit_plan_mode resolves. */
148 planModeActive: boolean;
112149 activeTurnId: TurnId | undefined;
113150 /** Turns already interrupted; late prompt RPCs must not resurrect them. */
114151 interruptedTurnIds: Set<TurnId>;
......@@ -116,7 +153,15 @@ interface GrokSessionContext {
116153 * >0 means a turn is actively running, so a new sendTurn is a steer that
117154 * continues it, and only the last remaining prompt settles the turn. */
118155 promptsInFlight: number;
156 readonly livenessSignals: Queue.Queue<GrokTurnLivenessSignal>;
157 livenessTurnId: TurnId | undefined;
158 lastTurnActivityAtNanos: bigint | undefined;
159 readonly activeToolCallIds: Set<string>;
160 livenessUpdatesInFlight: number;
161 /** Prompt RPCs that returned before their turn settlement acquired the lock. */
162 promptResponsesReady: number;
119163 currentModelId: string | undefined;
164 currentReasoningEffort: string | undefined;
120165 stopped: boolean;
121166}
122167
......@@ -164,6 +209,54 @@ const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined
164209
165210const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId;
166211
212function clearProposedPlanFallback(ctx: GrokSessionContext): void {
213 ctx.lastKnownProposedPlanMarkdown = undefined;
214 ctx.lastKnownProposedPlanTurnId = undefined;
215 ctx.planModeActive = false;
216}
217
218/** Detect Grok's enter_plan_mode tool call from ACP tool state. */
219export function isGrokEnterPlanModeToolCall(toolCall: {
220 readonly title?: string;
221 readonly data: Record<string, unknown>;
222}): boolean {
223 const title = toolCall.title?.trim().toLowerCase() ?? "";
224 if (
225 title === "enter_plan_mode" ||
226 title === "plan: enter" ||
227 title === "plan mode entered" ||
228 title.includes("enter_plan_mode")
229 ) {
230 return true;
231 }
232 const rawInput = toolCall.data.rawInput;
233 if (isRecord(rawInput) && rawInput.variant === "EnterPlanMode") {
234 return true;
235 }
236 return false;
237}
238
239/** Failed enter_plan_mode must not leave planModeActive stuck on. */
240export function nextGrokPlanModeActive(
241 currentlyActive: boolean,
242 toolCall: {
243 readonly title?: string;
244 readonly status?: "pending" | "inProgress" | "completed" | "failed";
245 readonly data: Record<string, unknown>;
246 },
247): boolean {
248 if (!isGrokEnterPlanModeToolCall(toolCall)) {
249 return currentlyActive;
250 }
251 if (toolCall.status === "failed") {
252 return false;
253 }
254 if (toolCall.status === "completed" || toolCall.status === "inProgress") {
255 return true;
256 }
257 return currentlyActive;
258}
259
167260const resolveSessionCallbackTurnId = (
168261 sessions: ReadonlyMap<ThreadId, GrokSessionContext>,
169262 threadId: ThreadId,
......@@ -179,26 +272,38 @@ function parseGrokResume(raw: unknown): { sessionId: string } | undefined {
179272 return { sessionId: raw.sessionId.trim() };
180273}
181274
182function selectPermissionOptionId(
275export function selectGrokPermissionOptionId(
183276 request: EffectAcpSchema.RequestPermissionRequest,
184277 decision: Exclude<ProviderApprovalDecision, "cancel">,
185278): string | undefined {
186 const kind =
279 const preferredKind =
187280 decision === "acceptForSession"
188281 ? "allow_always"
189282 : decision === "accept"
190283 ? "allow_once"
191284 : "reject_once";
192 const option = request.options.find((entry) => entry.kind === kind);
193 return option?.optionId.trim() || undefined;
285 const preferred = request.options.find((entry) => entry.kind === preferredKind);
286 const preferredId = preferred?.optionId.trim();
287 if (preferredId) {
288 return preferredId;
289 }
290 // Grok 4.6 often omits allow_always. T3 still offers "Always allow this session".
291 if (decision === "acceptForSession") {
292 const once = request.options.find((entry) => entry.kind === "allow_once");
293 const onceId = once?.optionId.trim();
294 if (onceId) {
295 return onceId;
296 }
297 }
298 return undefined;
194299}
195300
196301function selectAutoApprovedPermissionOption(
197302 request: EffectAcpSchema.RequestPermissionRequest,
198303): string | undefined {
199304 return (
200 selectPermissionOptionId(request, "acceptForSession") ??
201 selectPermissionOptionId(request, "accept")
305 selectGrokPermissionOptionId(request, "acceptForSession") ??
306 selectGrokPermissionOptionId(request, "accept")
202307 );
203308}
204309
......@@ -240,10 +345,31 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
240345 const managedNativeEventLogger =
241346 options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
242347 const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory();
348 const hostPlatform = yield* HostProcessPlatform;
349 const hostEnvironment = yield* HostProcessEnvironment;
350 const grokPlanPathHost = {
351 platform: hostPlatform,
352 environment: options?.environment ?? hostEnvironment,
353 };
243354
244355 const sessions = new Map<ThreadId, GrokSessionContext>();
245356 const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>());
246357 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
358 const requestedTurnInactivityTimeoutMs = options?.turnInactivityTimeoutMs;
359 const turnInactivityTimeoutMs =
360 typeof requestedTurnInactivityTimeoutMs === "number" &&
361 Number.isFinite(requestedTurnInactivityTimeoutMs)
362 ? Math.max(1, Math.floor(requestedTurnInactivityTimeoutMs))
363 : DEFAULT_GROK_TURN_INACTIVITY_TIMEOUT_MS;
364 const turnInactivityTimeoutNanos = BigInt(turnInactivityTimeoutMs) * NANOS_PER_MILLI;
365 const requestedActiveToolInactivityTimeoutMs = options?.activeToolInactivityTimeoutMs;
366 const activeToolInactivityTimeoutMs =
367 typeof requestedActiveToolInactivityTimeoutMs === "number" &&
368 Number.isFinite(requestedActiveToolInactivityTimeoutMs)
369 ? Math.max(1, Math.floor(requestedActiveToolInactivityTimeoutMs))
370 : DEFAULT_GROK_ACTIVE_TOOL_INACTIVITY_TIMEOUT_MS;
371 const activeToolInactivityTimeoutNanos =
372 BigInt(activeToolInactivityTimeoutMs) * NANOS_PER_MILLI;
247373
248374 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
249375 const randomUUIDv4 = crypto.randomUUIDv4.pipe(
......@@ -294,6 +420,146 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
294420 const withThreadLock = <A, E, R>(threadId: string, effect: Effect.Effect<A, E, R>) =>
295421 Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect));
296422
423 const signalTurnLiveness = (ctx: GrokSessionContext, turnId: TurnId) =>
424 Queue.offer(ctx.livenessSignals, { turnId }).pipe(Effect.asVoid);
425
426 const beginTurnLiveness = (ctx: GrokSessionContext, turnId: TurnId) =>
427 Effect.sync(() => {
428 ctx.livenessTurnId = turnId;
429 // Do not start a deadline until ACP has made observable progress.
430 // Grok's private reasoning phase is not present in the ACP stream.
431 ctx.lastTurnActivityAtNanos = undefined;
432 ctx.activeToolCallIds.clear();
433 });
434
435 const clearTurnLiveness = (ctx: GrokSessionContext) => {
436 const turnId = ctx.livenessTurnId;
437 ctx.livenessTurnId = undefined;
438 ctx.lastTurnActivityAtNanos = undefined;
439 ctx.activeToolCallIds.clear();
440 ctx.livenessUpdatesInFlight = 0;
441 ctx.promptResponsesReady = 0;
442 return turnId === undefined ? Effect.void : signalTurnLiveness(ctx, turnId);
443 };
444
445 const recordTurnActivity = Effect.fn("GrokAdapter.recordTurnActivity")(function* (
446 ctx: GrokSessionContext,
447 turnId: TurnId,
448 event: Extract<
449 AcpSessionRuntime.AcpSessionRuntimeEvent,
450 {
451 _tag:
452 | "AssistantItemStarted"
453 | "AssistantItemCompleted"
454 | "PlanUpdated"
455 | "ToolCallUpdated"
456 | "ContentDelta";
457 }
458 >,
459 ) {
460 if (
461 ctx.livenessTurnId !== turnId ||
462 (event._tag === "ContentDelta" && event.text.length === 0)
463 ) {
464 return;
465 }
466 ctx.livenessUpdatesInFlight += 1;
467 try {
468 const activityAtNanos = yield* Clock.monotonicTimeNanos;
469 if (ctx.livenessTurnId !== turnId || ctx.interruptedTurnIds.has(turnId)) {
470 return;
471 }
472 if (event._tag === "ToolCallUpdated") {
473 if (event.toolCall.status === "completed" || event.toolCall.status === "failed") {
474 ctx.activeToolCallIds.delete(event.toolCall.toolCallId);
475 } else {
476 // A tool update without a terminal status receives a longer
477 // deadline so a long-running tool is not mistaken for a stall.
478 ctx.activeToolCallIds.add(event.toolCall.toolCallId);
479 }
480 }
481 ctx.lastTurnActivityAtNanos = activityAtNanos;
482 } finally {
483 // Decrement before signaling. The watchdog treats in-flight updates as a
484 // pause; if it consumed a signal while the counter was still > 0 it would
485 // wait on the next take with no follow-up wake after this decrement.
486 ctx.livenessUpdatesInFlight = Math.max(0, ctx.livenessUpdatesInFlight - 1);
487 yield* signalTurnLiveness(ctx, turnId);
488 }
489 });
490
491 const hasLivenessPause = (ctx: GrokSessionContext) =>
492 ctx.pendingApprovals.size > 0 ||
493 ctx.pendingUserInputs.size > 0 ||
494 ctx.livenessUpdatesInFlight > 0;
495
496 const livenessTimeoutFor = (ctx: GrokSessionContext) =>
497 ctx.activeToolCallIds.size > 0
498 ? {
499 milliseconds: activeToolInactivityTimeoutMs,
500 nanos: activeToolInactivityTimeoutNanos,
501 }
502 : { milliseconds: turnInactivityTimeoutMs, nanos: turnInactivityTimeoutNanos };
503
504 const signalSessionTurnLiveness = (threadId: ThreadId, turnId: TurnId | undefined) => {
505 const ctx = sessions.get(threadId);
506 return ctx && turnId !== undefined ? signalTurnLiveness(ctx, turnId) : Effect.void;
507 };
508
509 const resumeSessionTurnLiveness = Effect.fn("GrokAdapter.resumeSessionTurnLiveness")(function* (
510 threadId: ThreadId,
511 turnId: TurnId | undefined,
512 ) {
513 const ctx = sessions.get(threadId);
514 if (!ctx || turnId === undefined || ctx.livenessTurnId !== turnId) {
515 return;
516 }
517 // An approval or user-input wait can last longer than the watchdog.
518 // Its resolution gives the provider a fresh window to resume output.
519 ctx.lastTurnActivityAtNanos = yield* Clock.monotonicTimeNanos;
520 yield* signalTurnLiveness(ctx, turnId);
521 });
522
523 const refreshSessionTurnLiveness = Effect.fn("GrokAdapter.refreshSessionTurnLiveness")(
524 function* (threadId: ThreadId, turnId: TurnId | undefined) {
525 const ctx = sessions.get(threadId);
526 if (
527 !ctx ||
528 turnId === undefined ||
529 ctx.livenessTurnId !== turnId ||
530 ctx.lastTurnActivityAtNanos === undefined
531 ) {
532 return;
533 }
534 ctx.lastTurnActivityAtNanos = yield* Clock.monotonicTimeNanos;
535 yield* signalTurnLiveness(ctx, turnId);
536 },
537 );
538
539 const markPromptResponseReady = Effect.fn("GrokAdapter.markPromptResponseReady")(function* (
540 threadId: ThreadId,
541 acpSessionId: string,
542 turnId: TurnId,
543 ) {
544 const ctx = sessions.get(threadId);
545 if (
546 ctx &&
547 ctx.acpSessionId === acpSessionId &&
548 !ctx.stopped &&
549 !ctx.interruptedTurnIds.has(turnId) &&
550 ctx.livenessTurnId === turnId &&
551 ctx.activeTurnId === turnId &&
552 ctx.session.activeTurnId === turnId
553 ) {
554 ctx.promptResponsesReady += 1;
555 yield* signalTurnLiveness(ctx, turnId);
556 }
557 });
558
559 const consumePromptResponseReady = (ctx: GrokSessionContext) => {
560 ctx.promptResponsesReady = Math.max(0, ctx.promptResponsesReady - 1);
561 };
562
297563 const settlePromptInFlight = (
298564 threadId: ThreadId,
299565 turnId: TurnId,
......@@ -373,6 +639,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
373639 updatedAt,
374640 };
375641 }
642 yield* clearTurnLiveness(liveCtx);
376643 return;
377644 }
378645 settleTurnId = fallbackTurnId;
......@@ -389,6 +656,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
389656 }
390657 liveCtx.promptsInFlight = remainingPrompts;
391658 }
659 yield* clearTurnLiveness(liveCtx);
392660 const updatedAt = yield* nowIso;
393661 const canEmitTurnCompletion =
394662 liveCtx.session.status === "running" || liveCtx.session.status === "connecting";
......@@ -397,6 +665,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
397665 options?.completedStopReason !== undefined && canEmitTurnCompletion;
398666 const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session;
399667 liveCtx.activeTurnId = undefined;
668 // Drop turn-scoped plan fallback so a later empty exit_plan cannot
669 // resurrect this turn's markdown as a fresh proposal.
670 clearProposedPlanFallback(liveCtx);
400671 liveCtx.session = {
401672 ...readySession,
402673 status: "ready",
......@@ -432,6 +703,104 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
432703 }
433704 });
434705
706 const isLiveTurn = (ctx: GrokSessionContext, turnId: TurnId) =>
707 ctx.promptsInFlight > 0 &&
708 ctx.promptsInFlight > ctx.promptResponsesReady &&
709 ctx.activeTurnId === turnId &&
710 ctx.session.activeTurnId === turnId &&
711 (ctx.session.status === "running" || ctx.session.status === "connecting");
712
713 const settleStalledTurn = Effect.fn("GrokAdapter.settleStalledTurn")(function* (
714 ctx: GrokSessionContext,
715 turnId: TurnId,
716 ) {
717 return yield* withThreadLock(
718 ctx.threadId,
719 Effect.gen(function* () {
720 const liveCtx = sessions.get(ctx.threadId);
721 if (
722 liveCtx !== ctx ||
723 ctx.stopped ||
724 !isLiveTurn(ctx, turnId) ||
725 ctx.interruptedTurnIds.has(turnId) ||
726 hasLivenessPause(ctx)
727 ) {
728 return;
729 }
730 const lastActivityAtNanos = ctx.lastTurnActivityAtNanos;
731 if (lastActivityAtNanos === undefined) {
732 return;
733 }
734 const nowNanos = yield* Clock.monotonicTimeNanos;
735 if (
736 ctx.interruptedTurnIds.has(turnId) ||
737 !isLiveTurn(ctx, turnId) ||
738 hasLivenessPause(ctx) ||
739 nowNanos - lastActivityAtNanos < livenessTimeoutFor(ctx).nanos
740 ) {
741 return;
742 }
743
744 // Mark before cancel/drain so notifications already in flight finish
745 // before the terminal event, while late notifications are dropped.
746 ctx.interruptedTurnIds.add(turnId);
747 yield* Effect.ignore(
748 ctx.acp.cancel.pipe(
749 Effect.mapError((error) =>
750 mapAcpToAdapterError(PROVIDER, ctx.threadId, "session/cancel", error),
751 ),
752 ),
753 );
754 yield* Effect.ignore(ctx.acp.drainEvents);
755 yield* settlePromptInFlight(ctx.threadId, turnId, ctx.acpSessionId, {
756 errorMessage: `Grok ACP turn stalled without content or tool progress for ${livenessTimeoutFor(ctx).milliseconds}ms.`,
757 settleAllPrompts: true,
758 });
759 }),
760 );
761 });
762
763 const runTurnLivenessWatchdog = Effect.fn("GrokAdapter.runTurnLivenessWatchdog")(
764 function* (ctx: GrokSessionContext) {
765 while (true) {
766 if (ctx.stopped) {
767 return;
768 }
769 const turnId = ctx.livenessTurnId;
770 if (
771 turnId === undefined ||
772 ctx.interruptedTurnIds.has(turnId) ||
773 !isLiveTurn(ctx, turnId) ||
774 hasLivenessPause(ctx)
775 ) {
776 yield* Queue.take(ctx.livenessSignals);
777 continue;
778 }
779
780 const lastActivityAtNanos = ctx.lastTurnActivityAtNanos;
781 if (lastActivityAtNanos === undefined) {
782 yield* Queue.take(ctx.livenessSignals);
783 continue;
784 }
785 const nowNanos = yield* Clock.monotonicTimeNanos;
786 const remainingNanos = livenessTimeoutFor(ctx).nanos - (nowNanos - lastActivityAtNanos);
787 if (remainingNanos <= 0n) {
788 yield* settleStalledTurn(ctx, turnId);
789 continue;
790 }
791
792 const wakeReason = yield* Effect.raceFirst(
793 Effect.sleep(Duration.nanos(remainingNanos)).pipe(Effect.as("timeout" as const)),
794 Queue.take(ctx.livenessSignals).pipe(Effect.as("activity" as const)),
795 );
796 if (wakeReason === "timeout") {
797 yield* settleStalledTurn(ctx, turnId);
798 }
799 }
800 },
801 Effect.catch(() => Effect.void),
802 );
803
435804 const logNative = (threadId: ThreadId, method: string, payload: unknown) =>
436805 Effect.gen(function* () {
437806 if (!nativeEventLogger) return;
......@@ -495,6 +864,45 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
495864 );
496865 });
497866
867 /** Surface Grok plan.md as T3's proposed-plan card (while writing + on exit). */
868 const emitProposedPlanCompleted = (
869 ctx: GrokSessionContext,
870 turnId: TurnId | undefined,
871 stamp: { readonly eventId: EventId; readonly createdAt: string },
872 planMarkdown: string,
873 raw: { readonly method: string; readonly payload: unknown },
874 ) =>
875 Effect.gen(function* () {
876 const trimmed = planMarkdown.trim();
877 if (trimmed.length === 0) {
878 ctx.lastKnownProposedPlanMarkdown = "";
879 ctx.lastKnownProposedPlanTurnId = turnId;
880 return;
881 }
882 // Turn-scoped dedupe: identical text on a later turn must still emit.
883 if (
884 ctx.lastKnownProposedPlanMarkdown === trimmed &&
885 ctx.lastKnownProposedPlanTurnId === turnId
886 ) {
887 return;
888 }
889 ctx.lastKnownProposedPlanMarkdown = trimmed;
890 ctx.lastKnownProposedPlanTurnId = turnId;
891 yield* offerRuntimeEvent({
892 type: "turn.proposed.completed",
893 ...stamp,
894 provider: PROVIDER,
895 threadId: ctx.threadId,
896 turnId,
897 payload: { planMarkdown: trimmed },
898 raw: {
899 source: "acp.grok.extension",
900 method: raw.method,
901 payload: raw.payload,
902 },
903 });
904 });
905
498906 const requireSession = (
499907 threadId: ThreadId,
500908 ): Effect.Effect<GrokSessionContext, ProviderAdapterSessionNotFoundError> => {
......@@ -556,6 +964,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
556964
557965 const pendingApprovals = new Map<ApprovalRequestId, PendingApproval>();
558966 const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>();
967 const sessionApprovedOperations = new Set<string>();
559968 const sessionScope = yield* Scope.make("sequential");
560969 let sessionScopeTransferred = false;
561970 yield* Effect.addFinalizer(() =>
......@@ -575,6 +984,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
575984 ...(options?.environment ? { environment: options.environment } : {}),
576985 childProcessSpawner,
577986 cwd,
987 runtimeMode: input.runtimeMode,
578988 ...(resumeSessionId ? { resumeSessionId } : {}),
579989 clientInfo: { name: "t3-code", version: "0.0.0" },
580990 ...(mcpSession
......@@ -621,6 +1031,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
6211031 const resolution = yield* Deferred.make<PendingUserInputResolution>();
6221032 const turnId = resolveSessionCallbackTurnId(sessions, input.threadId);
6231033 pendingUserInputs.set(requestId, { resolution });
1034 yield* signalSessionTurnLiveness(input.threadId, turnId);
6241035 yield* offerRuntimeEvent({
6251036 type: "user-input.requested",
6261037 ...(yield* makeEventStamp()),
......@@ -637,6 +1048,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
6371048 });
6381049 const resolved = yield* Deferred.await(resolution);
6391050 pendingUserInputs.delete(requestId);
1051 yield* resumeSessionTurnLiveness(input.threadId, turnId);
6401052 const resolvedAnswers = resolved._tag === "answered" ? resolved.answers : {};
6411053 yield* offerRuntimeEvent({
6421054 type: "user-input.resolved",
......@@ -663,12 +1075,77 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
6631075 ),
6641076 { discard: true },
6651077 );
1078 // Grok intercepts exit_plan_mode and reverse-requests client approval.
1079 // Capture plan into T3 proposed-plan UI and abandon the native gate so
1080 // the turn does not hang (Claude ExitPlanMode pattern).
1081 yield* Effect.forEach(
1082 ["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"] as const,
1083 (method) =>
1084 acp.handleExtRequest(method, XAiExitPlanModeRequest, (params) =>
1085 mapAcpCallbackFailure(
1086 Effect.gen(function* () {
1087 yield* logNative(input.threadId, method, params);
1088 const turnId = resolveSessionCallbackTurnId(sessions, input.threadId);
1089 const ctx = sessions.get(input.threadId);
1090 const planMarkdown = extractXAiExitPlanMarkdown(
1091 params,
1092 ctx?.lastKnownProposedPlanMarkdown,
1093 );
1094 if (ctx) {
1095 yield* emitProposedPlanCompleted(
1096 ctx,
1097 turnId,
1098 yield* makeEventStamp(),
1099 planMarkdown,
1100 { method, payload: params },
1101 );
1102 ctx.planModeActive = false;
1103 } else {
1104 yield* offerRuntimeEvent({
1105 type: "turn.proposed.completed",
1106 ...(yield* makeEventStamp()),
1107 provider: PROVIDER,
1108 threadId: input.threadId,
1109 turnId,
1110 payload: { planMarkdown },
1111 raw: {
1112 source: "acp.grok.extension",
1113 method,
1114 payload: params,
1115 },
1116 });
1117 }
1118 return makeXAiExitPlanModeCapturedResponse();
1119 }),
1120 ),
1121 ),
1122 { discard: true },
1123 );
6661124 yield* acp.handleRequestPermission((params) =>
6671125 mapAcpCallbackFailure(
6681126 Effect.gen(function* () {
6691127 yield* logNative(input.threadId, "session/request_permission", params);
670 if (input.runtimeMode === "full-access") {
671 const autoApprovedOptionId = selectAutoApprovedPermissionOption(params);
1128 const permissionRequest = parsePermissionRequest(params);
1129 const command = permissionRequest.toolCall?.command;
1130 const { kind, title, rawInput, locations } = params.toolCall;
1131 let operationInput = rawInput;
1132 if (isRecord(rawInput) && rawInput.variant === "Bash") {
1133 const { description: _description, ...shellInput } = rawInput;
1134 operationInput = shellInput;
1135 }
1136 // Remember the operation, not the tool-call id or every future tool.
1137 // Generic titles without input cannot identify an operation safely.
1138 const approvalKey =
1139 command || (isRecord(rawInput) && Object.keys(rawInput).length > 0)
1140 ? stableStringify({ kind, title, command, input: operationInput, locations })
1141 : undefined;
1142 const alreadyApproved =
1143 approvalKey !== undefined && sessionApprovedOperations.has(approvalKey);
1144 if (input.runtimeMode === "full-access" || alreadyApproved) {
1145 const autoApprovedOptionId =
1146 input.runtimeMode === "full-access"
1147 ? selectAutoApprovedPermissionOption(params)
1148 : selectGrokPermissionOptionId(params, "accept");
6721149 if (autoApprovedOptionId !== undefined) {
6731150 return {
6741151 outcome: {
......@@ -678,12 +1155,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
6781155 };
6791156 }
6801157 }
681 const permissionRequest = parsePermissionRequest(params);
6821158 const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
6831159 const runtimeRequestId = RuntimeRequestId.make(requestId);
6841160 const decision = yield* Deferred.make<ProviderApprovalDecision>();
6851161 const turnId = resolveSessionCallbackTurnId(sessions, input.threadId);
6861162 pendingApprovals.set(requestId, { decision });
1163 yield* signalSessionTurnLiveness(input.threadId, turnId);
6871164 yield* offerRuntimeEvent(
6881165 makeAcpRequestOpenedEvent({
6891166 stamp: yield* makeEventStamp(),
......@@ -704,6 +1181,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
7041181 );
7051182 const resolved = yield* Deferred.await(decision);
7061183 pendingApprovals.delete(requestId);
1184 yield* resumeSessionTurnLiveness(input.threadId, turnId);
7071185 yield* offerRuntimeEvent(
7081186 makeAcpRequestResolvedEvent({
7091187 stamp: yield* makeEventStamp(),
......@@ -716,7 +1194,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
7161194 }),
7171195 );
7181196 const selectedOptionId =
719 resolved === "cancel" ? undefined : selectPermissionOptionId(params, resolved);
1197 resolved === "cancel"
1198 ? undefined
1199 : selectGrokPermissionOptionId(params, resolved);
1200 if (
1201 resolved === "acceptForSession" &&
1202 selectedOptionId &&
1203 approvalKey !== undefined
1204 ) {
1205 sessionApprovedOperations.add(approvalKey);
1206 }
7201207 return {
7211208 outcome: selectedOptionId
7221209 ? {
......@@ -738,10 +1225,22 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
7381225 const requestedStartModelId = grokModelSelection?.model
7391226 ? resolveGrokAcpBaseModelId(grokModelSelection.model)
7401227 : undefined;
1228 const currentStartModelId = currentGrokModelIdFromSessionSetup(
1229 started.sessionSetupResult,
1230 );
1231 const currentStartReasoningEffort = currentGrokReasoningEffortFromSessionSetup(
1232 started.sessionSetupResult,
1233 );
1234 const requestedStartReasoningEffort = getModelSelectionStringOptionValue(
1235 grokModelSelection,
1236 "reasoningEffort",
1237 );
7411238 const boundModelId = yield* applyGrokAcpModelSelection({
7421239 runtime: acp,
743 currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult),
1240 currentModelId: currentStartModelId,
1241 currentReasoningEffort: currentStartReasoningEffort,
7441242 requestedModelId: requestedStartModelId,
1243 requestedReasoningEffort: requestedStartReasoningEffort,
7451244 mapError: (cause) =>
7461245 mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
7471246 });
......@@ -774,10 +1273,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
7741273 pendingUserInputs,
7751274 turns: [],
7761275 lastPlanFingerprint: undefined,
1276 lastKnownProposedPlanMarkdown: undefined,
1277 lastKnownProposedPlanTurnId: undefined,
1278 planModeActive: false,
7771279 activeTurnId: undefined,
7781280 interruptedTurnIds: new Set(),
7791281 promptsInFlight: 0,
1282 livenessSignals: yield* Queue.sliding<GrokTurnLivenessSignal>(1),
1283 livenessTurnId: undefined,
1284 lastTurnActivityAtNanos: undefined,
1285 activeToolCallIds: new Set(),
1286 livenessUpdatesInFlight: 0,
1287 promptResponsesReady: 0,
7801288 currentModelId: boundModelId,
1289 currentReasoningEffort:
1290 requestedStartReasoningEffort !== undefined
1291 ? normalizeGrokReasoningEffort(requestedStartReasoningEffort)
1292 : currentStartReasoningEffort,
7811293 stopped: false,
7821294 };
7831295
......@@ -807,6 +1319,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
8071319 ) {
8081320 return;
8091321 }
1322 if (
1323 event._tag === "AssistantItemStarted" ||
1324 event._tag === "AssistantItemCompleted" ||
1325 event._tag === "PlanUpdated" ||
1326 event._tag === "ToolCallUpdated" ||
1327 event._tag === "ContentDelta"
1328 ) {
1329 yield* recordTurnActivity(ctx, notificationTurnId, event);
1330 }
8101331 const stamp = yield* makeEventStamp();
8111332
8121333 switch (event._tag) {
......@@ -844,7 +1365,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
8441365 "session/update",
8451366 );
8461367 return;
847 case "ToolCallUpdated":
1368 case "ToolCallUpdated": {
8481369 yield* offerRuntimeEvent(
8491370 makeAcpToolCallEvent({
8501371 stamp,
......@@ -855,7 +1376,30 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
8551376 rawPayload: event.rawPayload,
8561377 }),
8571378 );
1379 ctx.planModeActive = nextGrokPlanModeActive(ctx.planModeActive, event.toolCall);
1380 // Only promote session plan.md writes while plan mode is
1381 // active — avoids treating unrelated plan files as proposals.
1382 // Fresh stamp: must not share eventId with the tool lifecycle event.
1383 if (ctx.planModeActive) {
1384 const planMarkdown = extractGrokPlanMarkdownFromToolCallData(
1385 event.toolCall.data,
1386 grokPlanPathHost,
1387 );
1388 if (planMarkdown !== undefined) {
1389 yield* emitProposedPlanCompleted(
1390 ctx,
1391 notificationTurnId,
1392 yield* makeEventStamp(),
1393 planMarkdown,
1394 {
1395 method: "session/update",
1396 payload: event.rawPayload,
1397 },
1398 );
1399 }
1400 }
8581401 return;
1402 }
8591403 case "ContentDelta":
8601404 yield* offerRuntimeEvent(
8611405 makeAcpContentDeltaEvent({
......@@ -887,6 +1431,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
8871431
8881432 ctx.notificationFiber = nf;
8891433 sessions.set(input.threadId, ctx);
1434 yield* runTurnLivenessWatchdog(ctx).pipe(Effect.forkIn(ctx.scope), Effect.asVoid);
8901435 sessionScopeTransferred = true;
8911436
8921437 yield* offerRuntimeEvent({
......@@ -933,6 +1478,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
9331478 // Bind the turn id before cooperative yields so interruptTurn can
9341479 // settle this prompt even if stop arrives during preparation.
9351480 ctx.activeTurnId = turnId;
1481 // New turn: do not fall back to a previous turn's plan.md body when
1482 // exit_plan_mode omits planContent.
1483 if (steeringTurnId === undefined) {
1484 clearProposedPlanFallback(ctx);
1485 }
9361486 ctx.session = {
9371487 ...ctx.session,
9381488 status: steeringTurnId === undefined ? "connecting" : "running",
......@@ -948,13 +1498,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
9481498 const requestedTurnModelId = turnModelSelection?.model
9491499 ? resolveGrokAcpBaseModelId(turnModelSelection.model)
9501500 : undefined;
951 const currentModelId = yield* applyGrokAcpModelSelection({
952 runtime: ctx.acp,
953 currentModelId: ctx.currentModelId,
954 requestedModelId: requestedTurnModelId,
955 mapError: (cause) =>
956 mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
957 });
1501 const requestedTurnReasoningEffort = getModelSelectionStringOptionValue(
1502 turnModelSelection,
1503 "reasoningEffort",
1504 );
9581505
9591506 const text = input.input?.trim();
9601507 const imagePromptParts = yield* Effect.forEach(
......@@ -1003,7 +1550,21 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
10031550 });
10041551 }
10051552
1553 const currentModelId = yield* applyGrokAcpModelSelection({
1554 runtime: ctx.acp,
1555 currentModelId: ctx.currentModelId,
1556 currentReasoningEffort: ctx.currentReasoningEffort,
1557 requestedModelId: requestedTurnModelId,
1558 requestedReasoningEffort: requestedTurnReasoningEffort,
1559 mapError: (cause) =>
1560 mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
1561 });
10061562 ctx.currentModelId = currentModelId;
1563 if (requestedTurnReasoningEffort !== undefined) {
1564 ctx.currentReasoningEffort = normalizeGrokReasoningEffort(
1565 requestedTurnReasoningEffort,
1566 );
1567 }
10071568 const displayModel = currentModelId
10081569 ? resolveGrokAcpBaseModelId(currentModelId)
10091570 : undefined;
......@@ -1032,6 +1593,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
10321593 updatedAt: yield* nowIso,
10331594 ...(displayModel ? { model: displayModel } : {}),
10341595 };
1596 if (steeringTurnId === undefined) {
1597 yield* beginTurnLiveness(ctx, turnId);
1598 } else {
1599 yield* refreshSessionTurnLiveness(input.threadId, turnId);
1600 }
10351601
10361602 if (steeringTurnId === undefined) {
10371603 yield* offerRuntimeEvent({
......@@ -1082,10 +1648,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
10821648 })
10831649 .pipe(
10841650 Effect.tap((promptResult) =>
1085 Effect.all([
1086 Ref.set(promptRpcSucceeded, true),
1087 Ref.set(promptResultRef, promptResult),
1088 ]),
1651 Effect.all(
1652 [
1653 Ref.set(promptRpcSucceeded, true),
1654 Ref.set(promptResultRef, promptResult),
1655 markPromptResponseReady(input.threadId, prepared.acpSessionId, prepared.turnId),
1656 ],
1657 { discard: true },
1658 ),
10891659 ),
10901660 Effect.tapError((error) =>
10911661 Ref.set(
......@@ -1126,6 +1696,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
11261696 yield* Effect.yieldNow;
11271697 }
11281698 yield* prepared.acp.drainEvents;
1699 consumePromptResponseReady(ctx);
11291700 if (ctx.interruptedTurnIds.has(prepared.turnId)) {
11301701 yield* Ref.set(promptSettled, true);
11311702 return {
......@@ -1184,6 +1755,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
11841755 updatedAt: completedAt,
11851756 ...(prepared.displayModel ? { model: prepared.displayModel } : {}),
11861757 };
1758 yield* clearTurnLiveness(ctx);
11871759 const completedStopReason = completedStopReasonFromPromptResponse(result);
11881760 yield* offerRuntimeEvent({
11891761 type: "turn.completed",
......@@ -1240,6 +1812,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
12401812 if (ctx.interruptedTurnIds.has(prepared.turnId)) {
12411813 return;
12421814 }
1815 consumePromptResponseReady(ctx);
12431816 if (
12441817 ctx.promptsInFlight <= 0 ||
12451818 ctx.activeTurnId !== prepared.turnId ||
......@@ -1356,6 +1929,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
13561929 status: "ready",
13571930 updatedAt,
13581931 };
1932 yield* clearTurnLiveness(ctx);
13591933 }
13601934 }),
13611935 );
apps/server/src/provider/Layers/GrokProvider.test.ts+174-1
......@@ -6,10 +6,183 @@ import * as Path from "effect/Path";
66import * as Schema from "effect/Schema";
77import { GrokSettings } from "@t3tools/contracts";
88
9import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts";
9import {
10 buildGrokModelCapabilities,
11 buildInitialGrokProviderSnapshot,
12 checkGrokProviderStatus,
13} from "./GrokProvider.ts";
1014
1115const decodeGrokSettings = Schema.decodeSync(GrokSettings);
1216
17describe("buildGrokModelCapabilities", () => {
18 it("preserves ACP-provided reasoning labels and the active default", () => {
19 const capabilities = buildGrokModelCapabilities({
20 modelId: "grok-4.6",
21 name: "Grok 4.6",
22 _meta: {
23 supportsReasoningEffort: true,
24 reasoningEffort: "xhigh",
25 reasoningEfforts: [
26 { value: "xhigh", label: "Extra High Effort", default: true },
27 { value: "high", label: "High Effort", default: true },
28 { value: "medium", label: "Medium Effort" },
29 { value: "low", label: "Low Effort" },
30 ],
31 },
32 });
33
34 expect(capabilities.optionDescriptors).toEqual([
35 {
36 id: "reasoningEffort",
37 label: "Reasoning",
38 type: "select",
39 currentValue: "xhigh",
40 options: [
41 { id: "xhigh", label: "Extra High Effort", isDefault: true },
42 { id: "high", label: "High Effort" },
43 { id: "medium", label: "Medium Effort" },
44 { id: "low", label: "Low Effort" },
45 ],
46 },
47 ]);
48 });
49
50 it("uses raw ACP values when option labels are omitted", () => {
51 const capabilities = buildGrokModelCapabilities({
52 modelId: "grok-4.6",
53 name: "Grok 4.6",
54 _meta: {
55 supportsReasoningEffort: true,
56 reasoningEffort: "xhigh",
57 reasoningEfforts: [{ value: "xhigh" }, { value: "medium" }],
58 },
59 });
60
61 expect(capabilities.optionDescriptors).toEqual([
62 {
63 id: "reasoningEffort",
64 label: "Reasoning",
65 type: "select",
66 currentValue: "xhigh",
67 options: [
68 { id: "xhigh", label: "xhigh" },
69 { id: "medium", label: "medium" },
70 ],
71 },
72 ]);
73 });
74
75 it("keeps ACP current effort separate from its collapsed advertised default", () => {
76 const capabilities = buildGrokModelCapabilities({
77 modelId: "grok-4.6",
78 name: "Grok 4.6",
79 _meta: {
80 supportsReasoningEffort: true,
81 reasoningEffort: "medium",
82 reasoningEfforts: [
83 { value: "xhigh", label: "Extra High Effort", default: true },
84 { value: "high", label: "High Effort", default: true },
85 { value: "medium", label: "Medium Effort" },
86 ],
87 },
88 });
89
90 expect(capabilities.optionDescriptors).toEqual([
91 {
92 id: "reasoningEffort",
93 label: "Reasoning",
94 type: "select",
95 currentValue: "medium",
96 options: [
97 { id: "xhigh", label: "Extra High Effort", isDefault: true },
98 { id: "high", label: "High Effort" },
99 { id: "medium", label: "Medium Effort" },
100 ],
101 },
102 ]);
103 });
104
105 it("preserves ACP descriptions and falls back from invalid values to valid ids", () => {
106 const capabilities = buildGrokModelCapabilities({
107 modelId: "grok-4.6",
108 name: "Grok 4.6",
109 _meta: {
110 supportsReasoningEffort: true,
111 reasoningEffort: "high",
112 reasoningEfforts: [
113 {
114 id: "high",
115 value: "not a token",
116 label: "High Effort",
117 description: "Higher implementation quality",
118 default: true,
119 },
120 { id: "bad id", value: "also invalid", label: "Invalid" },
121 ],
122 },
123 });
124
125 expect(capabilities.optionDescriptors).toEqual([
126 {
127 id: "reasoningEffort",
128 label: "Reasoning",
129 type: "select",
130 currentValue: "high",
131 options: [
132 {
133 id: "high",
134 label: "High Effort",
135 description: "Higher implementation quality",
136 isDefault: true,
137 },
138 ],
139 },
140 ]);
141 });
142
143 it("accepts an advertised ACP menu when the support flag is omitted", () => {
144 const capabilities = buildGrokModelCapabilities({
145 modelId: "grok-4.6",
146 name: "Grok 4.6",
147 _meta: {
148 reasoningEffort: "high",
149 reasoningEfforts: [{ value: "high", label: "High Effort", default: true }],
150 },
151 });
152
153 expect(capabilities.optionDescriptors).toHaveLength(1);
154 });
155
156 it("honors an explicit ACP opt-out even when a menu is present", () => {
157 const capabilities = buildGrokModelCapabilities({
158 modelId: "grok-4.6",
159 name: "Grok 4.6",
160 _meta: {
161 supportsReasoningEffort: false,
162 reasoningEfforts: [{ value: "high", label: "High Effort", default: true }],
163 },
164 });
165
166 expect(capabilities.optionDescriptors).toEqual([]);
167 });
168
169 it("does not synthesize a reasoning menu when ACP omits it", () => {
170 expect(
171 buildGrokModelCapabilities({
172 modelId: "grok-4.6",
173 name: "Grok 4.6",
174 _meta: { supportsReasoningEffort: true, reasoningEffort: "xhigh" },
175 }).optionDescriptors,
176 ).toEqual([]);
177 });
178
179 it("keeps non-reasoning Grok models free of reasoning controls", () => {
180 expect(
181 buildGrokModelCapabilities({ modelId: "grok-build", name: "Grok Build" }).optionDescriptors,
182 ).toEqual([]);
183 });
184});
185
13186describe("buildInitialGrokProviderSnapshot", () => {
14187 it.effect("returns a disabled snapshot when settings.enabled is false", () =>
15188 Effect.gen(function* () {
apps/server/src/provider/Layers/GrokProvider.ts+111-2
......@@ -29,7 +29,12 @@ import {
2929 enrichProviderSnapshotWithVersionAdvisory,
3030 type ProviderMaintenanceCapabilities,
3131} from "../providerMaintenance.ts";
32import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts";
32import {
33 isValidGrokReasoningEffortToken,
34 makeGrokAcpRuntime,
35 resolveGrokAcpBaseModelId,
36} from "../acp/GrokAcpSupport.ts";
37import { discoverGrokSkills } from "../Drivers/GrokSkills.ts";
3338
3439const GROK_PRESENTATION = {
3540 displayName: "Grok",
......@@ -99,6 +104,104 @@ function grokModelsFromSettings(
99104 return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
100105}
101106
107function nonEmptyString(value: unknown): string | undefined {
108 return typeof value === "string" ? value.trim() || undefined : undefined;
109}
110
111function isRecord(value: unknown): value is Record<string, unknown> {
112 return typeof value === "object" && value !== null && !Array.isArray(value);
113}
114
115function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): {
116 readonly options: ReadonlyArray<{
117 value: string;
118 label: string;
119 description?: string;
120 isDefault?: boolean;
121 }>;
122 readonly currentValue: string | undefined;
123} {
124 const meta = model._meta;
125 if (!meta || meta.supportsReasoningEffort === false) {
126 return { options: [], currentValue: undefined };
127 }
128
129 const currentEffort = nonEmptyString(meta.reasoningEffort);
130 const advertisedOptions = Array.isArray(meta.reasoningEfforts) ? meta.reasoningEfforts : [];
131 const seen = new Set<string>();
132 const options: Array<{
133 value: string;
134 label: string;
135 description?: string;
136 advertisedDefault: boolean;
137 }> = [];
138
139 for (const entry of advertisedOptions) {
140 if (!isRecord(entry)) {
141 continue;
142 }
143 const rawValue = nonEmptyString(entry.value);
144 const rawId = nonEmptyString(entry.id);
145 const value =
146 rawValue && isValidGrokReasoningEffortToken(rawValue)
147 ? rawValue
148 : rawId && isValidGrokReasoningEffortToken(rawId)
149 ? rawId
150 : undefined;
151 if (value === undefined || seen.has(value)) {
152 continue;
153 }
154 seen.add(value);
155 const description = nonEmptyString(entry.description);
156 options.push({
157 value,
158 label: nonEmptyString(entry.label) ?? value,
159 ...(description ? { description } : {}),
160 advertisedDefault: entry.default === true || entry.isDefault === true,
161 });
162 }
163
164 const currentValue =
165 currentEffort && options.some((option) => option.value === currentEffort)
166 ? currentEffort
167 : undefined;
168 const advertisedDefaults = options.filter((option) => option.advertisedDefault);
169 const selectedDefault =
170 advertisedDefaults.find((option) => option.value === currentValue)?.value ??
171 advertisedDefaults[0]?.value;
172 return {
173 options: options.map(({ value, label, description }) => ({
174 value,
175 label,
176 ...(description ? { description } : {}),
177 ...(value === selectedDefault ? { isDefault: true } : {}),
178 })),
179 currentValue: currentValue ?? selectedDefault,
180 };
181}
182
183export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): ModelCapabilities {
184 const reasoning = grokReasoningOptionsFromModel(model);
185 return reasoning.options.length > 0
186 ? createModelCapabilities({
187 optionDescriptors: [
188 {
189 id: "reasoningEffort",
190 label: "Reasoning",
191 type: "select",
192 options: reasoning.options.map((option) => ({
193 id: option.value,
194 label: option.label,
195 ...(option.description ? { description: option.description } : {}),
196 ...(option.isDefault ? { isDefault: true } : {}),
197 })),
198 ...(reasoning.currentValue ? { currentValue: reasoning.currentValue } : {}),
199 },
200 ],
201 })
202 : EMPTY_CAPABILITIES;
203}
204
102205function buildGrokDiscoveredModelsFromSessionModelState(
103206 modelState: EffectAcpSchema.SessionModelState | null | undefined,
104207): ReadonlyArray<ServerProviderModel> {
......@@ -117,7 +220,7 @@ function buildGrokDiscoveredModelsFromSessionModelState(
117220 slug,
118221 name: model.name.trim() || slug,
119222 isCustom: false,
120 capabilities: EMPTY_CAPABILITIES,
223 capabilities: buildGrokModelCapabilities(model),
121224 };
122225 })
123226 .filter((model): model is ServerProviderModel => model !== undefined);
......@@ -161,6 +264,7 @@ const runGrokVersionCommand = (
161264export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
162265 grokSettings: GrokSettings,
163266 environment: NodeJS.ProcessEnv = process.env,
267 cwd?: string,
164268): Effect.fn.Return<
165269 ServerProviderDraft,
166270 never,
......@@ -251,6 +355,8 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
251355 });
252356 }
253357
358 const skills = yield* discoverGrokSkills(grokSettings, environment, cwd);
359
254360 const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe(
255361 Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS),
256362 Effect.exit,
......@@ -264,6 +370,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
264370 enabled: grokSettings.enabled,
265371 checkedAt,
266372 models: fallbackModels,
373 skills,
267374 probe: {
268375 installed: true,
269376 version,
......@@ -282,6 +389,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
282389 enabled: grokSettings.enabled,
283390 checkedAt,
284391 models: fallbackModels,
392 skills,
285393 probe: {
286394 installed: true,
287395 version,
......@@ -302,6 +410,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
302410 enabled: grokSettings.enabled,
303411 checkedAt,
304412 models,
413 skills,
305414 probe: {
306415 installed: true,
307416 version,
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+17-8
......@@ -330,7 +330,7 @@ describe("AcpSessionRuntime", () => {
330330 ),
331331 );
332332
333 it.effect("suppresses generic placeholder tool updates until completion", () =>
333 it.effect("emits status-only tool updates through completion", () =>
334334 Effect.gen(function* () {
335335 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
336336 yield* runtime.start();
......@@ -340,13 +340,22 @@ describe("AcpSessionRuntime", () => {
340340 });
341341 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
342342
343 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 1)));
344 expect(notes.map((note) => note._tag)).toEqual(["ToolCallUpdated"]);
345 const toolCall = notes[0];
346 expect(toolCall?._tag).toBe("ToolCallUpdated");
347 if (toolCall?._tag === "ToolCallUpdated") {
348 expect(toolCall.toolCall.status).toBe("completed");
349 expect(toolCall.toolCall.title).toBe("Read file");
343 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 3)));
344 expect(notes.map((note) => note._tag)).toEqual([
345 "ToolCallUpdated",
346 "ToolCallUpdated",
347 "ToolCallUpdated",
348 ]);
349 const toolCalls = notes.flatMap((note) =>
350 note._tag === "ToolCallUpdated" ? [note.toolCall] : [],
351 );
352 expect(toolCalls.map((toolCall) => toolCall.status)).toEqual([
353 "pending",
354 "inProgress",
355 "completed",
356 ]);
357 for (const toolCall of toolCalls) {
358 expect(toolCall.title).toBe("Read file");
350359 }
351360 }).pipe(
352361 Effect.provide(
apps/server/src/provider/acp/AcpRuntimeModel.test.ts+188-2
......@@ -11,6 +11,7 @@ import {
1111 parseSessionUpdateEvent,
1212 sessionUpdateIsReplay,
1313 syntheticLoadSessionResponseFromInitialize,
14 toolCallProgressLength,
1415 type AcpToolCallState,
1516} from "./AcpRuntimeModel.ts";
1617
......@@ -542,6 +543,130 @@ describe("AcpRuntimeModel", () => {
542543 });
543544 });
544545
546 it("keeps a retained tail on the original text entries around non-text content", () => {
547 const prefix = "a".repeat(4_000);
548 const suffix = "b".repeat(5_000);
549 const { events } = parseSessionUpdateEvent({
550 sessionId: "session-1",
551 update: {
552 sessionUpdate: "tool_call_update",
553 toolCallId: "tool-1",
554 kind: "edit",
555 status: "in_progress",
556 content: [
557 { type: "content", content: { type: "text", text: prefix } },
558 { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" },
559 { type: "content", content: { type: "text", text: suffix } },
560 ],
561 },
562 } satisfies EffectAcpSchema.SessionNotification);
563
564 const event = events[0];
565 if (event?._tag !== "ToolCallUpdated") {
566 throw new Error("expected a ToolCallUpdated event");
567 }
568 const content = event.toolCall.data.content as ReadonlyArray<EffectAcpSchema.ToolCallContent>;
569 expect(content).toHaveLength(3);
570 const firstText = content[0];
571 if (firstText?.type !== "content" || firstText.content.type !== "text") {
572 throw new Error("expected a bounded prefix text entry");
573 }
574 expect(firstText.content.text.startsWith("[Earlier output truncated]")).toBe(true);
575 expect(firstText.content.text.endsWith("a".repeat(100))).toBe(true);
576 expect(content[1]).toEqual({
577 type: "diff",
578 path: "/repo/file.ts",
579 oldText: "before",
580 newText: "after",
581 });
582 expect(content[2]).toEqual({
583 type: "content",
584 content: { type: "text", text: suffix },
585 });
586 });
587
588 it("bounds oversized whitespace-only tool call content that has no trimmed text", () => {
589 // Whitespace-only entries are skipped when extracting display text (`chunks.length === 0`)
590 // and used to be returned unchanged, which let a redrawing terminal persist unbounded
591 // buffers on `toolCall.data.content` and `rawPayload`.
592 const hugeWhitespace = " \n\t".repeat(30_000);
593 expect(hugeWhitespace.length).toBeGreaterThan(60_000);
594
595 const { events } = parseSessionUpdateEvent({
596 sessionId: "session-1",
597 update: {
598 sessionUpdate: "tool_call_update",
599 toolCallId: "tool-1",
600 kind: "other",
601 status: "in_progress",
602 content: [{ type: "content", content: { type: "text", text: hugeWhitespace } }],
603 },
604 } satisfies EffectAcpSchema.SessionNotification);
605
606 const event = events[0];
607 if (event?._tag !== "ToolCallUpdated") {
608 throw new Error("expected a ToolCallUpdated event");
609 }
610
611 expect(event.toolCall.detail).toBeUndefined();
612 const content = event.toolCall.data.content as ReadonlyArray<EffectAcpSchema.ToolCallContent>;
613 const textEntry = content[0];
614 if (textEntry?.type !== "content" || textEntry.content.type !== "text") {
615 throw new Error("expected a bounded text entry");
616 }
617 expect(textEntry.content.text.length).toBeLessThan(8_100);
618 expect(textEntry.content.text.startsWith("[Earlier output truncated]")).toBe(true);
619
620 const rawUpdate = (
621 event.rawPayload as {
622 readonly update: {
623 readonly content: ReadonlyArray<{ readonly content: { text: string } }>;
624 };
625 }
626 ).update;
627 expect(rawUpdate.content[0]?.content.text.length).toBeLessThan(8_100);
628 expect(JSON.stringify(event).length).toBeLessThan(hugeWhitespace.length);
629 });
630
631 it("bounds oversized whitespace-padded text entries even when trimmed content fits", () => {
632 const padded = `${" ".repeat(40_000)}ok${" ".repeat(40_000)}`;
633 expect(padded.length).toBeGreaterThan(60_000);
634
635 const { events } = parseSessionUpdateEvent({
636 sessionId: "session-1",
637 update: {
638 sessionUpdate: "tool_call_update",
639 toolCallId: "tool-1",
640 kind: "other",
641 status: "in_progress",
642 content: [{ type: "content", content: { type: "text", text: padded } }],
643 },
644 } satisfies EffectAcpSchema.SessionNotification);
645
646 const event = events[0];
647 if (event?._tag !== "ToolCallUpdated") {
648 throw new Error("expected a ToolCallUpdated event");
649 }
650
651 expect(event.toolCall.detail).toBe("ok");
652 const content = event.toolCall.data.content as ReadonlyArray<EffectAcpSchema.ToolCallContent>;
653 const textEntry = content[0];
654 if (textEntry?.type !== "content" || textEntry.content.type !== "text") {
655 throw new Error("expected a bounded text entry");
656 }
657 expect(textEntry.content.text).toBe("ok");
658
659 const rawUpdate = (
660 event.rawPayload as {
661 readonly update: {
662 readonly content: ReadonlyArray<{ readonly content: { text: string } }>;
663 };
664 }
665 ).update;
666 expect(rawUpdate.content[0]?.content.text).toBe("ok");
667 expect(JSON.stringify(event).length).toBeLessThan(padded.length);
668 });
669
545670 describe("decideToolCallUpdateEmission", () => {
546671 const toolCall = (detail: string | undefined, status?: AcpToolCallState["status"]) =>
547672 ({
......@@ -552,6 +677,17 @@ describe("AcpRuntimeModel", () => {
552677 data: {},
553678 }) satisfies AcpToolCallState;
554679
680 it("emits the first in-progress tool_call even when it has no detail", () => {
681 expect(
682 decideToolCallUpdateEmission({
683 previous: undefined,
684 next: { toolCallId: "tool-1", title: "Grok Tool", status: "pending", data: {} },
685 lastEmittedDetailLength: undefined,
686 skippedSinceEmit: 0,
687 }),
688 ).toEqual({ emit: true, skippedSinceEmit: 0 });
689 });
690
555691 it("always emits terminal (completed/failed) status updates regardless of growth", () => {
556692 expect(
557693 decideToolCallUpdateEmission({
......@@ -573,16 +709,66 @@ describe("AcpRuntimeModel", () => {
573709 });
574710
575711 it("skips updates whose bounded detail did not change", () => {
712 const previous = toolCall("frame 1", "inProgress");
576713 expect(
577714 decideToolCallUpdateEmission({
578 previous: toolCall("frame 1", "inProgress"),
579 next: toolCall("frame 1", "inProgress"),
715 previous,
716 next: previous,
580717 lastEmittedDetailLength: 7,
581718 skippedSinceEmit: 0,
582719 }),
583720 ).toEqual({ emit: false, skippedSinceEmit: 0 });
584721 });
585722
723 it("coalesces command-tool updates whose content grew while detail stayed the command", () => {
724 const commandCall = (stdout: string): AcpToolCallState => ({
725 toolCallId: "tool-1",
726 title: "Ran command",
727 status: "inProgress",
728 command: "ls",
729 detail: "ls",
730 data: {
731 command: "ls",
732 content: [{ type: "content", content: { type: "text", text: stdout } }],
733 },
734 });
735
736 let previous: AcpToolCallState | undefined;
737 let lastEmittedDetailLength: number | undefined;
738 let skippedSinceEmit = 0;
739 const emissions: Array<boolean> = [];
740
741 for (let i = 1; i <= 12; i += 1) {
742 const next = commandCall("x".repeat(i));
743 const decision = decideToolCallUpdateEmission({
744 previous,
745 next,
746 lastEmittedDetailLength,
747 skippedSinceEmit,
748 });
749 emissions.push(decision.emit);
750 skippedSinceEmit = decision.skippedSinceEmit;
751 if (decision.emit) {
752 lastEmittedDetailLength = toolCallProgressLength(next);
753 }
754 previous = next;
755 }
756
757 const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : []));
758 expect(emittedIndices).toEqual([1, 11]);
759 });
760
761 it("emits pending to inProgress status changes even when detail and output are unchanged", () => {
762 expect(
763 decideToolCallUpdateEmission({
764 previous: toolCall("same", "pending"),
765 next: toolCall("same", "inProgress"),
766 lastEmittedDetailLength: 4,
767 skippedSinceEmit: 0,
768 }),
769 ).toEqual({ emit: true, skippedSinceEmit: 0 });
770 });
771
586772 it("emits immediately when the title changes, even with no growth", () => {
587773 const decision = decideToolCallUpdateEmission({
588774 previous: { toolCallId: "tool-1", title: "Reading file", detail: "x", data: {} },
apps/server/src/provider/acp/AcpRuntimeModel.ts+123-21
......@@ -316,6 +316,32 @@ function toolCallContentText(entry: EffectAcpSchema.ToolCallContent): string | u
316316 return entry.content.text;
317317}
318318
319// Trim is used for display `text`, so whitespace-only (or whitespace-padded) entries never
320// contribute to `chunks` and used to take the early returns with the original array. Bound
321// each text entry independently so those paths cannot persist an unbounded terminal buffer
322// on `toolCall.data.content` / `rawPayload`.
323function boundToolCallContentEntries(
324 content: ReadonlyArray<EffectAcpSchema.ToolCallContent>,
325): ReadonlyArray<EffectAcpSchema.ToolCallContent> {
326 let changed = false;
327 const bounded = content.map((entry) => {
328 const text = toolCallContentText(entry);
329 if (text === undefined || text.length <= TOOL_CALL_CONTENT_MAX_CHARS) {
330 return entry;
331 }
332 changed = true;
333 const trimmed = text.trim();
334 return {
335 type: "content",
336 content: {
337 type: "text",
338 text: boundToolCallOutputText(trimmed.length > 0 ? trimmed : text),
339 },
340 } as const;
341 });
342 return changed ? bounded : content;
343}
344
319345function extractTextContentFromToolCallContent(
320346 content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined,
321347): ExtractedToolCallContent {
......@@ -330,31 +356,74 @@ function extractTextContentFromToolCallContent(
330356 }
331357 }
332358 if (chunks.length === 0) {
333 return { text: undefined, content };
359 return { text: undefined, content: boundToolCallContentEntries(content) };
334360 }
335361 const joined = chunks.join("\n");
336362 if (joined.length <= TOOL_CALL_CONTENT_MAX_CHARS) {
337 return { text: joined, content };
363 return { text: joined, content: boundToolCallContentEntries(content) };
338364 }
339365 const bounded = boundToolCallOutputText(joined);
340 // Collapse the text entries into a single bounded one at the final contributing text entry,
341 // and leave every other entry kind (diffs, images, resource links) in its original relative
342 // order. The retained tail came from that text entry, so placing it there also preserves its
343 // ordering relative to interleaved non-text content and ignores later blank text entries.
344 const lastContributingTextIndex = content.reduce(
345 (lastIndex, entry, index) => (toolCallContentText(entry)?.trim() ? index : lastIndex),
346 -1,
347 );
348 const boundedContent = content.flatMap((entry, index) => {
366 const tail = joined.slice(joined.length - TOOL_CALL_CONTENT_MAX_CHARS);
367 return {
368 text: bounded,
369 content: distributeRetainedTailAcrossContent(content, tail),
370 };
371}
372
373// Walk the original text entries from the joined tail window so a retained slice that
374// spans entries around an image/diff stays on those entries. Non-text kinds keep their
375// relative order; blank text entries are dropped; the truncation marker is prepended to
376// the first remaining text entry.
377function distributeRetainedTailAcrossContent(
378 content: ReadonlyArray<EffectAcpSchema.ToolCallContent>,
379 tail: string,
380): ReadonlyArray<EffectAcpSchema.ToolCallContent> {
381 const textRanges: Array<
382 | {
383 readonly start: number;
384 readonly end: number;
385 readonly text: string;
386 }
387 | undefined
388 > = Array.from({ length: content.length });
389 let offset = 0;
390 let seenText = false;
391 for (const [index, entry] of content.entries()) {
392 const text = toolCallContentText(entry)?.trim();
393 if (!text) {
394 continue;
395 }
396 if (seenText) {
397 offset += 1;
398 }
399 seenText = true;
400 const start = offset;
401 const end = offset + text.length;
402 textRanges[index] = { start, end, text };
403 offset = end;
404 }
405 const tailStart = Math.max(0, offset - tail.length);
406 let markerPending = true;
407 return content.flatMap((entry, index) => {
349408 if (toolCallContentText(entry) === undefined) {
350409 return [entry];
351410 }
352 if (index !== lastContributingTextIndex) {
411 const range = textRanges[index];
412 if (range === undefined) {
413 return [];
414 }
415 const overlapStart = Math.max(range.start, tailStart);
416 const overlapEnd = Math.min(range.end, offset);
417 if (overlapEnd <= overlapStart) {
353418 return [];
354419 }
355 return [{ type: "content", content: { type: "text", text: bounded } } as const];
420 let piece = range.text.slice(overlapStart - range.start, overlapEnd - range.start);
421 if (markerPending) {
422 piece = `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${piece}`;
423 markerPending = false;
424 }
425 return [{ type: "content", content: { type: "text", text: piece } } as const];
356426 });
357 return { text: bounded, content: boundedContent };
358427}
359428
360429function normalizeToolKind(kind: unknown): string | undefined {
......@@ -518,6 +587,42 @@ export interface AcpToolCallEmitDecision {
518587 readonly skippedSinceEmit: number;
519588}
520589
590function toolCallOutputUnchanged(previous: AcpToolCallState, next: AcpToolCallState): boolean {
591 return (
592 previous.data.content === next.data.content && previous.data.rawOutput === next.data.rawOutput
593 );
594}
595
596// Command tools keep `detail` equal to the command, so live stdout lives on
597// `data.content` / `data.rawOutput`. Measure that too, otherwise coalescing never
598// sees growth and in-progress output is held until completed/failed.
599export function toolCallProgressLength(state: AcpToolCallState): number {
600 let contentChars = 0;
601 const content = state.data.content;
602 if (Array.isArray(content)) {
603 for (const entry of content) {
604 if (!isRecord(entry)) {
605 continue;
606 }
607 const text = toolCallContentText(entry as EffectAcpSchema.ToolCallContent);
608 if (text) {
609 contentChars += text.length;
610 }
611 }
612 }
613 let rawOutputChars = 0;
614 const rawOutput = state.data.rawOutput;
615 if (isRecord(rawOutput)) {
616 for (const field of RAW_OUTPUT_TEXT_FIELDS) {
617 const value = rawOutput[field];
618 if (typeof value === "string") {
619 rawOutputChars += value.length;
620 }
621 }
622 }
623 return Math.max(state.detail?.length ?? 0, contentChars, rawOutputChars);
624}
625
521626export function decideToolCallUpdateEmission(
522627 input: AcpToolCallEmitDecisionInput,
523628): AcpToolCallEmitDecision {
......@@ -525,19 +630,16 @@ export function decideToolCallUpdateEmission(
525630 if (next.status === "completed" || next.status === "failed") {
526631 return { emit: true, skippedSinceEmit: 0 };
527632 }
528 if (!next.detail) {
529 return { emit: false, skippedSinceEmit };
530 }
531 if (previous === undefined || previous.title !== next.title) {
633 if (previous === undefined || previous.title !== next.title || previous.status !== next.status) {
532634 return { emit: true, skippedSinceEmit: 0 };
533635 }
534 if (previous.detail === next.detail) {
636 if (previous.detail === next.detail && toolCallOutputUnchanged(previous, next)) {
535637 return { emit: false, skippedSinceEmit };
536638 }
639 const progressLength = toolCallProgressLength(next);
537640 const grewMeaningfully =
538641 lastEmittedDetailLength === undefined ||
539 Math.abs(next.detail.length - lastEmittedDetailLength) >=
540 TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS;
642 Math.abs(progressLength - lastEmittedDetailLength) >= TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS;
541643 if (grewMeaningfully || skippedSinceEmit + 1 >= TOOL_CALL_UPDATE_COALESCE_LIMIT) {
542644 return { emit: true, skippedSinceEmit: 0 };
543645 }
apps/server/src/provider/acp/AcpSessionRuntime.ts+5-2
......@@ -27,6 +27,7 @@ import {
2727 extractModelConfigId,
2828 findSessionConfigOption,
2929 mergeToolCallState,
30 toolCallProgressLength,
3031 parseSessionModeState,
3132 parseSessionUpdateEvent,
3233 sessionUpdateIsReplay,
......@@ -233,6 +234,7 @@ export class AcpSessionRuntime extends Context.Service<
233234 */
234235 readonly setSessionModel: (
235236 modelId: string,
237 meta?: EffectAcpSchema.SetSessionModelRequest["_meta"],
236238 ) => Effect.Effect<EffectAcpSchema.SetSessionModelResponse, EffectAcpErrors.AcpError>;
237239 /**
238240 * Sends a generic ACP extension request and records it through the request logger.
......@@ -796,12 +798,13 @@ export const make = (
796798 Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)),
797799 Effect.asVoid,
798800 ),
799 setSessionModel: (modelId) =>
801 setSessionModel: (modelId, meta) =>
800802 getStartedState.pipe(
801803 Effect.flatMap((started) => {
802804 const requestPayload = {
803805 sessionId: started.sessionId,
804806 modelId,
807 ...(meta !== undefined ? { _meta: meta } : {}),
805808 } satisfies EffectAcpSchema.SetSessionModelRequest;
806809 return runLoggedRequest(
807810 "session/set_model",
......@@ -893,7 +896,7 @@ const handleSessionUpdate = ({
893896 next.set(nextToolCall.toolCallId, {
894897 state: nextToolCall,
895898 lastEmittedDetailLength: decision.emit
896 ? nextToolCall.detail?.length
899 ? toolCallProgressLength(nextToolCall)
897900 : tracked?.lastEmittedDetailLength,
898901 skippedSinceEmit: decision.skippedSinceEmit,
899902 });
apps/server/src/provider/acp/GrokAcpCliProbe.test.ts+62-1
......@@ -1,6 +1,7 @@
11/**
22 * Optional integration check against a real `grok agent stdio` install.
3 * Enable with: T3_GROK_ACP_PROBE=1 bun run test GrokAcpCliProbe
3 * Enable with: T3_GROK_ACP_PROBE=1 vp test run GrokAcpCliProbe
4 * Set T3_GROK_LIVE_TURN=1 to also send a small prompt to the real model.
45 *
56 * The probe assumes either `XAI_API_KEY` is set in the environment or
67 * the user has previously run `grok login`. Without credentials the
......@@ -10,6 +11,10 @@
1011import * as NodeServices from "@effect/platform-node/NodeServices";
1112import { it } from "@effect/vitest";
1213import * as Effect from "effect/Effect";
14import * as Deferred from "effect/Deferred";
15import * as Fiber from "effect/Fiber";
16import * as FileSystem from "effect/FileSystem";
17import * as Stream from "effect/Stream";
1318import { ChildProcessSpawner } from "effect/unstable/process";
1419import { describe, expect } from "vite-plus/test";
1520
......@@ -66,4 +71,60 @@ describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () =
6671 yield* runtime.setSessionModel(currentModelId);
6772 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
6873 );
74
75 it.effect("session/set_model accepts advertised reasoning effort metadata", () =>
76 Effect.gen(function* () {
77 const runtime = yield* makeProbeRuntime;
78 const started = yield* runtime.start();
79 const modelState = started.sessionSetupResult.models;
80 const currentModelId = modelState?.currentModelId.trim();
81 expect(currentModelId).toBeDefined();
82 if (!currentModelId) return;
83
84 const currentModel = modelState?.availableModels.find(
85 (model) => model.modelId.trim() === currentModelId,
86 );
87 const reasoningEffort = currentModel?._meta?.reasoningEffort;
88 expect(typeof reasoningEffort).toBe("string");
89 if (typeof reasoningEffort !== "string") return;
90
91 yield* runtime.setSessionModel(currentModelId, { reasoningEffort });
92 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
93 );
94
95 it.effect.skipIf(process.env.T3_GROK_LIVE_TURN !== "1")(
96 "finishes a real Grok turn and streams its answer",
97 () =>
98 Effect.gen(function* () {
99 const fileSystem = yield* FileSystem.FileSystem;
100 const cwd = yield* fileSystem.makeTempDirectoryScoped();
101 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
102 const runtime = yield* makeGrokAcpRuntime({
103 grokSettings: { binaryPath: "grok" },
104 environment: process.env,
105 childProcessSpawner,
106 cwd,
107 runtimeMode: "approval-required",
108 clientInfo: { name: "t3-grok-probe", version: "0.0.0" },
109 });
110 yield* runtime.start();
111 const chunks: string[] = [];
112 const events = yield* Stream.runForEach(runtime.getEvents(), (event) => {
113 if (event._tag === "EventStreamBarrier") {
114 return Deferred.succeed(event.acknowledge, undefined);
115 }
116 if (event._tag === "ContentDelta") {
117 chunks.push(event.text);
118 }
119 return Effect.void;
120 }).pipe(Effect.forkChild);
121 const result = yield* runtime.prompt({
122 prompt: [{ type: "text", text: "Reply exactly GROK_T3_OK. Do not use any tools." }],
123 });
124 yield* runtime.drainEvents;
125 expect(result.stopReason).toBe("end_turn");
126 expect(chunks.join("")).toContain("GROK_T3_OK");
127 yield* Fiber.interrupt(events);
128 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
129 );
69130});
apps/server/src/provider/acp/GrokAcpSupport.test.ts+105-4
......@@ -5,6 +5,8 @@ import * as EffectAcpErrors from "effect-acp/errors";
55import {
66 applyGrokAcpModelSelection,
77 buildGrokAcpSpawnInput,
8 grokAcpSpawnArgs,
9 isValidGrokReasoningEffortToken,
810 resolveGrokAcpBaseModelId,
911} from "./GrokAcpSupport.ts";
1012
......@@ -16,6 +18,35 @@ describe("resolveGrokAcpBaseModelId", () => {
1618 });
1719});
1820
21describe("grokAcpSpawnArgs", () => {
22 it("inherits the Grok CLI config when no T3 runtime mode is set", () => {
23 expect(grokAcpSpawnArgs()).toEqual(["agent", "stdio"]);
24 });
25
26 it("forces Grok to ask when T3 is Supervised", () => {
27 expect(grokAcpSpawnArgs("approval-required")).toEqual([
28 "--permission-mode",
29 "default",
30 "agent",
31 "stdio",
32 ]);
33 });
34
35 it("maps Full access to Grok always-approve", () => {
36 expect(grokAcpSpawnArgs("full-access")).toEqual(["agent", "--always-approve", "stdio"]);
37 });
38
39 it("maps Auto-accept edits and Auto onto Grok permission modes", () => {
40 expect(grokAcpSpawnArgs("auto-accept-edits")).toEqual([
41 "--permission-mode",
42 "acceptEdits",
43 "agent",
44 "stdio",
45 ]);
46 expect(grokAcpSpawnArgs("auto")).toEqual(["--permission-mode", "auto", "agent", "stdio"]);
47 });
48});
49
1950describe("buildGrokAcpSpawnInput", () => {
2051 it("passes the T3 Code referrer through Grok OAuth env", () => {
2152 const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", {
......@@ -33,15 +64,38 @@ describe("buildGrokAcpSpawnInput", () => {
3364 },
3465 });
3566 });
67
68 it("puts Supervised on the Grok argv so config always-approve cannot win", () => {
69 const spawn = buildGrokAcpSpawnInput(
70 { binaryPath: "/usr/local/bin/grok" },
71 "/tmp/project",
72 undefined,
73 "approval-required",
74 );
75 expect(spawn.args).toEqual(["--permission-mode", "default", "agent", "stdio"]);
76 });
77});
78
79describe("isValidGrokReasoningEffortToken", () => {
80 it("accepts future ACP tokens and rejects malformed metadata values", () => {
81 expect(isValidGrokReasoningEffortToken("xhigh")).toBe(true);
82 expect(isValidGrokReasoningEffortToken("turbo_v2")).toBe(true);
83 expect(isValidGrokReasoningEffortToken("not a token")).toBe(false);
84 expect(isValidGrokReasoningEffortToken("-leading-dash")).toBe(false);
85 expect(isValidGrokReasoningEffortToken("x".repeat(33))).toBe(false);
86 });
3687});
3788
3889describe("applyGrokAcpModelSelection", () => {
3990 const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => {
40 const modelCalls: Array<string> = [];
91 const modelCalls: Array<{
92 modelId: string;
93 meta?: { readonly [key: string]: unknown } | null;
94 }> = [];
4195 const runtime = {
42 setSessionModel: (modelId: string) =>
96 setSessionModel: (modelId: string, meta?: { readonly [key: string]: unknown } | null) =>
4397 Effect.gen(function* () {
44 modelCalls.push(modelId);
98 modelCalls.push(meta === undefined ? { modelId } : { modelId, meta });
4599 if (failure) return yield* failure;
46100 return {};
47101 }),
......@@ -58,11 +112,58 @@ describe("applyGrokAcpModelSelection", () => {
58112 requestedModelId: "grok-mock-alt",
59113 mapError: (cause) => cause.message,
60114 });
61 expect(modelCalls).toEqual(["grok-mock-alt"]);
115 expect(modelCalls).toEqual([{ modelId: "grok-mock-alt" }]);
62116 expect(result).toBe("grok-mock-alt");
63117 }),
64118 );
65119
120 it.effect("applies reasoning effort through session/set_model metadata", () =>
121 Effect.gen(function* () {
122 const { runtime, modelCalls } = makeRecordingRuntime();
123 const result = yield* applyGrokAcpModelSelection({
124 runtime,
125 currentModelId: "grok-4.6",
126 currentReasoningEffort: "high",
127 requestedModelId: "grok-4.6",
128 requestedReasoningEffort: "xhigh",
129 mapError: (cause) => cause.message,
130 });
131 expect(modelCalls).toEqual([{ modelId: "grok-4.6", meta: { reasoningEffort: "xhigh" } }]);
132 expect(result).toBe("grok-4.6");
133 }),
134 );
135
136 it.effect("does not clear reasoning when same-model selection omits effort", () =>
137 Effect.gen(function* () {
138 const { runtime, modelCalls } = makeRecordingRuntime();
139 const result = yield* applyGrokAcpModelSelection({
140 runtime,
141 currentModelId: "grok-4.6",
142 currentReasoningEffort: "high",
143 requestedModelId: "grok-4.6",
144 requestedReasoningEffort: undefined,
145 mapError: (cause) => cause.message,
146 });
147 expect(modelCalls).toEqual([]);
148 expect(result).toBe("grok-4.6");
149 }),
150 );
151
152 it.effect("drops malformed effort metadata instead of sending it", () =>
153 Effect.gen(function* () {
154 const { runtime, modelCalls } = makeRecordingRuntime();
155 yield* applyGrokAcpModelSelection({
156 runtime,
157 currentModelId: "grok-4.6",
158 currentReasoningEffort: "high",
159 requestedModelId: "grok-4.6",
160 requestedReasoningEffort: "not a token",
161 mapError: (cause) => cause.message,
162 });
163 expect(modelCalls).toEqual([{ modelId: "grok-4.6" }]);
164 }),
165 );
166
66167 it.effect("skips set_model when requested matches current", () =>
67168 Effect.gen(function* () {
68169 const { runtime, modelCalls } = makeRecordingRuntime();
apps/server/src/provider/acp/GrokAcpSupport.ts+79-7
......@@ -1,4 +1,4 @@
1import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts";
1import { type GrokSettings, ProviderDriverKind, type RuntimeMode } from "@t3tools/contracts";
22import * as Crypto from "effect/Crypto";
33import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
......@@ -27,16 +27,33 @@ interface GrokAcpRuntimeInput extends Omit<
2727 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
2828 readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined;
2929 readonly environment?: NodeJS.ProcessEnv;
30 readonly runtimeMode?: RuntimeMode;
31}
32
33export function grokAcpSpawnArgs(runtimeMode?: RuntimeMode): ReadonlyArray<string> {
34 switch (runtimeMode) {
35 case "approval-required":
36 return ["--permission-mode", "default", "agent", "stdio"];
37 case "auto-accept-edits":
38 return ["--permission-mode", "acceptEdits", "agent", "stdio"];
39 case "auto":
40 return ["--permission-mode", "auto", "agent", "stdio"];
41 case "full-access":
42 return ["agent", "--always-approve", "stdio"];
43 default:
44 return ["agent", "stdio"];
45 }
3046}
3147
3248export function buildGrokAcpSpawnInput(
3349 grokSettings: GrokAcpRuntimeGrokSettings | null | undefined,
3450 cwd: string,
3551 environment?: NodeJS.ProcessEnv,
52 runtimeMode?: RuntimeMode,
3653): AcpSessionRuntime.AcpSpawnInput {
3754 return {
3855 command: grokSettings?.binaryPath || "grok",
39 args: ["agent", "stdio"],
56 args: [...grokAcpSpawnArgs(runtimeMode)],
4057 cwd,
4158 env: {
4259 ...environment,
......@@ -62,7 +79,12 @@ export const makeGrokAcpRuntime = (
6279 const acpContext = yield* Layer.build(
6380 AcpSessionRuntime.layer({
6481 ...input,
65 spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment),
82 spawn: buildGrokAcpSpawnInput(
83 input.grokSettings,
84 input.cwd,
85 input.environment,
86 input.runtimeMode,
87 ),
6688 authMethodId: resolveGrokAuthMethodId(input.environment),
6789 }).pipe(
6890 Layer.provide(
......@@ -82,6 +104,17 @@ export function resolveGrokAcpBaseModelId(model: string | null | undefined): str
82104 return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build";
83105}
84106
107const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i;
108
109export function isValidGrokReasoningEffortToken(value: string): boolean {
110 return GROK_REASONING_EFFORT_TOKEN.test(value);
111}
112
113export function normalizeGrokReasoningEffort(value: string | undefined): string | undefined {
114 const effort = value?.trim();
115 return effort && isValidGrokReasoningEffortToken(effort) ? effort : undefined;
116}
117
85118export function currentGrokModelIdFromSessionSetup(
86119 sessionSetupResult:
87120 | EffectAcpSchema.LoadSessionResponse
......@@ -91,18 +124,57 @@ export function currentGrokModelIdFromSessionSetup(
91124 return sessionSetupResult.models?.currentModelId?.trim() || undefined;
92125}
93126
127export function currentGrokReasoningEffortFromSessionSetup(
128 sessionSetupResult:
129 | EffectAcpSchema.LoadSessionResponse
130 | EffectAcpSchema.NewSessionResponse
131 | EffectAcpSchema.ResumeSessionResponse,
132): string | undefined {
133 const modelState = sessionSetupResult.models;
134 if (!modelState) {
135 return undefined;
136 }
137 const currentModelId = modelState.currentModelId.trim();
138 if (currentModelId.length === 0) {
139 return undefined;
140 }
141 const currentModel = modelState.availableModels.find(
142 (model) => model.modelId.trim() === currentModelId,
143 );
144 const reasoningEffort = currentModel?._meta?.reasoningEffort;
145 return typeof reasoningEffort === "string"
146 ? normalizeGrokReasoningEffort(reasoningEffort)
147 : undefined;
148}
149
94150export function applyGrokAcpModelSelection<E>(input: {
95151 readonly runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "setSessionModel">;
96152 readonly currentModelId: string | undefined;
153 readonly currentReasoningEffort?: string | undefined;
97154 readonly requestedModelId: string | undefined;
155 readonly requestedReasoningEffort?: string | undefined;
98156 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
99157}): Effect.Effect<string | undefined, E> {
100 const shouldSwitchModel =
158 const modelChanged =
101159 input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId;
102 if (!shouldSwitchModel) {
160 const reasoningProvided = input.requestedReasoningEffort !== undefined;
161 const reasoningEffort = reasoningProvided
162 ? normalizeGrokReasoningEffort(input.requestedReasoningEffort)
163 : undefined;
164 const reasoningEffortChanged =
165 reasoningProvided && reasoningEffort !== input.currentReasoningEffort;
166 const targetModelId = input.requestedModelId ?? input.currentModelId;
167 if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) {
103168 return Effect.succeed(input.currentModelId);
104169 }
170 const reasoningMeta =
171 reasoningProvided && reasoningEffort !== undefined ? { reasoningEffort } : undefined;
172 // When reasoning was explicitly provided but invalid (normalize => undefined), we deliberately
173 // send no meta so the invalid value is dropped rather than forwarded. When reasoning was not
174 // provided at all, we also send no meta, but we only reach this call when the model itself
175 // changed - an omitted reasoning preference must not be treated as an explicit clear of the
176 // CLI-advertised default (e.g. Extra High) on same-model reselections.
105177 return input.runtime
106 .setSessionModel(input.requestedModelId)
107 .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId));
178 .setSessionModel(targetModelId, reasoningMeta)
179 .pipe(Effect.mapError(input.mapError), Effect.as(targetModelId));
108180}
apps/server/src/provider/acp/XAiAcpExtension.test.ts+194
......@@ -1,4 +1,5 @@
11// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeOS from "node:os";
23import * as NodePath from "node:path";
34import * as NodeURL from "node:url";
45
......@@ -9,11 +10,17 @@ import * as Schema from "effect/Schema";
910import { describe, expect } from "vite-plus/test";
1011
1112import {
13 extractGrokPlanMarkdownFromToolCallData,
1214 extractXAiAskUserQuestions,
15 extractXAiExitPlanMarkdown,
16 isGrokPlanMarkdownPath,
1317 makeXAiAskUserQuestionCancelledResponse,
1418 makeXAiAskUserQuestionResponse,
19 makeXAiExitPlanModeCapturedResponse,
1520 makeXAiPromptCompletionRuntime,
21 XAI_EMPTY_PLAN_MARKDOWN,
1622 XAiAskUserQuestionRequest,
23 XAiExitPlanModeRequest,
1724} from "./XAiAcpExtension.ts";
1825import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
1926
......@@ -299,6 +306,27 @@ describe("XAiAcpExtension", () => {
299306 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
300307 );
301308
309 it.effect("fails a hung standard prompt from an xAI rate-limit completion", () =>
310 Effect.gen(function* () {
311 const runtime = yield* makePromptCompletionRuntime({
312 T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG: "1",
313 });
314 yield* runtime.start();
315
316 const error = yield* Effect.flip(
317 runtime.prompt({
318 prompt: [{ type: "text", text: "hi" }],
319 }),
320 );
321
322 expect(error).toMatchObject({
323 _tag: "AcpRequestError",
324 code: -32003,
325 errorMessage: "Grok usage limit reached. Try again later.",
326 });
327 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
328 );
329
302330 it.effect("ignores stale xAI completion from an already settled prompt", () =>
303331 Effect.gen(function* () {
304332 const runtime = yield* makePromptCompletionRuntime({
......@@ -329,4 +357,170 @@ describe("XAiAcpExtension", () => {
329357 });
330358 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
331359 );
360
361 it("extracts plan markdown from exit_plan_mode payloads", () => {
362 const decode = Schema.decodeUnknownSync(XAiExitPlanModeRequest);
363 const direct = decode({
364 sessionId: "session-1",
365 toolCallId: "exit-1",
366 planContent: "# Plan\n\n- do the thing\n",
367 });
368 expect(extractXAiExitPlanMarkdown(direct)).toBe("# Plan\n\n- do the thing");
369
370 const wrapped = decode({
371 method: "_x.ai/exit_plan_mode",
372 params: {
373 sessionId: "session-1",
374 toolCallId: "exit-1",
375 planContent: null,
376 },
377 });
378 expect(extractXAiExitPlanMarkdown(wrapped, " # fallback plan ")).toBe("# fallback plan");
379 expect(extractXAiExitPlanMarkdown(wrapped, "")).toBe(XAI_EMPTY_PLAN_MARKDOWN);
380 expect(extractXAiExitPlanMarkdown(wrapped)).toBe(XAI_EMPTY_PLAN_MARKDOWN);
381 });
382
383 it("builds an abandoned exit_plan_mode response that captures the plan", () => {
384 expect(makeXAiExitPlanModeCapturedResponse()).toEqual({
385 outcome: "abandoned",
386 feedback:
387 "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.",
388 });
389 });
390
391 it("identifies Grok plan.md paths and extracts markdown from tool call data", () => {
392 const linuxHost = { platform: "linux" as const, environment: {} };
393 const windowsHost = { platform: "win32" as const, environment: {} };
394 const grokHomeHost = {
395 platform: "linux" as const,
396 environment: { GROK_HOME: "/opt/grok-data" },
397 };
398 const home = NodeOS.homedir().replace(/\\/g, "/");
399 const sessionPlan = `${home}/.grok/sessions/abc/plan.md`;
400 const nestedSessionPlan = `${home}/.grok/sessions/%2Fhome%2Fproj/019fd20e-c563-70a0-b801-a6bc51815a9b/plan.md`;
401 expect(isGrokPlanMarkdownPath(sessionPlan, linuxHost)).toBe(true);
402 expect(isGrokPlanMarkdownPath(nestedSessionPlan, linuxHost)).toBe(true);
403 expect(isGrokPlanMarkdownPath("~/.grok/sessions/abc/plan.md", linuxHost)).toBe(true);
404 expect(isGrokPlanMarkdownPath("/tmp/mock-home/.grok/sessions/sess/plan.md", linuxHost)).toBe(
405 true,
406 );
407 expect(isGrokPlanMarkdownPath("/home/other/.grok/sessions/sess/plan.md", linuxHost)).toBe(true);
408 expect(isGrokPlanMarkdownPath("/HOME/other/.grok/sessions/sess/plan.md", linuxHost)).toBe(
409 false,
410 );
411 expect(isGrokPlanMarkdownPath("C:/Users/other/.grok/sessions/id/plan.md", windowsHost)).toBe(
412 true,
413 );
414 expect(isGrokPlanMarkdownPath("c:/users/OTHER/.GROK/SESSIONS/id/PLAN.MD", windowsHost)).toBe(
415 true,
416 );
417 expect(
418 isGrokPlanMarkdownPath("C:\\Users\\other\\.grok\\sessions\\id\\plan.md", windowsHost),
419 ).toBe(true);
420 expect(isGrokPlanMarkdownPath("/opt/grok-data/sessions/sess/plan.md", grokHomeHost)).toBe(true);
421 expect(
422 isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", {
423 platform: "win32",
424 environment: { GROK_HOME: "/opt/grok-data" },
425 }),
426 ).toBe(true);
427 expect(isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", grokHomeHost)).toBe(
428 false,
429 );
430 // Workspace plan.md must not be treated as the session plan file.
431 expect(isGrokPlanMarkdownPath("plan.md", linuxHost)).toBe(false);
432 expect(isGrokPlanMarkdownPath("/repo/docs/plan.md", linuxHost)).toBe(false);
433 expect(isGrokPlanMarkdownPath("/tmp/other.md", linuxHost)).toBe(false);
434 expect(isGrokPlanMarkdownPath("/repo/.grok/sessions/example/plan.md", linuxHost)).toBe(false);
435 expect(
436 isGrokPlanMarkdownPath(`${home}/project/.grok/sessions/example/plan.md`, linuxHost),
437 ).toBe(false);
438 expect(
439 isGrokPlanMarkdownPath("/home/other/.grok/sessions/../../project/plan.md", linuxHost),
440 ).toBe(false);
441 expect(
442 isGrokPlanMarkdownPath("/home/other/.grok/sessions/foo/../../../project/plan.md", linuxHost),
443 ).toBe(false);
444
445 expect(
446 extractGrokPlanMarkdownFromToolCallData(
447 {
448 rawInput: {
449 file_path: sessionPlan,
450 content: "# From rawInput\n\n- a\n",
451 },
452 },
453 linuxHost,
454 ),
455 ).toBe("# From rawInput\n\n- a");
456
457 expect(
458 extractGrokPlanMarkdownFromToolCallData(
459 {
460 content: [
461 {
462 type: "diff",
463 path: sessionPlan,
464 oldText: "",
465 newText: "# From diff\n\n- b\n",
466 },
467 ],
468 },
469 linuxHost,
470 ),
471 ).toBe("# From diff\n\n- b");
472
473 expect(
474 extractGrokPlanMarkdownFromToolCallData(
475 {
476 rawInput: { file_path: sessionPlan, content: "" },
477 content: [
478 {
479 type: "diff",
480 path: sessionPlan,
481 oldText: "",
482 newText: "# From diff after empty rawInput\n",
483 },
484 ],
485 },
486 linuxHost,
487 ),
488 ).toBe("# From diff after empty rawInput");
489
490 expect(
491 extractGrokPlanMarkdownFromToolCallData(
492 {
493 rawInput: { file_path: sessionPlan, content: "" },
494 },
495 linuxHost,
496 ),
497 ).toBe("");
498
499 expect(
500 extractGrokPlanMarkdownFromToolCallData(
501 {
502 content: [{ type: "diff", path: sessionPlan, oldText: "# old", newText: "" }],
503 },
504 linuxHost,
505 ),
506 ).toBe("");
507
508 expect(
509 extractGrokPlanMarkdownFromToolCallData(
510 {
511 rawInput: { file_path: "/tmp/readme.md", content: "nope" },
512 },
513 linuxHost,
514 ),
515 ).toBeUndefined();
516
517 expect(
518 extractGrokPlanMarkdownFromToolCallData(
519 {
520 rawInput: { file_path: "/repo/docs/plan.md", content: "# Project plan\n" },
521 },
522 linuxHost,
523 ),
524 ).toBeUndefined();
525 });
332526});
apps/server/src/provider/acp/XAiAcpExtension.ts+258-4
......@@ -1,8 +1,11 @@
1import * as NodeOS from "node:os";
2
13import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts";
24import * as Deferred from "effect/Deferred";
35import * as Effect from "effect/Effect";
46import * as Ref from "effect/Ref";
57import * as Schema from "effect/Schema";
8import * as EffectAcpErrors from "effect-acp/errors";
69import type * as EffectAcpSchema from "effect-acp/schema";
710
811import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
......@@ -19,11 +22,15 @@ type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type;
1922interface PendingXAiPromptCompletion {
2023 readonly sessionId: string;
2124 readonly promptId: string;
22 readonly deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse>;
25 readonly deferred: Deferred.Deferred<
26 EffectAcpSchema.PromptResponse,
27 EffectAcpErrors.AcpRequestError
28 >;
2329}
2430
2531const completedXAiPromptIdLimit = 128;
2632const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing";
33const xAiRateLimitedErrorCode = -32003;
2734
2835const XAiAskUserQuestionOption = Schema.Struct({
2936 label: Schema.String,
......@@ -196,6 +203,218 @@ export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCan
196203 return { outcome: "cancelled" };
197204}
198205
206// ---------------------------------------------------------------------------
207// x.ai/exit_plan_mode — plan approval gate (mirrors Grok Build TUI plan window)
208// ---------------------------------------------------------------------------
209
210const XAiExitPlanModeParams = Schema.Struct({
211 sessionId: Schema.String,
212 toolCallId: Schema.String,
213 planContent: Schema.optional(Schema.NullOr(Schema.String)),
214});
215
216const XAiWrappedExitPlanModeParams = Schema.Struct({
217 method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]),
218 params: XAiExitPlanModeParams,
219});
220
221export const XAiExitPlanModeRequest = Schema.Union([
222 XAiExitPlanModeParams,
223 XAiWrappedExitPlanModeParams,
224]);
225
226type XAiExitPlanModeRequestParams = typeof XAiExitPlanModeParams.Type;
227type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type;
228
229function unwrapExitPlanModeParams(params: XAiExitPlanModeRequest): XAiExitPlanModeRequestParams {
230 return "params" in params ? params.params : params;
231}
232
233/** Empty-state copy when Grok exits plan mode without a plan file. */
234export const XAI_EMPTY_PLAN_MARKDOWN =
235 "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)";
236
237export function extractXAiExitPlanMarkdown(
238 params: XAiExitPlanModeRequest,
239 fallback?: string | null,
240): string {
241 const content = unwrapExitPlanModeParams(params).planContent;
242 const fromRequest = typeof content === "string" ? trimmed(content) : undefined;
243 if (fromRequest) {
244 return fromRequest;
245 }
246 const fromFallback = fallback?.trim();
247 if (fromFallback && fromFallback.length > 0) {
248 return fromFallback;
249 }
250 return XAI_EMPTY_PLAN_MARKDOWN;
251}
252
253export type XAiExitPlanModeOutcome = "approved" | "abandoned" | "request_changes";
254
255export interface XAiExitPlanModeResponse {
256 readonly outcome: XAiExitPlanModeOutcome;
257 readonly feedback?: string;
258}
259
260/**
261 * Client captured the plan for T3's proposed-plan card. Abandon the native
262 * Grok plan-approval gate so the turn unblocks; the user implements via T3 UI.
263 */
264export function makeXAiExitPlanModeCapturedResponse(feedback?: string): XAiExitPlanModeResponse {
265 return {
266 outcome: "abandoned",
267 feedback:
268 feedback ??
269 "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.",
270 };
271}
272
273function normalizeFsPath(value: string): string {
274 return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
275}
276
277function pathHasTraversalSegment(normalized: string): boolean {
278 return normalized.split("/").includes("..");
279}
280
281function addGrokSessionPrefix(
282 prefixes: Set<string>,
283 homeOrRoot: string,
284 nestedGrokDir: boolean,
285): void {
286 const root = normalizeFsPath(homeOrRoot);
287 if (!root) {
288 return;
289 }
290 prefixes.add(nestedGrokDir ? `${root}/.grok/sessions/` : `${root}/sessions/`);
291}
292
293/** Injected host bits so these helpers stay off `process.platform` / `process.env`. */
294export interface GrokPlanPathHost {
295 readonly platform: NodeJS.Platform;
296 readonly environment: NodeJS.ProcessEnv;
297}
298
299function grokPlanSessionPrefixes(environment: NodeJS.ProcessEnv): ReadonlySet<string> {
300 const prefixes = new Set<string>();
301 addGrokSessionPrefix(prefixes, NodeOS.homedir(), true);
302 addGrokSessionPrefix(prefixes, "~", true);
303 addGrokSessionPrefix(prefixes, environment.HOME ?? "", true);
304 addGrokSessionPrefix(prefixes, environment.USERPROFILE ?? "", true);
305 // ACP mock and isolated Grok spawns use a HOME that is not the server process home.
306 addGrokSessionPrefix(prefixes, "/tmp/mock-home", true);
307 const grokHome = environment.GROK_HOME ?? "";
308 addGrokSessionPrefix(prefixes, grokHome, false);
309 addGrokSessionPrefix(prefixes, grokHome, true);
310 return prefixes;
311}
312
313const CANONICAL_HOME_GROK_SESSION_PATH =
314 /^(?:\/home\/[^/]+|\/Users\/[^/]+|[a-zA-Z]:\/Users\/[^/]+)\/\.grok\/sessions\/(?:[^/]+\/)+plan\.md$/;
315const CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH = new RegExp(
316 CANONICAL_HOME_GROK_SESSION_PATH.source,
317 "i",
318);
319
320/**
321 * True when a path is Grok's session plan file under a Grok home
322 * (`~/.grok/sessions/.../plan.md`, `$HOME/.grok/sessions/...`, or `$GROK_HOME/sessions/...`).
323 * Deliberately does not match workspace files named `plan.md` (e.g. docs/plan.md
324 * or a repo-local `.grok/sessions/.../plan.md`).
325 */
326export function isGrokPlanMarkdownPath(
327 path: string | undefined | null,
328 host: GrokPlanPathHost,
329): boolean {
330 if (typeof path !== "string") {
331 return false;
332 }
333 const normalized = path.trim().replace(/\\/g, "/");
334 const win32 = host.platform === "win32";
335 const haystack = win32 ? normalized.toLowerCase() : normalized;
336 if (
337 normalized.length === 0 ||
338 !haystack.endsWith("/plan.md") ||
339 pathHasTraversalSegment(normalized)
340 ) {
341 return false;
342 }
343 for (const prefix of grokPlanSessionPrefixes(host.environment)) {
344 const needle = win32 ? prefix.toLowerCase() : prefix;
345 if (!haystack.startsWith(needle)) {
346 continue;
347 }
348 const rest = haystack.slice(needle.length);
349 // Session layout: <home>/.grok/sessions/<encoded-cwd>/<session-id>/plan.md
350 if (rest !== "plan.md" && rest.endsWith("plan.md")) {
351 return true;
352 }
353 }
354 return (
355 win32 ? CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH : CANONICAL_HOME_GROK_SESSION_PATH
356 ).test(haystack);
357}
358
359/**
360 * Extract plan markdown from a Grok write/edit tool call targeting plan.md.
361 * Used so T3 can show the plan while plan mode is still active (before exit).
362 */
363export function extractGrokPlanMarkdownFromToolCallData(
364 data: Record<string, unknown> | undefined,
365 host: GrokPlanPathHost,
366): string | undefined {
367 if (!data) {
368 return undefined;
369 }
370
371 let sawPlanWrite = false;
372 const takePlanText = (
373 value: string | undefined,
374 filePath: string | undefined,
375 ): string | undefined => {
376 if (!isGrokPlanMarkdownPath(filePath, host) || value === undefined) {
377 return undefined;
378 }
379 sawPlanWrite = true;
380 const trimmed = value.trim();
381 return trimmed.length > 0 ? trimmed : undefined;
382 };
383
384 const rawInput = data.rawInput;
385 if (isRecord(rawInput)) {
386 const filePath =
387 (typeof rawInput.file_path === "string" ? rawInput.file_path : undefined) ??
388 (typeof rawInput.path === "string" ? rawInput.path : undefined);
389 const content = typeof rawInput.content === "string" ? rawInput.content : undefined;
390 const fromRaw = takePlanText(content, filePath);
391 if (fromRaw !== undefined) {
392 return fromRaw;
393 }
394 }
395
396 const content = data.content;
397 if (Array.isArray(content)) {
398 for (const block of content) {
399 if (!isRecord(block) || block.type !== "diff") {
400 continue;
401 }
402 const path = typeof block.path === "string" ? block.path : undefined;
403 const newText = typeof block.newText === "string" ? block.newText : undefined;
404 const fromDiff = takePlanText(newText, path);
405 if (fromDiff !== undefined) {
406 return fromDiff;
407 }
408 }
409 }
410
411 return sawPlanWrite ? "" : undefined;
412}
413
414function isRecord(value: unknown): value is Record<string, unknown> {
415 return typeof value === "object" && value !== null && !Array.isArray(value);
416}
417
199418/**
200419 * Adds Grok's private prompt-completion fallback around a standards-only ACP runtime.
201420 * The underlying runtime remains unaware of xAI methods and metadata.
......@@ -278,7 +497,7 @@ const registerXAiPromptCompletionFallback = (
278497 sessionId: string,
279498 promptId: string,
280499) =>
281 Deferred.make<EffectAcpSchema.PromptResponse>().pipe(
500 Deferred.make<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>().pipe(
282501 Effect.tap((deferred) =>
283502 Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]),
284503 ),
......@@ -287,7 +506,7 @@ const registerXAiPromptCompletionFallback = (
287506
288507const unregisterXAiPromptCompletionFallback = (
289508 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,
290 deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse>,
509 deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>,
291510) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred));
292511
293512const abortPendingPromptCompletions = (
......@@ -358,13 +577,48 @@ const resolveXAiPromptCompletionFallback = ({
358577 return [Effect.void, pending] as const;
359578 }
360579 return [
361 Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid),
580 settleXAiPromptCompletion(entry.deferred, notification),
362581 [...pending.slice(0, index), ...pending.slice(index + 1)],
363582 ] as const;
364583 }).pipe(Effect.flatten);
365584 }),
366585 );
367586
587const settleXAiPromptCompletion = (
588 deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>,
589 notification: XAiPromptCompleteNotification,
590) => {
591 if (notification.stopReason === "rate_limit") {
592 return Deferred.fail(
593 deferred,
594 new EffectAcpErrors.AcpRequestError({
595 code: xAiRateLimitedErrorCode,
596 errorMessage: "Grok usage limit reached. Try again later.",
597 }),
598 ).pipe(Effect.asVoid);
599 }
600 if (notification.stopReason === "error") {
601 return Deferred.fail(
602 deferred,
603 EffectAcpErrors.AcpRequestError.internalError(
604 xAiAgentResultMessage(notification.agentResult) ?? "Grok prompt failed.",
605 ),
606 ).pipe(Effect.asVoid);
607 }
608 return Deferred.succeed(deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid);
609};
610
611function xAiAgentResultMessage(value: unknown): string | undefined {
612 if (typeof value === "string") {
613 return trimmed(value);
614 }
615 if (value === null || typeof value !== "object") {
616 return undefined;
617 }
618 const message = "message" in value ? value.message : undefined;
619 return typeof message === "string" ? trimmed(message) : undefined;
620}
621
368622const rememberCompletedXAiPromptId = (
369623 completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>,
370624 response: EffectAcpSchema.PromptResponse,
apps/server/src/textGeneration/GrokTextGeneration.ts+10
......@@ -8,6 +8,7 @@ import type * as EffectAcpErrors from "effect-acp/errors";
88
99import { type GrokSettings, type ModelSelection } from "@t3tools/contracts";
1010import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
11import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
1112import { extractJsonObject } from "@t3tools/shared/schemaJson";
1213
1314import { TextGenerationError } from "@t3tools/contracts";
......@@ -26,6 +27,7 @@ import {
2627import {
2728 applyGrokAcpModelSelection,
2829 currentGrokModelIdFromSessionSetup,
30 currentGrokReasoningEffortFromSessionSetup,
2931 makeGrokAcpRuntime,
3032 resolveGrokAcpBaseModelId,
3133} from "../provider/acp/GrokAcpSupport.ts";
......@@ -83,10 +85,18 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi
8385
8486 const promptResult = yield* Effect.gen(function* () {
8587 const started = yield* runtime.start();
88 const requestedReasoningEffort = getModelSelectionStringOptionValue(
89 modelSelection,
90 "reasoningEffort",
91 );
8692 yield* applyGrokAcpModelSelection({
8793 runtime,
8894 currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult),
95 currentReasoningEffort: currentGrokReasoningEffortFromSessionSetup(
96 started.sessionSetupResult,
97 ),
8998 requestedModelId: resolvedModel,
99 requestedReasoningEffort,
90100 mapError: (cause) =>
91101 new TextGenerationError({
92102 operation,
apps/server/src/usage/UsageService.ts+22-5
......@@ -1,9 +1,9 @@
11/**
22 * UsageService - scans provider transcripts and returns priced usage buckets.
33 *
4 * The scan reads the provider CLIs' own session files rather than T3 Code's
5 * orchestration projections, so usage covers turns driven outside T3 Code too.
6 * This is the approach `ccusage` takes.
4 * The scan reads the provider CLIs' own session files (Claude Code, Codex, and
5 * Grok Build) rather than T3 Code's orchestration projections, so usage covers
6 * turns driven outside T3 Code too. This is the approach `ccusage` takes.
77 *
88 * Transcripts are append-only, so parsed records are memoised per file by
99 * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm
......@@ -21,6 +21,7 @@ import {
2121 type UsageSummaryInput,
2222 UsageReadError,
2323} from "@t3tools/contracts";
24import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
2425import * as Cause from "effect/Cause";
2526import * as Clock from "effect/Clock";
2627import * as Context from "effect/Context";
......@@ -34,6 +35,7 @@ import * as Schema from "effect/Schema";
3435import { HttpClient, HttpClientResponse } from "effect/unstable/http";
3536
3637import { ServerConfig } from "../config.ts";
38import { expandHomePath } from "../pathExpansion.ts";
3739import * as ServerSettings from "../serverSettings.ts";
3840import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts";
3941import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
......@@ -123,6 +125,7 @@ export const make = Effect.gen(function* () {
123125 const config = yield* ServerConfig;
124126 const settingsService = yield* ServerSettings.ServerSettingsService;
125127 const httpClient = yield* HttpClient.HttpClient;
128 const hostEnvironment = yield* HostProcessEnvironment;
126129
127130 const fileCache: ScanCache = new Map();
128131 let cacheDirty = false;
......@@ -218,10 +221,22 @@ export const make = Effect.gen(function* () {
218221 const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent);
219222 const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome);
220223 const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex);
224 // Grok Settings only expose the binary path; home is `$GROK_HOME` or `~/.grok`.
225 // Empty/whitespace GROK_HOME must fall back: coalescing alone would scan cwd.
226 const grokHomeEnv = hostEnvironment["GROK_HOME"]?.trim() ?? "";
227 const grokHome =
228 grokHomeEnv.length > 0
229 ? path.resolve(expandHomePath(grokHomeEnv))
230 : path.join(NodeOS.homedir(), ".grok");
221231
222232 return [
223233 { provider: "claude" as const, dir: claudeDir },
224234 { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") },
235 {
236 provider: "grok" as const,
237 dir: path.join(grokHome, "sessions"),
238 fileName: "updates.jsonl",
239 },
225240 ];
226241 });
227242
......@@ -353,7 +368,7 @@ export const make = Effect.gen(function* () {
353368 const livePaths = new Set<string>();
354369 const walkedRoots: string[] = [];
355370
356 for (const { provider, dir } of dirs) {
371 for (const { provider, dir, fileName } of dirs) {
357372 const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir));
358373 const exists = yield* fileSystem
359374 .exists(dir)
......@@ -373,7 +388,9 @@ export const make = Effect.gen(function* () {
373388 }
374389
375390 walkedRoots.push(dir);
376 const files = yield* Effect.promise(() => listTranscriptFiles(dir, windowStartMs));
391 const files = yield* Effect.promise(() =>
392 listTranscriptFiles(dir, windowStartMs, fileName === undefined ? undefined : { fileName }),
393 );
377394 let scannedFiles = 0;
378395 let skippedFiles = 0;
379396 // Distinct per directory. Buckets carry per-cell session counts, but a
apps/server/src/usage/usageScanCache.test.ts+10-1
......@@ -42,12 +42,21 @@ describe("scan cache round trip", () => {
4242 ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]],
4343 ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]],
4444 ]);
45 original.set("/grok.jsonl", {
46 size: 40,
47 mtimeMs: 300,
48 provider: "grok",
49 records: [
50 record({ provider: "grok", model: "grok-4.5-build", dedupeKey: "s:p:grok-4.5-build" }),
51 ],
52 });
4553
4654 const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original))));
4755
48 expect(restored.size).toBe(2);
56 expect(restored.size).toBe(3);
4957 expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl"));
5058 expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl"));
59 expect(restored.get("/grok.jsonl")).toEqual(original.get("/grok.jsonl"));
5160 });
5261
5362 it("interns repeated model and session strings", () => {
apps/server/src/usage/usageScanCache.ts+1-1
......@@ -134,7 +134,7 @@ export function decodeScanCache(document: unknown): ScanCache {
134134 if (typeof raw !== "object" || raw === null) continue;
135135 const entry = raw as Partial<SerializedFile>;
136136 if (typeof entry.s !== "number" || typeof entry.m !== "number") continue;
137 if (entry.p !== "claude" && entry.p !== "codex") continue;
137 if (entry.p !== "claude" && entry.p !== "codex" && entry.p !== "grok") continue;
138138 if (!isRecordArray(entry.r)) continue;
139139
140140 const provider: UsageProviderKind = entry.p;
apps/server/src/usage/usageTranscriptReader.ts+18-1
......@@ -22,6 +22,7 @@ import {
2222 mightCarryUsage,
2323 parseClaudeLine,
2424 parseCodexLine,
25 parseGrokLine,
2526 type UsageRecord,
2627} from "./usageTranscripts.ts";
2728
......@@ -37,12 +38,18 @@ export interface TranscriptFile {
3738 * Errors on individual entries are swallowed: session files rotate and get
3839 * removed while the walk is in flight, and a partial listing is far better than
3940 * failing the page.
41 *
42 * `fileName` restricts the walk to a single basename (Grok's `updates.jsonl`).
43 * Grok sessions also ship multi-megabyte `chat_history` and `events` logs that
44 * never carry usage, so the basename filter keeps a cold scan off those files.
4045 */
4146export async function listTranscriptFiles(
4247 root: string,
4348 sinceMs: number,
49 options?: { readonly fileName?: string },
4450): Promise<readonly TranscriptFile[]> {
4551 const found: TranscriptFile[] = [];
52 const fileName = options?.fileName;
4653
4754 const walk = async (dir: string): Promise<void> => {
4855 let entries;
......@@ -57,7 +64,11 @@ export async function listTranscriptFiles(
5764 await walk(child);
5865 continue;
5966 }
60 if (!entry.name.endsWith(".jsonl")) continue;
67 if (fileName !== undefined) {
68 if (entry.name !== fileName) continue;
69 } else if (!entry.name.endsWith(".jsonl")) {
70 continue;
71 }
6172 try {
6273 const stats = await NodeFSP.stat(child);
6374 if (stats.mtimeMs >= sinceMs) {
......@@ -129,6 +140,12 @@ export async function readTranscriptRecords(
129140 continue;
130141 }
131142
143 if (provider === "grok") {
144 if (!mightCarryUsage(line, provider)) continue;
145 for (const grokRecord of parseGrokLine(line)) records.push(grokRecord);
146 continue;
147 }
148
132149 if (!mightCarryUsage(line, provider)) continue;
133150 const record = parseClaudeLine(line);
134151 if (record !== null) records.push(record);
apps/server/src/usage/usageTranscripts.test.ts+315
......@@ -1,9 +1,11 @@
11import { describe, expect, it } from "@effect/vitest";
22
33import {
4 GROK_COST_USD_TICKS_PER_DOLLAR,
45 initialCodexScanState,
56 parseClaudeLine,
67 parseCodexLine,
8 parseGrokLine,
79 totalTokens,
810} from "./usageTranscripts.ts";
911
......@@ -249,3 +251,316 @@ describe("totalTokens", () => {
249251 ).toBe(100);
250252 });
251253});
254
255describe("parseGrokLine", () => {
256 /** Shaped after a real Grok Build `turn_completed` session update. */
257 function turnCompleted(overrides?: {
258 sessionId?: string;
259 promptId?: string;
260 timestamp?: number;
261 agentTimestampMs?: number;
262 usage?: Record<string, unknown>;
263 modelUsage?: Record<string, Record<string, unknown>> | null;
264 }): string {
265 const modelUsage =
266 overrides && "modelUsage" in overrides
267 ? overrides.modelUsage
268 : {
269 "grok-4.5-build": {
270 inputTokens: 20_272,
271 outputTokens: 272,
272 totalTokens: 20_544,
273 cachedReadTokens: 11_264,
274 cacheCreationTokens: 0,
275 reasoningTokens: 180,
276 costUsdTicks: 230_272_000,
277 },
278 };
279
280 return JSON.stringify({
281 timestamp: overrides?.timestamp ?? 1_786_372_566,
282 method: "_x.ai/session/update",
283 params: {
284 sessionId: overrides?.sessionId ?? "019fec1a-12f7-72f2-9b1f-7778a00aea3c",
285 update: {
286 sessionUpdate: "turn_completed",
287 prompt_id: overrides?.promptId ?? "prompt-1",
288 stop_reason: "end_turn",
289 usage: {
290 inputTokens: 20_272,
291 outputTokens: 272,
292 totalTokens: 20_544,
293 cachedReadTokens: 11_264,
294 cacheCreationTokens: 0,
295 reasoningTokens: 180,
296 costUsdTicks: 230_272_000,
297 ...(modelUsage === null ? {} : { modelUsage }),
298 ...overrides?.usage,
299 },
300 },
301 _meta: {
302 eventId: "event-1",
303 agentTimestampMs: overrides?.agentTimestampMs ?? 1_786_372_566_485,
304 },
305 },
306 });
307 }
308
309 it("extracts per-model totals and provider-reported cost ticks", () => {
310 const records = parseGrokLine(turnCompleted());
311
312 expect(records).toHaveLength(1);
313 const [record] = records;
314 expect(record?.provider).toBe("grok");
315 expect(record?.model).toBe("grok-4.5-build");
316 expect(record?.sessionId).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c");
317 expect(record?.timestampMs).toBe(1_786_372_566_485);
318 expect(record?.totals).toEqual({
319 uncachedInputTokens: 20_272 - 11_264,
320 cachedInputTokens: 11_264,
321 cacheCreationTokens: 0,
322 outputTokens: 272,
323 reasoningTokens: 180,
324 });
325 expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12);
326 expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok-4.5-build");
327 });
328
329 it("emits one record per model when modelUsage has several entries", () => {
330 const records = parseGrokLine(
331 turnCompleted({
332 modelUsage: {
333 "grok-4.5": {
334 inputTokens: 1000,
335 outputTokens: 50,
336 cachedReadTokens: 400,
337 reasoningTokens: 20,
338 costUsdTicks: 50_000_000,
339 },
340 "grok-composer-2.5-fast": {
341 inputTokens: 200,
342 outputTokens: 30,
343 cachedReadTokens: 100,
344 reasoningTokens: 0,
345 costUsdTicks: 10_000_000,
346 },
347 },
348 }),
349 );
350
351 expect(records.map((record) => record.model).toSorted()).toEqual([
352 "grok-4.5",
353 "grok-composer-2.5-fast",
354 ]);
355 expect(records.every((record) => record.provider === "grok")).toBe(true);
356 expect(records.find((record) => record.model === "grok-4.5")?.reportedCostUsd).toBeCloseTo(
357 0.005,
358 12,
359 );
360 });
361
362 it("inherits top-level cost ticks for a single model without its own ticks", () => {
363 const records = parseGrokLine(
364 turnCompleted({
365 modelUsage: {
366 "grok-4.5-build": {
367 inputTokens: 1000,
368 outputTokens: 10,
369 cachedReadTokens: 0,
370 reasoningTokens: 0,
371 },
372 },
373 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
374 }),
375 );
376
377 expect(records).toHaveLength(1);
378 expect(records[0]?.reportedCostUsd).toBe(1);
379 });
380
381 it("falls back to a generic grok model when modelUsage is absent", () => {
382 const records = parseGrokLine(turnCompleted({ modelUsage: null }));
383
384 expect(records).toHaveLength(1);
385 const [record] = records;
386 expect(record?.provider).toBe("grok");
387 expect(record?.model).toBe("grok");
388 expect(record?.totals).toEqual({
389 uncachedInputTokens: 20_272 - 11_264,
390 cachedInputTokens: 11_264,
391 cacheCreationTokens: 0,
392 outputTokens: 272,
393 reasoningTokens: 180,
394 });
395 expect(record?.reportedCostUsd).toBeCloseTo(230_272_000 / GROK_COST_USD_TICKS_PER_DOLLAR, 12);
396 expect(record?.dedupeKey).toBe("019fec1a-12f7-72f2-9b1f-7778a00aea3c:prompt-1:grok");
397 });
398
399 it("pro-rates top-level cost ticks across multi-model turns without per-model ticks", () => {
400 const records = parseGrokLine(
401 turnCompleted({
402 modelUsage: {
403 "grok-4.5": {
404 inputTokens: 300,
405 outputTokens: 0,
406 cachedReadTokens: 0,
407 reasoningTokens: 0,
408 },
409 "grok-composer-2.5-fast": {
410 inputTokens: 100,
411 outputTokens: 0,
412 cachedReadTokens: 0,
413 reasoningTokens: 0,
414 },
415 },
416 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
417 }),
418 );
419
420 expect(records).toHaveLength(2);
421 const byModel = Object.fromEntries(records.map((record) => [record.model, record]));
422 expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12);
423 expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12);
424 const sum =
425 (byModel["grok-4.5"]?.reportedCostUsd ?? 0) +
426 (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0);
427 expect(sum).toBeCloseTo(1, 12);
428 });
429
430 it("pro-rates aggregate cost when a zero-token sibling carries costUsdTicks: 0", () => {
431 const records = parseGrokLine(
432 turnCompleted({
433 modelUsage: {
434 "grok-4.5": {
435 inputTokens: 300,
436 outputTokens: 0,
437 cachedReadTokens: 0,
438 reasoningTokens: 0,
439 },
440 "grok-composer-2.5-fast": {
441 inputTokens: 100,
442 outputTokens: 0,
443 cachedReadTokens: 0,
444 reasoningTokens: 0,
445 },
446 "empty-sibling": {
447 inputTokens: 0,
448 outputTokens: 0,
449 cachedReadTokens: 0,
450 reasoningTokens: 0,
451 costUsdTicks: 0,
452 },
453 },
454 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
455 }),
456 );
457
458 expect(records).toHaveLength(2);
459 expect(records.every((record) => record.model !== "empty-sibling")).toBe(true);
460 const byModel = Object.fromEntries(records.map((record) => [record.model, record]));
461 expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.75, 12);
462 expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.25, 12);
463 const sum =
464 (byModel["grok-4.5"]?.reportedCostUsd ?? 0) +
465 (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0);
466 expect(sum).toBeCloseTo(1, 12);
467 });
468
469 it("allocates leftover aggregate ticks to models that omit per-model ticks", () => {
470 const records = parseGrokLine(
471 turnCompleted({
472 modelUsage: {
473 "grok-4.5": {
474 inputTokens: 300,
475 outputTokens: 0,
476 cachedReadTokens: 0,
477 reasoningTokens: 0,
478 costUsdTicks: 0.4 * GROK_COST_USD_TICKS_PER_DOLLAR,
479 },
480 "grok-composer-2.5-fast": {
481 inputTokens: 100,
482 outputTokens: 0,
483 cachedReadTokens: 0,
484 reasoningTokens: 0,
485 },
486 },
487 usage: { costUsdTicks: GROK_COST_USD_TICKS_PER_DOLLAR },
488 }),
489 );
490
491 expect(records).toHaveLength(2);
492 const byModel = Object.fromEntries(records.map((record) => [record.model, record]));
493 expect(byModel["grok-4.5"]?.reportedCostUsd).toBeCloseTo(0.4, 12);
494 expect(byModel["grok-composer-2.5-fast"]?.reportedCostUsd).toBeCloseTo(0.6, 12);
495 const sum =
496 (byModel["grok-4.5"]?.reportedCostUsd ?? 0) +
497 (byModel["grok-composer-2.5-fast"]?.reportedCostUsd ?? 0);
498 expect(sum).toBeCloseTo(1, 12);
499 });
500
501 it("does not invent a colliding dedupe key when prompt_id is missing", () => {
502 const line = JSON.stringify({
503 timestamp: 1_786_372_566,
504 method: "_x.ai/session/update",
505 params: {
506 sessionId: "s1",
507 update: {
508 sessionUpdate: "turn_completed",
509 usage: {
510 inputTokens: 10,
511 outputTokens: 2,
512 modelUsage: {
513 "grok-4.5": { inputTokens: 10, outputTokens: 2 },
514 },
515 },
516 },
517 },
518 });
519
520 expect(parseGrokLine(line)[0]?.dedupeKey).toBeNull();
521 });
522
523 it("ignores non-turn lines and empty usage", () => {
524 expect(parseGrokLine(JSON.stringify({ method: "session/update", params: {} }))).toEqual([]);
525 expect(parseGrokLine("not json")).toEqual([]);
526 expect(
527 parseGrokLine(
528 turnCompleted({
529 modelUsage: {
530 "grok-4.5-build": {
531 inputTokens: 0,
532 outputTokens: 0,
533 cachedReadTokens: 0,
534 reasoningTokens: 0,
535 costUsdTicks: 0,
536 },
537 },
538 }),
539 ),
540 ).toEqual([]);
541 });
542
543 it("falls back to the outer unix-seconds timestamp when agent meta is missing", () => {
544 const line = JSON.stringify({
545 timestamp: 1_786_372_566,
546 method: "_x.ai/session/update",
547 params: {
548 sessionId: "s1",
549 update: {
550 sessionUpdate: "turn_completed",
551 prompt_id: "p1",
552 usage: {
553 inputTokens: 10,
554 outputTokens: 2,
555 modelUsage: {
556 "grok-4.5": { inputTokens: 10, outputTokens: 2 },
557 },
558 },
559 },
560 },
561 });
562
563 const records = parseGrokLine(line);
564 expect(records[0]?.timestampMs).toBe(1_786_372_566_000);
565 });
566});
apps/server/src/usage/usageTranscripts.ts+191-3
......@@ -1,8 +1,8 @@
11/**
22 * Pure parsers for the provider CLIs' on-disk session transcripts.
33 *
4 * Both parsers are line-at-a-time reducers so callers can stream large files
5 * without materialising them. Neither touches the filesystem.
4 * Each parser is a line-at-a-time reducer so callers can stream large files
5 * without materialising them. None of them touch the filesystem.
66 *
77 * @module usageTranscripts
88 */
......@@ -68,7 +68,20 @@ export function totalTokens(totals: UsageTokenTotals): number {
6868 * an order of magnitude.
6969 */
7070export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean {
71 return provider === "claude" ? line.includes('"usage"') : line.includes('"token_count"');
71 if (provider === "claude") return line.includes('"usage"');
72 if (provider === "grok") return line.includes('"turn_completed"');
73 return line.includes('"token_count"');
74}
75
76/**
77 * Grok reports cost in integer ticks where `1 USD = 10^10` ticks. See Grok
78 * headless `total_cost_usd_ticks`. Convert to dollars for pricing.
79 */
80export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000;
81
82export function grokCostTicksToUsd(ticks: unknown): number | null {
83 if (typeof ticks !== "number" || !Number.isFinite(ticks) || ticks < 0) return null;
84 return ticks / GROK_COST_USD_TICKS_PER_DOLLAR;
7285}
7386
7487/* -------------------------------------------------------------------------- */
......@@ -297,4 +310,179 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord
297310 };
298311}
299312
313/* -------------------------------------------------------------------------- */
314/* Grok Build */
315/* -------------------------------------------------------------------------- */
316
317interface GrokUsageTotals {
318 readonly inputTokens: number;
319 readonly outputTokens: number;
320 readonly cachedReadTokens: number;
321 readonly cacheCreationTokens: number;
322 readonly reasoningTokens: number;
323 readonly costUsdTicks: number | null;
324}
325
326function readGrokUsageTotals(value: unknown): GrokUsageTotals | null {
327 if (typeof value !== "object" || value === null) return null;
328 const record = value as Record<string, unknown>;
329 return {
330 inputTokens: int(record["inputTokens"]),
331 outputTokens: int(record["outputTokens"]),
332 cachedReadTokens: int(record["cachedReadTokens"]),
333 cacheCreationTokens: int(record["cacheCreationTokens"]),
334 reasoningTokens: int(record["reasoningTokens"]),
335 costUsdTicks:
336 typeof record["costUsdTicks"] === "number" && Number.isFinite(record["costUsdTicks"])
337 ? record["costUsdTicks"]
338 : null,
339 };
340}
341
342function grokTotalsToUsage(totals: GrokUsageTotals): UsageTokenTotals {
343 const cachedInputTokens = totals.cachedReadTokens;
344 const cacheCreationTokens = totals.cacheCreationTokens;
345 // Grok reports `inputTokens` inclusive of the cached portion, matching Codex.
346 const uncachedInputTokens = Math.max(
347 0,
348 totals.inputTokens - cachedInputTokens - cacheCreationTokens,
349 );
350 const outputTokens = totals.outputTokens;
351 return {
352 uncachedInputTokens,
353 cachedInputTokens,
354 cacheCreationTokens,
355 outputTokens,
356 reasoningTokens: Math.min(outputTokens, totals.reasoningTokens),
357 };
358}
359
360/**
361 * Parses one line of a Grok Build `updates.jsonl` session log.
362 *
363 * Usage lands on `turn_completed` session updates. Per-model breakdowns live
364 * under `usage.modelUsage`; when present each model becomes its own record.
365 *
366 * Returns every record for the line (0 or more). Callers stream line-by-line
367 * and flatten.
368 */
369export function parseGrokLine(line: string): readonly UsageRecord[] {
370 let parsed: unknown;
371 try {
372 parsed = JSON.parse(line);
373 } catch {
374 return [];
375 }
376 if (typeof parsed !== "object" || parsed === null) return [];
377
378 const record = parsed as Record<string, unknown>;
379 const params = record["params"];
380 if (typeof params !== "object" || params === null) return [];
381 const paramsRecord = params as Record<string, unknown>;
382
383 const update = paramsRecord["update"];
384 if (typeof update !== "object" || update === null) return [];
385 const updateRecord = update as Record<string, unknown>;
386 if (updateRecord["sessionUpdate"] !== "turn_completed") return [];
387
388 const usage = updateRecord["usage"];
389 if (typeof usage !== "object" || usage === null) return [];
390 const usageRecord = usage as Record<string, unknown>;
391
392 const sessionId = typeof paramsRecord["sessionId"] === "string" ? paramsRecord["sessionId"] : "";
393 const promptId = typeof updateRecord["prompt_id"] === "string" ? updateRecord["prompt_id"] : null;
394
395 // Prefer the high-resolution agent clock; fall back to the outer unix seconds.
396 const meta = paramsRecord["_meta"];
397 let timestampMs: number | null = null;
398 if (typeof meta === "object" && meta !== null) {
399 const agentTimestampMs = (meta as Record<string, unknown>)["agentTimestampMs"];
400 if (typeof agentTimestampMs === "number" && Number.isFinite(agentTimestampMs)) {
401 timestampMs = agentTimestampMs;
402 }
403 }
404 if (timestampMs === null) {
405 const timestamp = record["timestamp"];
406 if (typeof timestamp === "number" && Number.isFinite(timestamp)) {
407 timestampMs = timestamp > 1e12 ? timestamp : timestamp * 1000;
408 }
409 }
410 if (timestampMs === null) return [];
411
412 const topLevel = readGrokUsageTotals(usageRecord);
413 if (topLevel === null) return [];
414
415 const modelUsage = usageRecord["modelUsage"];
416 const modelEntries: Array<{ model: string; totals: GrokUsageTotals }> = [];
417 if (typeof modelUsage === "object" && modelUsage !== null) {
418 for (const [model, raw] of Object.entries(modelUsage as Record<string, unknown>)) {
419 if (model.length === 0) continue;
420 const totals = readGrokUsageTotals(raw);
421 if (totals === null) continue;
422 modelEntries.push({ model, totals });
423 }
424 }
425
426 if (modelEntries.length === 0) {
427 if (totalTokens(grokTotalsToUsage(topLevel)) === 0) return [];
428 return [
429 {
430 provider: "grok",
431 timestampMs,
432 model: "grok",
433 sessionId,
434 totals: grokTotalsToUsage(topLevel),
435 reportedCostUsd: grokCostTicksToUsd(topLevel.costUsdTicks),
436 // No prompt id means we cannot tell two same-second updates apart.
437 dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:grok`,
438 },
439 ];
440 }
441
442 // Cost allocation:
443 // 1. Emitted models with their own costUsdTicks keep those values.
444 // 2. Remaining aggregate cost (top-level minus those per-model ticks,
445 // clamped at 0) is pro-rated across emitted models that lack ticks,
446 // by token share among the unticked models only.
447 // 3. When no model has per-model ticks, remaining equals the full
448 // aggregate and every emitted model gets a token-share slice.
449 // Zero-token rows are never emitted and never count toward used ticks.
450 const topLevelCostUsd = grokCostTicksToUsd(topLevel.costUsdTicks);
451 let usedTickedCostUsd = 0;
452 let untickedTokenDenominator = 0;
453 for (const entry of modelEntries) {
454 const tokens = totalTokens(grokTotalsToUsage(entry.totals));
455 if (tokens === 0) continue;
456 if (entry.totals.costUsdTicks !== null) {
457 usedTickedCostUsd += grokCostTicksToUsd(entry.totals.costUsdTicks) ?? 0;
458 } else {
459 untickedTokenDenominator += tokens;
460 }
461 }
462 const remainingCostUsd =
463 topLevelCostUsd === null ? null : Math.max(0, topLevelCostUsd - usedTickedCostUsd);
464
465 const results: UsageRecord[] = [];
466 for (const entry of modelEntries) {
467 const totals = grokTotalsToUsage(entry.totals);
468 if (totalTokens(totals) === 0) continue;
469
470 let reportedCostUsd = grokCostTicksToUsd(entry.totals.costUsdTicks);
471 if (reportedCostUsd === null && remainingCostUsd !== null && untickedTokenDenominator > 0) {
472 reportedCostUsd = remainingCostUsd * (totalTokens(totals) / untickedTokenDenominator);
473 }
474
475 results.push({
476 provider: "grok",
477 timestampMs,
478 model: entry.model,
479 sessionId,
480 totals,
481 reportedCostUsd,
482 dedupeKey: promptId === null ? null : `${sessionId}:${promptId}:${entry.model}`,
483 });
484 }
485 return results;
486}
487
300488export { EMPTY_TOTALS };
apps/web/src/components/usage/UsageProviderChart.test.ts+1
......@@ -86,6 +86,7 @@ describe("buildDayColumns", () => {
8686 expect(first?.bands).toEqual([
8787 { provider: "codex", value: 10 },
8888 { provider: "claude", value: 20 },
89 { provider: "grok", value: 0 },
8990 ]);
9091 });
9192
apps/web/src/components/usage/usageProviders.ts+7-1
......@@ -1,6 +1,6 @@
11import type { UsageProviderKind } from "@t3tools/contracts";
22
3import { ClaudeAI, type Icon, OpenAI } from "../Icons";
3import { ClaudeAI, GrokIcon, type Icon, OpenAI } from "../Icons";
44
55type UsageProviderPresentation = {
66 readonly label: string;
......@@ -24,6 +24,12 @@ export const PROVIDER_PRESENTATION = {
2424 color: "#d97757",
2525 mark: ClaudeAI,
2626 },
27 grok: {
28 label: "Grok Build",
29 // Contrast-aware neutral between the Codex series and muted chart chrome.
30 color: "color-mix(in oklab, var(--contrast-foreground) 72%, var(--background))",
31 mark: GrokIcon,
32 },
2733} satisfies Record<UsageProviderKind, UsageProviderPresentation>;
2834
2935/** Stable provider reading order across charts, summaries, tables, and hover rows. */
docs/user/install.md+4
......@@ -68,6 +68,10 @@ them on in **Settings** → the provider's card when you want to use them.
6868Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that
6969T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`.
7070
71Grok models that support adjustable reasoning show a **Reasoning** control beside the model picker.
72The available levels and default come from the installed Grok Build CLI, so they can vary by model
73and CLI version.
74
7175Run the login command on the machine running the T3 Code server, not on the device you browse
7276from.
7377
docs/user/permission-modes.md+7-3
......@@ -26,6 +26,9 @@ unattended until it finishes or asks a question of its own.
2626Approvals appear inline in the conversation. Approve or reject one and the agent continues from
2727there.
2828
29For Grok, **Always allow this session** remembers the matching command or tool input. Other
30actions still ask for approval. It does not change the thread to **Full access**.
31
2932## Choosing a Mode
3033
3134Use **Full access** for work in a worktree or a sandbox you can throw away.
......@@ -40,8 +43,9 @@ shell commands.
4043
4144Each provider maps these modes onto its own approval and sandbox settings. Codex, for example,
4245translates the mode into its approval policy and sandbox level, so **Supervised** runs the CLI
43with prompting enabled and a restricted workspace while **Full access** disables both. The
44labels above describe what you get; the exact per-provider translation is internal and may
45change.
46with prompting enabled and a restricted workspace while **Full access** disables both. Grok
47threads do the same: **Supervised** starts Grok in ask mode even if your Grok CLI config is
48set to always-approve, and **Full access** starts Grok with always-approve. The labels above
49describe what you get; the exact per-provider translation is internal and may change.
4650
4751Mobile offers the same four modes with the same labels and descriptions.
docs/user/usage.md+7-4
......@@ -1,9 +1,12 @@
11# Review usage
22
3The Usage page combines Codex and Claude Code activity from your connected environments. It reads
4the providers' local session history and shows API-equivalent token cost, processed tokens, cache
5savings, provider shares, and model breakdowns. Subscription billing is separate from the raw token
6cost shown here.
3The Usage page combines Codex, Claude Code, and Grok Build activity from your connected
4environments. It reads the providers' local session history and shows API-equivalent token cost,
5processed tokens, cache savings, provider shares, and model breakdowns. Subscription billing is
6separate from the raw token cost shown here.
7
8Grok Build totals come from persisted session updates. Interactive turns that never wrote a
9completed-turn record will not appear.
710
811Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**,
912**30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the
packages/contracts/src/usage.ts+15-6
......@@ -2,10 +2,10 @@
22 * Usage reporting contract.
33 *
44 * Each environment scans the provider CLIs' own on-disk session transcripts
5 * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather than
6 * relying on T3 Code's own orchestration projections, so usage stays complete
7 * even for turns that were never driven through T3 Code. This mirrors the
8 * approach `ccusage` takes.
5 * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`,
6 * `~/.grok/sessions/**\/updates.jsonl`) rather than relying on T3 Code's own
7 * orchestration projections, so usage stays complete even for turns that were
8 * never driven through T3 Code. This mirrors the approach `ccusage` takes.
99 *
1010 * Environments return pre-aggregated `(day, hourStart?, provider, model)`
1111 * buckets. Raw transcript records never cross the wire.
......@@ -21,9 +21,18 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
2121 * client renders partial coverage when an environment reports an older version
2222 * rather than failing the whole page.
2323 */
24export const USAGE_CONTRACT_VERSION = 4 as const;
24export const USAGE_CONTRACT_VERSION = 5 as const;
2525
26export const UsageProviderKind = Schema.Literals(["claude", "codex"]);
26/**
27 * Oldest {@link UsageSummary} version a current client will still merge.
28 *
29 * v5 only adds `grok` to {@link UsageProviderKind}; v4 Claude/Codex buckets
30 * remain valid, so mixed-version environments keep those totals instead of
31 * treating every older server as stale.
32 */
33export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const;
34
35export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]);
2736export type UsageProviderKind = typeof UsageProviderKind.Type;
2837
2938/**
packages/shared/src/usageMerge.test.ts+27-1
......@@ -158,7 +158,7 @@ describe("mergeUsage", () => {
158158 summary(
159159 [bucket()],
160160 [{ provider: "claude", hostId: "linux", homePath: "/b" }],
161 USAGE_CONTRACT_VERSION - 1,
161 USAGE_CONTRACT_VERSION - 2,
162162 ),
163163 ),
164164 ],
......@@ -169,6 +169,32 @@ describe("mergeUsage", () => {
169169 expect(merged.staleEnvironments).toEqual(["env-b"]);
170170 });
171171
172 it("keeps the previous compatible contract version so additive provider expansions still merge", () => {
173 const merged = mergeUsage(
174 [
175 environment(
176 "env-a",
177 summary(
178 [bucket({ costUsd: 10 })],
179 [{ provider: "claude", hostId: "mac", homePath: "/a" }],
180 ),
181 ),
182 environment(
183 "env-b",
184 summary(
185 [bucket({ costUsd: 4, provider: "codex", model: "gpt-5.6-sol" })],
186 [{ provider: "codex", hostId: "linux", homePath: "/b" }],
187 USAGE_CONTRACT_VERSION - 1,
188 ),
189 ),
190 ],
191 USAGE_CONTRACT_VERSION,
192 );
193
194 expect(merged.costUsd).toBe(14);
195 expect(merged.staleEnvironments).toEqual([]);
196 });
197
172198 it("derives provider shares and cost quality", () => {
173199 const merged = mergeUsage(
174200 [
packages/shared/src/usageMerge.ts+16-9
......@@ -6,12 +6,13 @@
66 *
77 * @module usageMerge
88 */
9import type {
10 EnvironmentId,
11 UsageBucket,
12 UsageProviderKind,
13 UsageSourceFingerprint,
14 UsageSummary,
9import {
10 USAGE_MERGE_COMPATIBLE_SINCE,
11 type EnvironmentId,
12 type UsageBucket,
13 type UsageProviderKind,
14 type UsageSourceFingerprint,
15 type UsageSummary,
1516} from "@t3tools/contracts";
1617
1718export interface EnvironmentUsage {
......@@ -172,6 +173,10 @@ function bucketTokens(bucket: UsageBucket): number {
172173 );
173174}
174175
176function isCompatibleContractVersion(version: number, expected: number): boolean {
177 return version >= USAGE_MERGE_COMPATIBLE_SINCE && version <= expected;
178}
179
175180const EMPTY_MERGED: MergedUsage = {
176181 costUsd: 0,
177182 uncachedInputTokens: 0,
......@@ -201,8 +206,10 @@ const EMPTY_MERGED: MergedUsage = {
201206 * Merges every connected environment's summary.
202207 *
203208 * `expectedContractVersion` guards against an environment running older server
204 * code: rather than blocking the page, its data is excluded and its id is
205 * reported so the UI can say coverage is partial.
209 * code: rather than blocking the page, incompatible data is excluded and its
210 * id is reported so the UI can say coverage is partial. Versions in
211 * [{@link USAGE_MERGE_COMPATIBLE_SINCE}, expected] still merge, so an additive
212 * provider expansion does not drop Claude/Codex totals from older servers.
206213 */
207214export function mergeUsage(
208215 environments: readonly EnvironmentUsage[],
......@@ -213,7 +220,7 @@ export function mergeUsage(
213220 const current: EnvironmentUsage[] = [];
214221 const staleEnvironments: EnvironmentId[] = [];
215222 for (const environment of environments) {
216 if (environment.summary.contractVersion === expectedContractVersion) {
223 if (isCompatibleContractVersion(environment.summary.contractVersion, expectedContractVersion)) {
217224 current.push(environment);
218225 } else {
219226 staleEnvironments.push(environment.environmentId);