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...@@ -5,21 +5,23 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe
5 * Series and table order. The chart stacks providers from the bottom in this5 * Series and table order. The chart stacks providers from the bottom in this
6 * order, so it also fixes which band sits on top of the bars.6 * order, so it also fixes which band sits on top of the bars.
7 */7 */
8export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"];8export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"];
99
10export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {10export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {
11 claude: "Claude Code",11 claude: "Claude Code",
12 codex: "Codex",12 codex: "Codex",
13 grok: "Grok Build",
13};14};
1415
15/**16/**
16 * Claude's brand orange holds in both themes; Codex is neutral and must flip17 * Claude's brand orange holds in both themes; Codex and Grok are neutrals and
17 * with the theme or its bars vanish against the matching background.18 * must flip with the theme or their bars vanish against the matching background.
18 */19 */
19export function useProviderColors(): Record<UsageProviderKind, string> {20export function useProviderColors(): Record<UsageProviderKind, string> {
20 const { themeAppearance: scheme } = useAppearancePreferences();21 const { themeAppearance: scheme } = useAppearancePreferences();
21 return {22 return {
22 claude: "#d97757",23 claude: "#d97757",
23 codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",24 codex: scheme === "dark" ? "#e6e6e6" : "#3c3c43",
25 grok: scheme === "dark" ? "#a1a1aa" : "#52525b",
24 };26 };
25}27}
apps/server/scripts/acp-mock-agent.ts+212-30
...@@ -19,7 +19,15 @@ const emitInterleavedAssistantToolCalls =...@@ -19,7 +19,15 @@ const emitInterleavedAssistantToolCalls =
19const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";19const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
20const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";20const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
21const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";21const 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";
22const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1";24const 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";
23const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1";31const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1";
24const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1";32const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1";
25const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1";33const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1";
...@@ -39,12 +47,19 @@ const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1";...@@ -39,12 +47,19 @@ const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1";
39const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";47const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
40const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";48const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
41const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;49const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
50const initialGrokReasoningEffort =
51 process.env.T3_ACP_INITIAL_GROK_REASONING_EFFORT?.trim() || undefined;
42const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0");52const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0");
43const permissionOptionIds = {53const permissionOptionIds = {
44 allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once",54 allowOnce: process.env.T3_ACP_ALLOW_ONCE_OPTION_ID ?? "allow-once",
45 allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",55 allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",
46 rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once",56 rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once",
47};57};
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);
48const sessionId = "mock-session-1";63const sessionId = "mock-session-1";
4964
50let currentModeId = "ask";65let currentModeId = "ask";
...@@ -279,7 +294,13 @@ function modeState(): AcpSchema.SessionModeState {...@@ -279,7 +294,13 @@ function modeState(): AcpSchema.SessionModeState {
279}294}
280295
281const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [296const 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 },
283 { modelId: "grok-mock-alt", name: "Grok Mock Alt" },304 { modelId: "grok-mock-alt", name: "Grok Mock Alt" },
284];305];
285306
...@@ -522,6 +543,68 @@ const program = Effect.gen(function* () {...@@ -522,6 +543,68 @@ const program = Effect.gen(function* () {
522 return yield* Effect.never;543 return yield* Effect.never;
523 }544 }
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
525 if (emitXAiPromptCompleteThenHang) {608 if (emitXAiPromptCompleteThenHang) {
526 writeJsonRpcNotification("session/update", {609 writeJsonRpcNotification("session/update", {
527 sessionId: requestedSessionId,610 sessionId: requestedSessionId,
...@@ -656,37 +739,58 @@ const program = Effect.gen(function* () {...@@ -656,37 +739,58 @@ const program = Effect.gen(function* () {
656 },739 },
657 });740 });
658741
659 const permission = yield* agent.client.requestPermission({742 const permissionOptions: Array<AcpSchema.PermissionOption> = [
660 sessionId: requestedSessionId,743 { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" },
661 toolCall: {744 ...(omitAllowAlways
662 toolCallId,745 ? []
663 title: "`cat server/package.json`",746 : [
664 kind: "execute",747 {
665 status: "pending",748 optionId: permissionOptionIds.allowAlways,
666 content: [749 name: "Allow always",
667 {750 kind: "allow_always" as const,
668 type: "content",
669 content: {
670 type: "text",
671 text: "Not in allowlist: cat server/package.json",
672 },751 },
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",
673 },773 },
674 ],774 content: [
675 },775 {
676 options: [776 type: "content",
677 { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" },777 content: {
678 {778 type: "text",
679 optionId: permissionOptionIds.allowAlways,779 text: `Not in allowlist: ${command}`,
680 name: "Allow always",780 },
681 kind: "allow_always",781 },
782 ],
682 },783 },
683 { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" },784 options: permissionOptions,
684 ],785 });
685 });786 cancelled =
686787 cancelled ||
687 const cancelled =788 cancelledSessions.delete(requestedSessionId) ||
688 cancelledSessions.delete(requestedSessionId) ||789 permission.outcome.outcome === "cancelled";
689 permission.outcome.outcome === "cancelled";790 if (cancelled) {
791 break;
792 }
793 }
690794
691 yield* agent.client.sessionUpdate({795 yield* agent.client.sessionUpdate({
692 sessionId: requestedSessionId,796 sessionId: requestedSessionId,
...@@ -773,7 +877,7 @@ const program = Effect.gen(function* () {...@@ -773,7 +877,7 @@ const program = Effect.gen(function* () {
773 return { stopReason: "end_turn" };877 return { stopReason: "end_turn" };
774 }878 }
775879
776 if (emitXAiAskUserQuestion) {880 if (emitXAiAskUserQuestion || emitXAiAskUserQuestionThenHang) {
777 const result = yield* agent.client.extRequest("_x.ai/ask_user_question", {881 const result = yield* agent.client.extRequest("_x.ai/ask_user_question", {
778 method: "x.ai/ask_user_question",882 method: "x.ai/ask_user_question",
779 params: {883 params: {
...@@ -807,6 +911,84 @@ const program = Effect.gen(function* () {...@@ -807,6 +911,84 @@ const program = Effect.gen(function* () {
807 throw new Error("Expected accepted _x.ai/ask_user_question response answers.");911 throw new Error("Expected accepted _x.ai/ask_user_question response answers.");
808 }912 }
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 }
810 return { stopReason: "end_turn" };992 return { stopReason: "end_turn" };
811 }993 }
812994
apps/server/src/provider/Drivers/GrokDriver.ts+2-1
...@@ -88,6 +88,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {...@@ -88,6 +88,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
88 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;88 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
89 const httpClient = yield* HttpClient.HttpClient;89 const httpClient = yield* HttpClient.HttpClient;
90 const serverSettings = yield* ServerSettingsService;90 const serverSettings = yield* ServerSettingsService;
91 const { cwd } = yield* ServerConfig;
91 const eventLoggers = yield* ProviderEventLoggers;92 const eventLoggers = yield* ProviderEventLoggers;
92 const processEnv = mergeProviderInstanceEnvironment(environment);93 const processEnv = mergeProviderInstanceEnvironment(environment);
93 const continuationIdentity = defaultProviderContinuationIdentity({94 const continuationIdentity = defaultProviderContinuationIdentity({
...@@ -113,7 +114,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {...@@ -113,7 +114,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
113 });114 });
114 const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv);115 const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv);
115116
116 const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe(117 const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe(
117 Effect.map(stampIdentity),118 Effect.map(stampIdentity),
118 Effect.provideService(Crypto.Crypto, crypto),119 Effect.provideService(Crypto.Crypto, crypto),
119 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),120 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 {...@@ -26,7 +26,13 @@ import {
26} from "@t3tools/contracts";26} from "@t3tools/contracts";
2727
28import { ServerConfig } from "../../config.ts";28import { 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";
30const decodeGrokSettings = Schema.decodeSync(GrokSettings);36const decodeGrokSettings = Schema.decodeSync(GrokSettings);
3137
32const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));38const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
...@@ -89,6 +95,90 @@ const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), {...@@ -89,6 +95,90 @@ const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), {
89const makeTestAdapter = (binaryPath: string, options?: Parameters<typeof makeGrokAdapter>[1]) =>95const makeTestAdapter = (binaryPath: string, options?: Parameters<typeof makeGrokAdapter>[1]) =>
90 makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie);96 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
92it("requires a settlement to match the live Grok turn", () => {182it("requires a settlement to match the live Grok turn", () => {
93 const staleTurnId = TurnId.make("stale-turn");183 const staleTurnId = TurnId.make("stale-turn");
94 const replacementTurnId = TurnId.make("replacement-turn");184 const replacementTurnId = TurnId.make("replacement-turn");
...@@ -418,6 +508,362 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {...@@ -418,6 +508,362 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
418 }),508 }),
419 );509 );
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
421 it.effect("retains turn transcript when sendTurn is interrupted after prompt success", () =>867 it.effect("retains turn transcript when sendTurn is interrupted after prompt success", () =>
422 Effect.gen(function* () {868 Effect.gen(function* () {
423 const threadId = ThreadId.make("grok-send-turn-interrupt-after-prompt");869 const threadId = ThreadId.make("grok-send-turn-interrupt-after-prompt");
...@@ -943,6 +1389,64 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {...@@ -943,6 +1389,64 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
943 }),1389 }),
944 );1390 );
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
946 it.effect("ignores replayed session/load updates when resuming a Grok session", () =>1450 it.effect("ignores replayed session/load updates when resuming a Grok session", () =>
947 Effect.gen(function* () {1451 Effect.gen(function* () {
948 const threadId = ThreadId.make("grok-load-replay-filter");1452 const threadId = ThreadId.make("grok-load-replay-filter");
...@@ -1097,6 +1601,247 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {...@@ -1097,6 +1601,247 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
1097 }),1601 }),
1098 );1602 );
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
1100 it.effect("handles xAI ask_user_question extension requests", () =>1845 it.effect("handles xAI ask_user_question extension requests", () =>
1101 Effect.gen(function* () {1846 Effect.gen(function* () {
1102 const threadId = ThreadId.make("grok-xai-ask-user-question");1847 const threadId = ThreadId.make("grok-xai-ask-user-question");
...@@ -1159,6 +1904,82 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {...@@ -1159,6 +1904,82 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => {
1159 }),1904 }),
1160 );1905 );
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
1162 it.effect("continues streaming events when native notification logging fails", () =>1983 it.effect("continues streaming events when native notification logging fails", () =>
1163 Effect.gen(function* () {1984 Effect.gen(function* () {
1164 const threadId = ThreadId.make("grok-native-log-failure");1985 const threadId = ThreadId.make("grok-native-log-failure");
apps/server/src/provider/Layers/GrokAdapter.ts+597-23
...@@ -12,9 +12,14 @@ import {...@@ -12,9 +12,14 @@ import {
12 type ThreadId,12 type ThreadId,
13 TurnId,13 TurnId,
14} from "@t3tools/contracts";14} 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";
15import * as Crypto from "effect/Crypto";19import * as Crypto from "effect/Crypto";
16import * as DateTime from "effect/DateTime";20import * as DateTime from "effect/DateTime";
17import * as Deferred from "effect/Deferred";21import * as Deferred from "effect/Deferred";
22import * as Duration from "effect/Duration";
18import * as Effect from "effect/Effect";23import * as Effect from "effect/Effect";
19import * as Exit from "effect/Exit";24import * as Exit from "effect/Exit";
20import * as Fiber from "effect/Fiber";25import * as Fiber from "effect/Fiber";
...@@ -22,6 +27,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -22,6 +27,7 @@ import * as FileSystem from "effect/FileSystem";
22import * as Option from "effect/Option";27import * as Option from "effect/Option";
23import * as Path from "effect/Path";28import * as Path from "effect/Path";
24import * as PubSub from "effect/PubSub";29import * as PubSub from "effect/PubSub";
30import * as Queue from "effect/Queue";
25import * as Ref from "effect/Ref";31import * as Ref from "effect/Ref";
26import * as Schema from "effect/Schema";32import * as Schema from "effect/Schema";
27import * as Scope from "effect/Scope";33import * as Scope from "effect/Scope";
...@@ -56,15 +62,21 @@ import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";...@@ -56,15 +62,21 @@ import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";
56import {62import {
57 applyGrokAcpModelSelection,63 applyGrokAcpModelSelection,
58 currentGrokModelIdFromSessionSetup,64 currentGrokModelIdFromSessionSetup,
65 currentGrokReasoningEffortFromSessionSetup,
59 makeGrokAcpRuntime,66 makeGrokAcpRuntime,
67 normalizeGrokReasoningEffort,
60 resolveGrokAcpBaseModelId,68 resolveGrokAcpBaseModelId,
61} from "../acp/GrokAcpSupport.ts";69} from "../acp/GrokAcpSupport.ts";
62import {70import {
71 extractGrokPlanMarkdownFromToolCallData,
63 extractXAiAskUserQuestions,72 extractXAiAskUserQuestions,
73 extractXAiExitPlanMarkdown,
64 makeXAiAskUserQuestionCancelledResponse,74 makeXAiAskUserQuestionCancelledResponse,
65 makeXAiAskUserQuestionResponse,75 makeXAiAskUserQuestionResponse,
76 makeXAiExitPlanModeCapturedResponse,
66 promptResponseHasMissingXAiStopReason,77 promptResponseHasMissingXAiStopReason,
67 XAiAskUserQuestionRequest,78 XAiAskUserQuestionRequest,
79 XAiExitPlanModeRequest,
68} from "../acp/XAiAcpExtension.ts";80} from "../acp/XAiAcpExtension.ts";
69import { type GrokAdapterShape } from "../Services/GrokAdapter.ts";81import { type GrokAdapterShape } from "../Services/GrokAdapter.ts";
70import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";82import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
...@@ -73,6 +85,15 @@ const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonStri...@@ -73,6 +85,15 @@ const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonStri
7385
74const PROVIDER = ProviderDriverKind.make("grok");86const PROVIDER = ProviderDriverKind.make("grok");
75const GROK_RESUME_VERSION = 1 as const;87const 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
77function encodeJsonStringForDiagnostics(input: unknown): string | undefined {98function encodeJsonStringForDiagnostics(input: unknown): string | undefined {
78 const result = encodeUnknownJsonStringExit(input);99 const result = encodeUnknownJsonStringExit(input);
...@@ -84,6 +105,10 @@ export interface GrokAdapterLiveOptions {...@@ -84,6 +105,10 @@ export interface GrokAdapterLiveOptions {
84 readonly nativeEventLogPath?: string;105 readonly nativeEventLogPath?: string;
85 readonly nativeEventLogger?: EventNdjsonLogger;106 readonly nativeEventLogger?: EventNdjsonLogger;
86 readonly instanceId?: ProviderInstanceId;107 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;
87}112}
88113
89interface PendingApproval {114interface PendingApproval {
...@@ -98,6 +123,10 @@ interface PendingUserInput {...@@ -98,6 +123,10 @@ interface PendingUserInput {
98 readonly resolution: Deferred.Deferred<PendingUserInputResolution>;123 readonly resolution: Deferred.Deferred<PendingUserInputResolution>;
99}124}
100125
126interface GrokTurnLivenessSignal {
127 readonly turnId: TurnId;
128}
129
101interface GrokSessionContext {130interface GrokSessionContext {
102 readonly threadId: ThreadId;131 readonly threadId: ThreadId;
103 readonly acpSessionId: string;132 readonly acpSessionId: string;
...@@ -109,6 +138,14 @@ interface GrokSessionContext {...@@ -109,6 +138,14 @@ interface GrokSessionContext {
109 readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;138 readonly pendingUserInputs: Map<ApprovalRequestId, PendingUserInput>;
110 turns: Array<{ id: TurnId; items: Array<unknown> }>;139 turns: Array<{ id: TurnId; items: Array<unknown> }>;
111 lastPlanFingerprint: string | undefined;140 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;
112 activeTurnId: TurnId | undefined;149 activeTurnId: TurnId | undefined;
113 /** Turns already interrupted; late prompt RPCs must not resurrect them. */150 /** Turns already interrupted; late prompt RPCs must not resurrect them. */
114 interruptedTurnIds: Set<TurnId>;151 interruptedTurnIds: Set<TurnId>;
...@@ -116,7 +153,15 @@ interface GrokSessionContext {...@@ -116,7 +153,15 @@ interface GrokSessionContext {
116 * >0 means a turn is actively running, so a new sendTurn is a steer that153 * >0 means a turn is actively running, so a new sendTurn is a steer that
117 * continues it, and only the last remaining prompt settles the turn. */154 * continues it, and only the last remaining prompt settles the turn. */
118 promptsInFlight: number;155 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;
119 currentModelId: string | undefined;163 currentModelId: string | undefined;
164 currentReasoningEffort: string | undefined;
120 stopped: boolean;165 stopped: boolean;
121}166}
122167
...@@ -164,6 +209,54 @@ const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined...@@ -164,6 +209,54 @@ const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined
164209
165const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId;210const 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
167const resolveSessionCallbackTurnId = (260const resolveSessionCallbackTurnId = (
168 sessions: ReadonlyMap<ThreadId, GrokSessionContext>,261 sessions: ReadonlyMap<ThreadId, GrokSessionContext>,
169 threadId: ThreadId,262 threadId: ThreadId,
...@@ -179,26 +272,38 @@ function parseGrokResume(raw: unknown): { sessionId: string } | undefined {...@@ -179,26 +272,38 @@ function parseGrokResume(raw: unknown): { sessionId: string } | undefined {
179 return { sessionId: raw.sessionId.trim() };272 return { sessionId: raw.sessionId.trim() };
180}273}
181274
182function selectPermissionOptionId(275export function selectGrokPermissionOptionId(
183 request: EffectAcpSchema.RequestPermissionRequest,276 request: EffectAcpSchema.RequestPermissionRequest,
184 decision: Exclude<ProviderApprovalDecision, "cancel">,277 decision: Exclude<ProviderApprovalDecision, "cancel">,
185): string | undefined {278): string | undefined {
186 const kind =279 const preferredKind =
187 decision === "acceptForSession"280 decision === "acceptForSession"
188 ? "allow_always"281 ? "allow_always"
189 : decision === "accept"282 : decision === "accept"
190 ? "allow_once"283 ? "allow_once"
191 : "reject_once";284 : "reject_once";
192 const option = request.options.find((entry) => entry.kind === kind);285 const preferred = request.options.find((entry) => entry.kind === preferredKind);
193 return option?.optionId.trim() || undefined;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;
194}299}
195300
196function selectAutoApprovedPermissionOption(301function selectAutoApprovedPermissionOption(
197 request: EffectAcpSchema.RequestPermissionRequest,302 request: EffectAcpSchema.RequestPermissionRequest,
198): string | undefined {303): string | undefined {
199 return (304 return (
200 selectPermissionOptionId(request, "acceptForSession") ??305 selectGrokPermissionOptionId(request, "acceptForSession") ??
201 selectPermissionOptionId(request, "accept")306 selectGrokPermissionOptionId(request, "accept")
202 );307 );
203}308}
204309
...@@ -240,10 +345,31 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -240,10 +345,31 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
240 const managedNativeEventLogger =345 const managedNativeEventLogger =
241 options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;346 options?.nativeEventLogger === undefined ? nativeEventLogger : undefined;
242 const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory();347 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
244 const sessions = new Map<ThreadId, GrokSessionContext>();355 const sessions = new Map<ThreadId, GrokSessionContext>();
245 const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>());356 const threadLocksRef = yield* SynchronizedRef.make(new Map<string, Semaphore.Semaphore>());
246 const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();357 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
248 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);374 const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
249 const randomUUIDv4 = crypto.randomUUIDv4.pipe(375 const randomUUIDv4 = crypto.randomUUIDv4.pipe(
...@@ -294,6 +420,146 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -294,6 +420,146 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
294 const withThreadLock = <A, E, R>(threadId: string, effect: Effect.Effect<A, E, R>) =>420 const withThreadLock = <A, E, R>(threadId: string, effect: Effect.Effect<A, E, R>) =>
295 Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect));421 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
297 const settlePromptInFlight = (563 const settlePromptInFlight = (
298 threadId: ThreadId,564 threadId: ThreadId,
299 turnId: TurnId,565 turnId: TurnId,
...@@ -373,6 +639,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -373,6 +639,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
373 updatedAt,639 updatedAt,
374 };640 };
375 }641 }
642 yield* clearTurnLiveness(liveCtx);
376 return;643 return;
377 }644 }
378 settleTurnId = fallbackTurnId;645 settleTurnId = fallbackTurnId;
...@@ -389,6 +656,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -389,6 +656,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
389 }656 }
390 liveCtx.promptsInFlight = remainingPrompts;657 liveCtx.promptsInFlight = remainingPrompts;
391 }658 }
659 yield* clearTurnLiveness(liveCtx);
392 const updatedAt = yield* nowIso;660 const updatedAt = yield* nowIso;
393 const canEmitTurnCompletion =661 const canEmitTurnCompletion =
394 liveCtx.session.status === "running" || liveCtx.session.status === "connecting";662 liveCtx.session.status === "running" || liveCtx.session.status === "connecting";
...@@ -397,6 +665,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -397,6 +665,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
397 options?.completedStopReason !== undefined && canEmitTurnCompletion;665 options?.completedStopReason !== undefined && canEmitTurnCompletion;
398 const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session;666 const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session;
399 liveCtx.activeTurnId = undefined;667 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);
400 liveCtx.session = {671 liveCtx.session = {
401 ...readySession,672 ...readySession,
402 status: "ready",673 status: "ready",
...@@ -432,6 +703,104 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -432,6 +703,104 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
432 }703 }
433 });704 });
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
435 const logNative = (threadId: ThreadId, method: string, payload: unknown) =>804 const logNative = (threadId: ThreadId, method: string, payload: unknown) =>
436 Effect.gen(function* () {805 Effect.gen(function* () {
437 if (!nativeEventLogger) return;806 if (!nativeEventLogger) return;
...@@ -495,6 +864,45 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -495,6 +864,45 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
495 );864 );
496 });865 });
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
498 const requireSession = (906 const requireSession = (
499 threadId: ThreadId,907 threadId: ThreadId,
500 ): Effect.Effect<GrokSessionContext, ProviderAdapterSessionNotFoundError> => {908 ): Effect.Effect<GrokSessionContext, ProviderAdapterSessionNotFoundError> => {
...@@ -556,6 +964,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -556,6 +964,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
556964
557 const pendingApprovals = new Map<ApprovalRequestId, PendingApproval>();965 const pendingApprovals = new Map<ApprovalRequestId, PendingApproval>();
558 const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>();966 const pendingUserInputs = new Map<ApprovalRequestId, PendingUserInput>();
967 const sessionApprovedOperations = new Set<string>();
559 const sessionScope = yield* Scope.make("sequential");968 const sessionScope = yield* Scope.make("sequential");
560 let sessionScopeTransferred = false;969 let sessionScopeTransferred = false;
561 yield* Effect.addFinalizer(() =>970 yield* Effect.addFinalizer(() =>
...@@ -575,6 +984,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -575,6 +984,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
575 ...(options?.environment ? { environment: options.environment } : {}),984 ...(options?.environment ? { environment: options.environment } : {}),
576 childProcessSpawner,985 childProcessSpawner,
577 cwd,986 cwd,
987 runtimeMode: input.runtimeMode,
578 ...(resumeSessionId ? { resumeSessionId } : {}),988 ...(resumeSessionId ? { resumeSessionId } : {}),
579 clientInfo: { name: "t3-code", version: "0.0.0" },989 clientInfo: { name: "t3-code", version: "0.0.0" },
580 ...(mcpSession990 ...(mcpSession
...@@ -621,6 +1031,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -621,6 +1031,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
621 const resolution = yield* Deferred.make<PendingUserInputResolution>();1031 const resolution = yield* Deferred.make<PendingUserInputResolution>();
622 const turnId = resolveSessionCallbackTurnId(sessions, input.threadId);1032 const turnId = resolveSessionCallbackTurnId(sessions, input.threadId);
623 pendingUserInputs.set(requestId, { resolution });1033 pendingUserInputs.set(requestId, { resolution });
1034 yield* signalSessionTurnLiveness(input.threadId, turnId);
624 yield* offerRuntimeEvent({1035 yield* offerRuntimeEvent({
625 type: "user-input.requested",1036 type: "user-input.requested",
626 ...(yield* makeEventStamp()),1037 ...(yield* makeEventStamp()),
...@@ -637,6 +1048,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -637,6 +1048,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
637 });1048 });
638 const resolved = yield* Deferred.await(resolution);1049 const resolved = yield* Deferred.await(resolution);
639 pendingUserInputs.delete(requestId);1050 pendingUserInputs.delete(requestId);
1051 yield* resumeSessionTurnLiveness(input.threadId, turnId);
640 const resolvedAnswers = resolved._tag === "answered" ? resolved.answers : {};1052 const resolvedAnswers = resolved._tag === "answered" ? resolved.answers : {};
641 yield* offerRuntimeEvent({1053 yield* offerRuntimeEvent({
642 type: "user-input.resolved",1054 type: "user-input.resolved",
...@@ -663,12 +1075,77 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -663,12 +1075,77 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
663 ),1075 ),
664 { discard: true },1076 { discard: true },
665 );1077 );
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 );
666 yield* acp.handleRequestPermission((params) =>1124 yield* acp.handleRequestPermission((params) =>
667 mapAcpCallbackFailure(1125 mapAcpCallbackFailure(
668 Effect.gen(function* () {1126 Effect.gen(function* () {
669 yield* logNative(input.threadId, "session/request_permission", params);1127 yield* logNative(input.threadId, "session/request_permission", params);
670 if (input.runtimeMode === "full-access") {1128 const permissionRequest = parsePermissionRequest(params);
671 const autoApprovedOptionId = selectAutoApprovedPermissionOption(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");
672 if (autoApprovedOptionId !== undefined) {1149 if (autoApprovedOptionId !== undefined) {
673 return {1150 return {
674 outcome: {1151 outcome: {
...@@ -678,12 +1155,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -678,12 +1155,12 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
678 };1155 };
679 }1156 }
680 }1157 }
681 const permissionRequest = parsePermissionRequest(params);
682 const requestId = ApprovalRequestId.make(yield* randomUUIDv4);1158 const requestId = ApprovalRequestId.make(yield* randomUUIDv4);
683 const runtimeRequestId = RuntimeRequestId.make(requestId);1159 const runtimeRequestId = RuntimeRequestId.make(requestId);
684 const decision = yield* Deferred.make<ProviderApprovalDecision>();1160 const decision = yield* Deferred.make<ProviderApprovalDecision>();
685 const turnId = resolveSessionCallbackTurnId(sessions, input.threadId);1161 const turnId = resolveSessionCallbackTurnId(sessions, input.threadId);
686 pendingApprovals.set(requestId, { decision });1162 pendingApprovals.set(requestId, { decision });
1163 yield* signalSessionTurnLiveness(input.threadId, turnId);
687 yield* offerRuntimeEvent(1164 yield* offerRuntimeEvent(
688 makeAcpRequestOpenedEvent({1165 makeAcpRequestOpenedEvent({
689 stamp: yield* makeEventStamp(),1166 stamp: yield* makeEventStamp(),
...@@ -704,6 +1181,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -704,6 +1181,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
704 );1181 );
705 const resolved = yield* Deferred.await(decision);1182 const resolved = yield* Deferred.await(decision);
706 pendingApprovals.delete(requestId);1183 pendingApprovals.delete(requestId);
1184 yield* resumeSessionTurnLiveness(input.threadId, turnId);
707 yield* offerRuntimeEvent(1185 yield* offerRuntimeEvent(
708 makeAcpRequestResolvedEvent({1186 makeAcpRequestResolvedEvent({
709 stamp: yield* makeEventStamp(),1187 stamp: yield* makeEventStamp(),
...@@ -716,7 +1194,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -716,7 +1194,16 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
716 }),1194 }),
717 );1195 );
718 const selectedOptionId =1196 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 }
720 return {1207 return {
721 outcome: selectedOptionId1208 outcome: selectedOptionId
722 ? {1209 ? {
...@@ -738,10 +1225,22 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -738,10 +1225,22 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
738 const requestedStartModelId = grokModelSelection?.model1225 const requestedStartModelId = grokModelSelection?.model
739 ? resolveGrokAcpBaseModelId(grokModelSelection.model)1226 ? resolveGrokAcpBaseModelId(grokModelSelection.model)
740 : undefined;1227 : 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 );
741 const boundModelId = yield* applyGrokAcpModelSelection({1238 const boundModelId = yield* applyGrokAcpModelSelection({
742 runtime: acp,1239 runtime: acp,
743 currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult),1240 currentModelId: currentStartModelId,
1241 currentReasoningEffort: currentStartReasoningEffort,
744 requestedModelId: requestedStartModelId,1242 requestedModelId: requestedStartModelId,
1243 requestedReasoningEffort: requestedStartReasoningEffort,
745 mapError: (cause) =>1244 mapError: (cause) =>
746 mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),1245 mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
747 });1246 });
...@@ -774,10 +1273,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -774,10 +1273,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
774 pendingUserInputs,1273 pendingUserInputs,
775 turns: [],1274 turns: [],
776 lastPlanFingerprint: undefined,1275 lastPlanFingerprint: undefined,
1276 lastKnownProposedPlanMarkdown: undefined,
1277 lastKnownProposedPlanTurnId: undefined,
1278 planModeActive: false,
777 activeTurnId: undefined,1279 activeTurnId: undefined,
778 interruptedTurnIds: new Set(),1280 interruptedTurnIds: new Set(),
779 promptsInFlight: 0,1281 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,
780 currentModelId: boundModelId,1288 currentModelId: boundModelId,
1289 currentReasoningEffort:
1290 requestedStartReasoningEffort !== undefined
1291 ? normalizeGrokReasoningEffort(requestedStartReasoningEffort)
1292 : currentStartReasoningEffort,
781 stopped: false,1293 stopped: false,
782 };1294 };
7831295
...@@ -807,6 +1319,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -807,6 +1319,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
807 ) {1319 ) {
808 return;1320 return;
809 }1321 }
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 }
810 const stamp = yield* makeEventStamp();1331 const stamp = yield* makeEventStamp();
8111332
812 switch (event._tag) {1333 switch (event._tag) {
...@@ -844,7 +1365,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -844,7 +1365,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
844 "session/update",1365 "session/update",
845 );1366 );
846 return;1367 return;
847 case "ToolCallUpdated":1368 case "ToolCallUpdated": {
848 yield* offerRuntimeEvent(1369 yield* offerRuntimeEvent(
849 makeAcpToolCallEvent({1370 makeAcpToolCallEvent({
850 stamp,1371 stamp,
...@@ -855,7 +1376,30 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -855,7 +1376,30 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
855 rawPayload: event.rawPayload,1376 rawPayload: event.rawPayload,
856 }),1377 }),
857 );1378 );
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 }
858 return;1401 return;
1402 }
859 case "ContentDelta":1403 case "ContentDelta":
860 yield* offerRuntimeEvent(1404 yield* offerRuntimeEvent(
861 makeAcpContentDeltaEvent({1405 makeAcpContentDeltaEvent({
...@@ -887,6 +1431,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -887,6 +1431,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
8871431
888 ctx.notificationFiber = nf;1432 ctx.notificationFiber = nf;
889 sessions.set(input.threadId, ctx);1433 sessions.set(input.threadId, ctx);
1434 yield* runTurnLivenessWatchdog(ctx).pipe(Effect.forkIn(ctx.scope), Effect.asVoid);
890 sessionScopeTransferred = true;1435 sessionScopeTransferred = true;
8911436
892 yield* offerRuntimeEvent({1437 yield* offerRuntimeEvent({
...@@ -933,6 +1478,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -933,6 +1478,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
933 // Bind the turn id before cooperative yields so interruptTurn can1478 // Bind the turn id before cooperative yields so interruptTurn can
934 // settle this prompt even if stop arrives during preparation.1479 // settle this prompt even if stop arrives during preparation.
935 ctx.activeTurnId = turnId;1480 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 }
936 ctx.session = {1486 ctx.session = {
937 ...ctx.session,1487 ...ctx.session,
938 status: steeringTurnId === undefined ? "connecting" : "running",1488 status: steeringTurnId === undefined ? "connecting" : "running",
...@@ -948,13 +1498,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -948,13 +1498,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
948 const requestedTurnModelId = turnModelSelection?.model1498 const requestedTurnModelId = turnModelSelection?.model
949 ? resolveGrokAcpBaseModelId(turnModelSelection.model)1499 ? resolveGrokAcpBaseModelId(turnModelSelection.model)
950 : undefined;1500 : undefined;
951 const currentModelId = yield* applyGrokAcpModelSelection({1501 const requestedTurnReasoningEffort = getModelSelectionStringOptionValue(
952 runtime: ctx.acp,1502 turnModelSelection,
953 currentModelId: ctx.currentModelId,1503 "reasoningEffort",
954 requestedModelId: requestedTurnModelId,1504 );
955 mapError: (cause) =>
956 mapAcpToAdapterError(PROVIDER, input.threadId, "session/set_model", cause),
957 });
9581505
959 const text = input.input?.trim();1506 const text = input.input?.trim();
960 const imagePromptParts = yield* Effect.forEach(1507 const imagePromptParts = yield* Effect.forEach(
...@@ -1003,7 +1550,21 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1003,7 +1550,21 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1003 });1550 });
1004 }1551 }
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 });
1006 ctx.currentModelId = currentModelId;1562 ctx.currentModelId = currentModelId;
1563 if (requestedTurnReasoningEffort !== undefined) {
1564 ctx.currentReasoningEffort = normalizeGrokReasoningEffort(
1565 requestedTurnReasoningEffort,
1566 );
1567 }
1007 const displayModel = currentModelId1568 const displayModel = currentModelId
1008 ? resolveGrokAcpBaseModelId(currentModelId)1569 ? resolveGrokAcpBaseModelId(currentModelId)
1009 : undefined;1570 : undefined;
...@@ -1032,6 +1593,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1032,6 +1593,11 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1032 updatedAt: yield* nowIso,1593 updatedAt: yield* nowIso,
1033 ...(displayModel ? { model: displayModel } : {}),1594 ...(displayModel ? { model: displayModel } : {}),
1034 };1595 };
1596 if (steeringTurnId === undefined) {
1597 yield* beginTurnLiveness(ctx, turnId);
1598 } else {
1599 yield* refreshSessionTurnLiveness(input.threadId, turnId);
1600 }
10351601
1036 if (steeringTurnId === undefined) {1602 if (steeringTurnId === undefined) {
1037 yield* offerRuntimeEvent({1603 yield* offerRuntimeEvent({
...@@ -1082,10 +1648,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1082,10 +1648,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1082 })1648 })
1083 .pipe(1649 .pipe(
1084 Effect.tap((promptResult) =>1650 Effect.tap((promptResult) =>
1085 Effect.all([1651 Effect.all(
1086 Ref.set(promptRpcSucceeded, true),1652 [
1087 Ref.set(promptResultRef, promptResult),1653 Ref.set(promptRpcSucceeded, true),
1088 ]),1654 Ref.set(promptResultRef, promptResult),
1655 markPromptResponseReady(input.threadId, prepared.acpSessionId, prepared.turnId),
1656 ],
1657 { discard: true },
1658 ),
1089 ),1659 ),
1090 Effect.tapError((error) =>1660 Effect.tapError((error) =>
1091 Ref.set(1661 Ref.set(
...@@ -1126,6 +1696,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1126,6 +1696,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1126 yield* Effect.yieldNow;1696 yield* Effect.yieldNow;
1127 }1697 }
1128 yield* prepared.acp.drainEvents;1698 yield* prepared.acp.drainEvents;
1699 consumePromptResponseReady(ctx);
1129 if (ctx.interruptedTurnIds.has(prepared.turnId)) {1700 if (ctx.interruptedTurnIds.has(prepared.turnId)) {
1130 yield* Ref.set(promptSettled, true);1701 yield* Ref.set(promptSettled, true);
1131 return {1702 return {
...@@ -1184,6 +1755,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1184,6 +1755,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1184 updatedAt: completedAt,1755 updatedAt: completedAt,
1185 ...(prepared.displayModel ? { model: prepared.displayModel } : {}),1756 ...(prepared.displayModel ? { model: prepared.displayModel } : {}),
1186 };1757 };
1758 yield* clearTurnLiveness(ctx);
1187 const completedStopReason = completedStopReasonFromPromptResponse(result);1759 const completedStopReason = completedStopReasonFromPromptResponse(result);
1188 yield* offerRuntimeEvent({1760 yield* offerRuntimeEvent({
1189 type: "turn.completed",1761 type: "turn.completed",
...@@ -1240,6 +1812,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1240,6 +1812,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1240 if (ctx.interruptedTurnIds.has(prepared.turnId)) {1812 if (ctx.interruptedTurnIds.has(prepared.turnId)) {
1241 return;1813 return;
1242 }1814 }
1815 consumePromptResponseReady(ctx);
1243 if (1816 if (
1244 ctx.promptsInFlight <= 0 ||1817 ctx.promptsInFlight <= 0 ||
1245 ctx.activeTurnId !== prepared.turnId ||1818 ctx.activeTurnId !== prepared.turnId ||
...@@ -1356,6 +1929,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte...@@ -1356,6 +1929,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
1356 status: "ready",1929 status: "ready",
1357 updatedAt,1930 updatedAt,
1358 };1931 };
1932 yield* clearTurnLiveness(ctx);
1359 }1933 }
1360 }),1934 }),
1361 );1935 );
apps/server/src/provider/Layers/GrokProvider.test.ts+174-1
...@@ -6,10 +6,183 @@ import * as Path from "effect/Path";...@@ -6,10 +6,183 @@ import * as Path from "effect/Path";
6import * as Schema from "effect/Schema";6import * as Schema from "effect/Schema";
7import { GrokSettings } from "@t3tools/contracts";7import { GrokSettings } from "@t3tools/contracts";
88
9import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts";9import {
10 buildGrokModelCapabilities,
11 buildInitialGrokProviderSnapshot,
12 checkGrokProviderStatus,
13} from "./GrokProvider.ts";
1014
11const decodeGrokSettings = Schema.decodeSync(GrokSettings);15const 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
13describe("buildInitialGrokProviderSnapshot", () => {186describe("buildInitialGrokProviderSnapshot", () => {
14 it.effect("returns a disabled snapshot when settings.enabled is false", () =>187 it.effect("returns a disabled snapshot when settings.enabled is false", () =>
15 Effect.gen(function* () {188 Effect.gen(function* () {
apps/server/src/provider/Layers/GrokProvider.ts+111-2
...@@ -29,7 +29,12 @@ import {...@@ -29,7 +29,12 @@ import {
29 enrichProviderSnapshotWithVersionAdvisory,29 enrichProviderSnapshotWithVersionAdvisory,
30 type ProviderMaintenanceCapabilities,30 type ProviderMaintenanceCapabilities,
31} from "../providerMaintenance.ts";31} 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
34const GROK_PRESENTATION = {39const GROK_PRESENTATION = {
35 displayName: "Grok",40 displayName: "Grok",
...@@ -99,6 +104,104 @@ function grokModelsFromSettings(...@@ -99,6 +104,104 @@ function grokModelsFromSettings(
99 return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);104 return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES);
100}105}
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
102function buildGrokDiscoveredModelsFromSessionModelState(205function buildGrokDiscoveredModelsFromSessionModelState(
103 modelState: EffectAcpSchema.SessionModelState | null | undefined,206 modelState: EffectAcpSchema.SessionModelState | null | undefined,
104): ReadonlyArray<ServerProviderModel> {207): ReadonlyArray<ServerProviderModel> {
...@@ -117,7 +220,7 @@ function buildGrokDiscoveredModelsFromSessionModelState(...@@ -117,7 +220,7 @@ function buildGrokDiscoveredModelsFromSessionModelState(
117 slug,220 slug,
118 name: model.name.trim() || slug,221 name: model.name.trim() || slug,
119 isCustom: false,222 isCustom: false,
120 capabilities: EMPTY_CAPABILITIES,223 capabilities: buildGrokModelCapabilities(model),
121 };224 };
122 })225 })
123 .filter((model): model is ServerProviderModel => model !== undefined);226 .filter((model): model is ServerProviderModel => model !== undefined);
...@@ -161,6 +264,7 @@ const runGrokVersionCommand = (...@@ -161,6 +264,7 @@ const runGrokVersionCommand = (
161export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (264export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* (
162 grokSettings: GrokSettings,265 grokSettings: GrokSettings,
163 environment: NodeJS.ProcessEnv = process.env,266 environment: NodeJS.ProcessEnv = process.env,
267 cwd?: string,
164): Effect.fn.Return<268): Effect.fn.Return<
165 ServerProviderDraft,269 ServerProviderDraft,
166 never,270 never,
...@@ -251,6 +355,8 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func...@@ -251,6 +355,8 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
251 });355 });
252 }356 }
253357
358 const skills = yield* discoverGrokSkills(grokSettings, environment, cwd);
359
254 const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe(360 const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe(
255 Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS),361 Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS),
256 Effect.exit,362 Effect.exit,
...@@ -264,6 +370,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func...@@ -264,6 +370,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
264 enabled: grokSettings.enabled,370 enabled: grokSettings.enabled,
265 checkedAt,371 checkedAt,
266 models: fallbackModels,372 models: fallbackModels,
373 skills,
267 probe: {374 probe: {
268 installed: true,375 installed: true,
269 version,376 version,
...@@ -282,6 +389,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func...@@ -282,6 +389,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
282 enabled: grokSettings.enabled,389 enabled: grokSettings.enabled,
283 checkedAt,390 checkedAt,
284 models: fallbackModels,391 models: fallbackModels,
392 skills,
285 probe: {393 probe: {
286 installed: true,394 installed: true,
287 version,395 version,
...@@ -302,6 +410,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func...@@ -302,6 +410,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func
302 enabled: grokSettings.enabled,410 enabled: grokSettings.enabled,
303 checkedAt,411 checkedAt,
304 models,412 models,
413 skills,
305 probe: {414 probe: {
306 installed: true,415 installed: true,
307 version,416 version,
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+17-8
...@@ -330,7 +330,7 @@ describe("AcpSessionRuntime", () => {...@@ -330,7 +330,7 @@ describe("AcpSessionRuntime", () => {
330 ),330 ),
331 );331 );
332332
333 it.effect("suppresses generic placeholder tool updates until completion", () =>333 it.effect("emits status-only tool updates through completion", () =>
334 Effect.gen(function* () {334 Effect.gen(function* () {
335 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;335 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
336 yield* runtime.start();336 yield* runtime.start();
...@@ -340,13 +340,22 @@ describe("AcpSessionRuntime", () => {...@@ -340,13 +340,22 @@ describe("AcpSessionRuntime", () => {
340 });340 });
341 expect(promptResult).toMatchObject({ stopReason: "end_turn" });341 expect(promptResult).toMatchObject({ stopReason: "end_turn" });
342342
343 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 1)));343 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 3)));
344 expect(notes.map((note) => note._tag)).toEqual(["ToolCallUpdated"]);344 expect(notes.map((note) => note._tag)).toEqual([
345 const toolCall = notes[0];345 "ToolCallUpdated",
346 expect(toolCall?._tag).toBe("ToolCallUpdated");346 "ToolCallUpdated",
347 if (toolCall?._tag === "ToolCallUpdated") {347 "ToolCallUpdated",
348 expect(toolCall.toolCall.status).toBe("completed");348 ]);
349 expect(toolCall.toolCall.title).toBe("Read file");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");
350 }359 }
351 }).pipe(360 }).pipe(
352 Effect.provide(361 Effect.provide(
apps/server/src/provider/acp/AcpRuntimeModel.test.ts+188-2
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 parseSessionUpdateEvent,11 parseSessionUpdateEvent,
12 sessionUpdateIsReplay,12 sessionUpdateIsReplay,
13 syntheticLoadSessionResponseFromInitialize,13 syntheticLoadSessionResponseFromInitialize,
14 toolCallProgressLength,
14 type AcpToolCallState,15 type AcpToolCallState,
15} from "./AcpRuntimeModel.ts";16} from "./AcpRuntimeModel.ts";
1617
...@@ -542,6 +543,130 @@ describe("AcpRuntimeModel", () => {...@@ -542,6 +543,130 @@ describe("AcpRuntimeModel", () => {
542 });543 });
543 });544 });
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
545 describe("decideToolCallUpdateEmission", () => {670 describe("decideToolCallUpdateEmission", () => {
546 const toolCall = (detail: string | undefined, status?: AcpToolCallState["status"]) =>671 const toolCall = (detail: string | undefined, status?: AcpToolCallState["status"]) =>
547 ({672 ({
...@@ -552,6 +677,17 @@ describe("AcpRuntimeModel", () => {...@@ -552,6 +677,17 @@ describe("AcpRuntimeModel", () => {
552 data: {},677 data: {},
553 }) satisfies AcpToolCallState;678 }) 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
555 it("always emits terminal (completed/failed) status updates regardless of growth", () => {691 it("always emits terminal (completed/failed) status updates regardless of growth", () => {
556 expect(692 expect(
557 decideToolCallUpdateEmission({693 decideToolCallUpdateEmission({
...@@ -573,16 +709,66 @@ describe("AcpRuntimeModel", () => {...@@ -573,16 +709,66 @@ describe("AcpRuntimeModel", () => {
573 });709 });
574710
575 it("skips updates whose bounded detail did not change", () => {711 it("skips updates whose bounded detail did not change", () => {
712 const previous = toolCall("frame 1", "inProgress");
576 expect(713 expect(
577 decideToolCallUpdateEmission({714 decideToolCallUpdateEmission({
578 previous: toolCall("frame 1", "inProgress"),715 previous,
579 next: toolCall("frame 1", "inProgress"),716 next: previous,
580 lastEmittedDetailLength: 7,717 lastEmittedDetailLength: 7,
581 skippedSinceEmit: 0,718 skippedSinceEmit: 0,
582 }),719 }),
583 ).toEqual({ emit: false, skippedSinceEmit: 0 });720 ).toEqual({ emit: false, skippedSinceEmit: 0 });
584 });721 });
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
586 it("emits immediately when the title changes, even with no growth", () => {772 it("emits immediately when the title changes, even with no growth", () => {
587 const decision = decideToolCallUpdateEmission({773 const decision = decideToolCallUpdateEmission({
588 previous: { toolCallId: "tool-1", title: "Reading file", detail: "x", data: {} },774 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...@@ -316,6 +316,32 @@ function toolCallContentText(entry: EffectAcpSchema.ToolCallContent): string | u
316 return entry.content.text;316 return entry.content.text;
317}317}
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
319function extractTextContentFromToolCallContent(345function extractTextContentFromToolCallContent(
320 content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined,346 content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined,
321): ExtractedToolCallContent {347): ExtractedToolCallContent {
...@@ -330,31 +356,74 @@ function extractTextContentFromToolCallContent(...@@ -330,31 +356,74 @@ function extractTextContentFromToolCallContent(
330 }356 }
331 }357 }
332 if (chunks.length === 0) {358 if (chunks.length === 0) {
333 return { text: undefined, content };359 return { text: undefined, content: boundToolCallContentEntries(content) };
334 }360 }
335 const joined = chunks.join("\n");361 const joined = chunks.join("\n");
336 if (joined.length <= TOOL_CALL_CONTENT_MAX_CHARS) {362 if (joined.length <= TOOL_CALL_CONTENT_MAX_CHARS) {
337 return { text: joined, content };363 return { text: joined, content: boundToolCallContentEntries(content) };
338 }364 }
339 const bounded = boundToolCallOutputText(joined);365 const bounded = boundToolCallOutputText(joined);
340 // Collapse the text entries into a single bounded one at the final contributing text entry,366 const tail = joined.slice(joined.length - TOOL_CALL_CONTENT_MAX_CHARS);
341 // and leave every other entry kind (diffs, images, resource links) in its original relative367 return {
342 // order. The retained tail came from that text entry, so placing it there also preserves its368 text: bounded,
343 // ordering relative to interleaved non-text content and ignores later blank text entries.369 content: distributeRetainedTailAcrossContent(content, tail),
344 const lastContributingTextIndex = content.reduce(370 };
345 (lastIndex, entry, index) => (toolCallContentText(entry)?.trim() ? index : lastIndex),371}
346 -1,372
347 );373// Walk the original text entries from the joined tail window so a retained slice that
348 const boundedContent = content.flatMap((entry, index) => {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) => {
349 if (toolCallContentText(entry) === undefined) {408 if (toolCallContentText(entry) === undefined) {
350 return [entry];409 return [entry];
351 }410 }
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) {
353 return [];418 return [];
354 }419 }
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];
356 });426 });
357 return { text: bounded, content: boundedContent };
358}427}
359428
360function normalizeToolKind(kind: unknown): string | undefined {429function normalizeToolKind(kind: unknown): string | undefined {
...@@ -518,6 +587,42 @@ export interface AcpToolCallEmitDecision {...@@ -518,6 +587,42 @@ export interface AcpToolCallEmitDecision {
518 readonly skippedSinceEmit: number;587 readonly skippedSinceEmit: number;
519}588}
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
521export function decideToolCallUpdateEmission(626export function decideToolCallUpdateEmission(
522 input: AcpToolCallEmitDecisionInput,627 input: AcpToolCallEmitDecisionInput,
523): AcpToolCallEmitDecision {628): AcpToolCallEmitDecision {
...@@ -525,19 +630,16 @@ export function decideToolCallUpdateEmission(...@@ -525,19 +630,16 @@ export function decideToolCallUpdateEmission(
525 if (next.status === "completed" || next.status === "failed") {630 if (next.status === "completed" || next.status === "failed") {
526 return { emit: true, skippedSinceEmit: 0 };631 return { emit: true, skippedSinceEmit: 0 };
527 }632 }
528 if (!next.detail) {633 if (previous === undefined || previous.title !== next.title || previous.status !== next.status) {
529 return { emit: false, skippedSinceEmit };
530 }
531 if (previous === undefined || previous.title !== next.title) {
532 return { emit: true, skippedSinceEmit: 0 };634 return { emit: true, skippedSinceEmit: 0 };
533 }635 }
534 if (previous.detail === next.detail) {636 if (previous.detail === next.detail && toolCallOutputUnchanged(previous, next)) {
535 return { emit: false, skippedSinceEmit };637 return { emit: false, skippedSinceEmit };
536 }638 }
639 const progressLength = toolCallProgressLength(next);
537 const grewMeaningfully =640 const grewMeaningfully =
538 lastEmittedDetailLength === undefined ||641 lastEmittedDetailLength === undefined ||
539 Math.abs(next.detail.length - lastEmittedDetailLength) >=642 Math.abs(progressLength - lastEmittedDetailLength) >= TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS;
540 TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS;
541 if (grewMeaningfully || skippedSinceEmit + 1 >= TOOL_CALL_UPDATE_COALESCE_LIMIT) {643 if (grewMeaningfully || skippedSinceEmit + 1 >= TOOL_CALL_UPDATE_COALESCE_LIMIT) {
542 return { emit: true, skippedSinceEmit: 0 };644 return { emit: true, skippedSinceEmit: 0 };
543 }645 }
apps/server/src/provider/acp/AcpSessionRuntime.ts+5-2
...@@ -27,6 +27,7 @@ import {...@@ -27,6 +27,7 @@ import {
27 extractModelConfigId,27 extractModelConfigId,
28 findSessionConfigOption,28 findSessionConfigOption,
29 mergeToolCallState,29 mergeToolCallState,
30 toolCallProgressLength,
30 parseSessionModeState,31 parseSessionModeState,
31 parseSessionUpdateEvent,32 parseSessionUpdateEvent,
32 sessionUpdateIsReplay,33 sessionUpdateIsReplay,
...@@ -233,6 +234,7 @@ export class AcpSessionRuntime extends Context.Service<...@@ -233,6 +234,7 @@ export class AcpSessionRuntime extends Context.Service<
233 */234 */
234 readonly setSessionModel: (235 readonly setSessionModel: (
235 modelId: string,236 modelId: string,
237 meta?: EffectAcpSchema.SetSessionModelRequest["_meta"],
236 ) => Effect.Effect<EffectAcpSchema.SetSessionModelResponse, EffectAcpErrors.AcpError>;238 ) => Effect.Effect<EffectAcpSchema.SetSessionModelResponse, EffectAcpErrors.AcpError>;
237 /**239 /**
238 * Sends a generic ACP extension request and records it through the request logger.240 * Sends a generic ACP extension request and records it through the request logger.
...@@ -796,12 +798,13 @@ export const make = (...@@ -796,12 +798,13 @@ export const make = (
796 Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)),798 Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)),
797 Effect.asVoid,799 Effect.asVoid,
798 ),800 ),
799 setSessionModel: (modelId) =>801 setSessionModel: (modelId, meta) =>
800 getStartedState.pipe(802 getStartedState.pipe(
801 Effect.flatMap((started) => {803 Effect.flatMap((started) => {
802 const requestPayload = {804 const requestPayload = {
803 sessionId: started.sessionId,805 sessionId: started.sessionId,
804 modelId,806 modelId,
807 ...(meta !== undefined ? { _meta: meta } : {}),
805 } satisfies EffectAcpSchema.SetSessionModelRequest;808 } satisfies EffectAcpSchema.SetSessionModelRequest;
806 return runLoggedRequest(809 return runLoggedRequest(
807 "session/set_model",810 "session/set_model",
...@@ -893,7 +896,7 @@ const handleSessionUpdate = ({...@@ -893,7 +896,7 @@ const handleSessionUpdate = ({
893 next.set(nextToolCall.toolCallId, {896 next.set(nextToolCall.toolCallId, {
894 state: nextToolCall,897 state: nextToolCall,
895 lastEmittedDetailLength: decision.emit898 lastEmittedDetailLength: decision.emit
896 ? nextToolCall.detail?.length899 ? toolCallProgressLength(nextToolCall)
897 : tracked?.lastEmittedDetailLength,900 : tracked?.lastEmittedDetailLength,
898 skippedSinceEmit: decision.skippedSinceEmit,901 skippedSinceEmit: decision.skippedSinceEmit,
899 });902 });
apps/server/src/provider/acp/GrokAcpCliProbe.test.ts+62-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1/**1/**
2 * Optional integration check against a real `grok agent stdio` install.2 * Optional integration check against a real `grok agent stdio` install.
3 * Enable with: T3_GROK_ACP_PROBE=1 bun run test GrokAcpCliProbe3 * 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.
4 *5 *
5 * The probe assumes either `XAI_API_KEY` is set in the environment or6 * The probe assumes either `XAI_API_KEY` is set in the environment or
6 * the user has previously run `grok login`. Without credentials the7 * the user has previously run `grok login`. Without credentials the
...@@ -10,6 +11,10 @@...@@ -10,6 +11,10 @@
10import * as NodeServices from "@effect/platform-node/NodeServices";11import * as NodeServices from "@effect/platform-node/NodeServices";
11import { it } from "@effect/vitest";12import { it } from "@effect/vitest";
12import * as Effect from "effect/Effect";13import * 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";
13import { ChildProcessSpawner } from "effect/unstable/process";18import { ChildProcessSpawner } from "effect/unstable/process";
14import { describe, expect } from "vite-plus/test";19import { describe, expect } from "vite-plus/test";
1520
...@@ -66,4 +71,60 @@ describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () =...@@ -66,4 +71,60 @@ describe.runIf(process.env.T3_GROK_ACP_PROBE === "1")("Grok ACP CLI probe", () =
66 yield* runtime.setSessionModel(currentModelId);71 yield* runtime.setSessionModel(currentModelId);
67 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),72 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
68 );73 );
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 );
69});130});
apps/server/src/provider/acp/GrokAcpSupport.test.ts+105-4
...@@ -5,6 +5,8 @@ import * as EffectAcpErrors from "effect-acp/errors";...@@ -5,6 +5,8 @@ import * as EffectAcpErrors from "effect-acp/errors";
5import {5import {
6 applyGrokAcpModelSelection,6 applyGrokAcpModelSelection,
7 buildGrokAcpSpawnInput,7 buildGrokAcpSpawnInput,
8 grokAcpSpawnArgs,
9 isValidGrokReasoningEffortToken,
8 resolveGrokAcpBaseModelId,10 resolveGrokAcpBaseModelId,
9} from "./GrokAcpSupport.ts";11} from "./GrokAcpSupport.ts";
1012
...@@ -16,6 +18,35 @@ describe("resolveGrokAcpBaseModelId", () => {...@@ -16,6 +18,35 @@ describe("resolveGrokAcpBaseModelId", () => {
16 });18 });
17});19});
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
19describe("buildGrokAcpSpawnInput", () => {50describe("buildGrokAcpSpawnInput", () => {
20 it("passes the T3 Code referrer through Grok OAuth env", () => {51 it("passes the T3 Code referrer through Grok OAuth env", () => {
21 const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", {52 const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", {
...@@ -33,15 +64,38 @@ describe("buildGrokAcpSpawnInput", () => {...@@ -33,15 +64,38 @@ describe("buildGrokAcpSpawnInput", () => {
33 },64 },
34 });65 });
35 });66 });
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 });
36});87});
3788
38describe("applyGrokAcpModelSelection", () => {89describe("applyGrokAcpModelSelection", () => {
39 const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => {90 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 }> = [];
41 const runtime = {95 const runtime = {
42 setSessionModel: (modelId: string) =>96 setSessionModel: (modelId: string, meta?: { readonly [key: string]: unknown } | null) =>
43 Effect.gen(function* () {97 Effect.gen(function* () {
44 modelCalls.push(modelId);98 modelCalls.push(meta === undefined ? { modelId } : { modelId, meta });
45 if (failure) return yield* failure;99 if (failure) return yield* failure;
46 return {};100 return {};
47 }),101 }),
...@@ -58,11 +112,58 @@ describe("applyGrokAcpModelSelection", () => {...@@ -58,11 +112,58 @@ describe("applyGrokAcpModelSelection", () => {
58 requestedModelId: "grok-mock-alt",112 requestedModelId: "grok-mock-alt",
59 mapError: (cause) => cause.message,113 mapError: (cause) => cause.message,
60 });114 });
61 expect(modelCalls).toEqual(["grok-mock-alt"]);115 expect(modelCalls).toEqual([{ modelId: "grok-mock-alt" }]);
62 expect(result).toBe("grok-mock-alt");116 expect(result).toBe("grok-mock-alt");
63 }),117 }),
64 );118 );
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
66 it.effect("skips set_model when requested matches current", () =>167 it.effect("skips set_model when requested matches current", () =>
67 Effect.gen(function* () {168 Effect.gen(function* () {
68 const { runtime, modelCalls } = makeRecordingRuntime();169 const { runtime, modelCalls } = makeRecordingRuntime();
apps/server/src/provider/acp/GrokAcpSupport.ts+79-7
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts";1import { type GrokSettings, ProviderDriverKind, type RuntimeMode } from "@t3tools/contracts";
2import * as Crypto from "effect/Crypto";2import * as Crypto from "effect/Crypto";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
...@@ -27,16 +27,33 @@ interface GrokAcpRuntimeInput extends Omit<...@@ -27,16 +27,33 @@ interface GrokAcpRuntimeInput extends Omit<
27 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];27 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
28 readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined;28 readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined;
29 readonly environment?: NodeJS.ProcessEnv;29 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 }
30}46}
3147
32export function buildGrokAcpSpawnInput(48export function buildGrokAcpSpawnInput(
33 grokSettings: GrokAcpRuntimeGrokSettings | null | undefined,49 grokSettings: GrokAcpRuntimeGrokSettings | null | undefined,
34 cwd: string,50 cwd: string,
35 environment?: NodeJS.ProcessEnv,51 environment?: NodeJS.ProcessEnv,
52 runtimeMode?: RuntimeMode,
36): AcpSessionRuntime.AcpSpawnInput {53): AcpSessionRuntime.AcpSpawnInput {
37 return {54 return {
38 command: grokSettings?.binaryPath || "grok",55 command: grokSettings?.binaryPath || "grok",
39 args: ["agent", "stdio"],56 args: [...grokAcpSpawnArgs(runtimeMode)],
40 cwd,57 cwd,
41 env: {58 env: {
42 ...environment,59 ...environment,
...@@ -62,7 +79,12 @@ export const makeGrokAcpRuntime = (...@@ -62,7 +79,12 @@ export const makeGrokAcpRuntime = (
62 const acpContext = yield* Layer.build(79 const acpContext = yield* Layer.build(
63 AcpSessionRuntime.layer({80 AcpSessionRuntime.layer({
64 ...input,81 ...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 ),
66 authMethodId: resolveGrokAuthMethodId(input.environment),88 authMethodId: resolveGrokAuthMethodId(input.environment),
67 }).pipe(89 }).pipe(
68 Layer.provide(90 Layer.provide(
...@@ -82,6 +104,17 @@ export function resolveGrokAcpBaseModelId(model: string | null | undefined): str...@@ -82,6 +104,17 @@ export function resolveGrokAcpBaseModelId(model: string | null | undefined): str
82 return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build";104 return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build";
83}105}
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
85export function currentGrokModelIdFromSessionSetup(118export function currentGrokModelIdFromSessionSetup(
86 sessionSetupResult:119 sessionSetupResult:
87 | EffectAcpSchema.LoadSessionResponse120 | EffectAcpSchema.LoadSessionResponse
...@@ -91,18 +124,57 @@ export function currentGrokModelIdFromSessionSetup(...@@ -91,18 +124,57 @@ export function currentGrokModelIdFromSessionSetup(
91 return sessionSetupResult.models?.currentModelId?.trim() || undefined;124 return sessionSetupResult.models?.currentModelId?.trim() || undefined;
92}125}
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
94export function applyGrokAcpModelSelection<E>(input: {150export function applyGrokAcpModelSelection<E>(input: {
95 readonly runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "setSessionModel">;151 readonly runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "setSessionModel">;
96 readonly currentModelId: string | undefined;152 readonly currentModelId: string | undefined;
153 readonly currentReasoningEffort?: string | undefined;
97 readonly requestedModelId: string | undefined;154 readonly requestedModelId: string | undefined;
155 readonly requestedReasoningEffort?: string | undefined;
98 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;156 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
99}): Effect.Effect<string | undefined, E> {157}): Effect.Effect<string | undefined, E> {
100 const shouldSwitchModel =158 const modelChanged =
101 input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId;159 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) {
103 return Effect.succeed(input.currentModelId);168 return Effect.succeed(input.currentModelId);
104 }169 }
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.
105 return input.runtime177 return input.runtime
106 .setSessionModel(input.requestedModelId)178 .setSessionModel(targetModelId, reasoningMeta)
107 .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId));179 .pipe(Effect.mapError(input.mapError), Effect.as(targetModelId));
108}180}
apps/server/src/provider/acp/XAiAcpExtension.test.ts+194
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1// @effect-diagnostics nodeBuiltinImport:off1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeOS from "node:os";
2import * as NodePath from "node:path";3import * as NodePath from "node:path";
3import * as NodeURL from "node:url";4import * as NodeURL from "node:url";
45
...@@ -9,11 +10,17 @@ import * as Schema from "effect/Schema";...@@ -9,11 +10,17 @@ import * as Schema from "effect/Schema";
9import { describe, expect } from "vite-plus/test";10import { describe, expect } from "vite-plus/test";
1011
11import {12import {
13 extractGrokPlanMarkdownFromToolCallData,
12 extractXAiAskUserQuestions,14 extractXAiAskUserQuestions,
15 extractXAiExitPlanMarkdown,
16 isGrokPlanMarkdownPath,
13 makeXAiAskUserQuestionCancelledResponse,17 makeXAiAskUserQuestionCancelledResponse,
14 makeXAiAskUserQuestionResponse,18 makeXAiAskUserQuestionResponse,
19 makeXAiExitPlanModeCapturedResponse,
15 makeXAiPromptCompletionRuntime,20 makeXAiPromptCompletionRuntime,
21 XAI_EMPTY_PLAN_MARKDOWN,
16 XAiAskUserQuestionRequest,22 XAiAskUserQuestionRequest,
23 XAiExitPlanModeRequest,
17} from "./XAiAcpExtension.ts";24} from "./XAiAcpExtension.ts";
18import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";25import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
1926
...@@ -299,6 +306,27 @@ describe("XAiAcpExtension", () => {...@@ -299,6 +306,27 @@ describe("XAiAcpExtension", () => {
299 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),306 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
300 );307 );
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
302 it.effect("ignores stale xAI completion from an already settled prompt", () =>330 it.effect("ignores stale xAI completion from an already settled prompt", () =>
303 Effect.gen(function* () {331 Effect.gen(function* () {
304 const runtime = yield* makePromptCompletionRuntime({332 const runtime = yield* makePromptCompletionRuntime({
...@@ -329,4 +357,170 @@ describe("XAiAcpExtension", () => {...@@ -329,4 +357,170 @@ describe("XAiAcpExtension", () => {
329 });357 });
330 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),358 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
331 );359 );
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 });
332});526});
apps/server/src/provider/acp/XAiAcpExtension.ts+258-4
...@@ -1,8 +1,11 @@...@@ -1,8 +1,11 @@
1import * as NodeOS from "node:os";
2
1import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts";3import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts";
2import * as Deferred from "effect/Deferred";4import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";5import * as Effect from "effect/Effect";
4import * as Ref from "effect/Ref";6import * as Ref from "effect/Ref";
5import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
8import * as EffectAcpErrors from "effect-acp/errors";
6import type * as EffectAcpSchema from "effect-acp/schema";9import type * as EffectAcpSchema from "effect-acp/schema";
710
8import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";11import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
...@@ -19,11 +22,15 @@ type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type;...@@ -19,11 +22,15 @@ type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type;
19interface PendingXAiPromptCompletion {22interface PendingXAiPromptCompletion {
20 readonly sessionId: string;23 readonly sessionId: string;
21 readonly promptId: string;24 readonly promptId: string;
22 readonly deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse>;25 readonly deferred: Deferred.Deferred<
26 EffectAcpSchema.PromptResponse,
27 EffectAcpErrors.AcpRequestError
28 >;
23}29}
2430
25const completedXAiPromptIdLimit = 128;31const completedXAiPromptIdLimit = 128;
26const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing";32const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing";
33const xAiRateLimitedErrorCode = -32003;
2734
28const XAiAskUserQuestionOption = Schema.Struct({35const XAiAskUserQuestionOption = Schema.Struct({
29 label: Schema.String,36 label: Schema.String,
...@@ -196,6 +203,218 @@ export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCan...@@ -196,6 +203,218 @@ export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCan
196 return { outcome: "cancelled" };203 return { outcome: "cancelled" };
197}204}
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
199/**418/**
200 * Adds Grok's private prompt-completion fallback around a standards-only ACP runtime.419 * Adds Grok's private prompt-completion fallback around a standards-only ACP runtime.
201 * The underlying runtime remains unaware of xAI methods and metadata.420 * The underlying runtime remains unaware of xAI methods and metadata.
...@@ -278,7 +497,7 @@ const registerXAiPromptCompletionFallback = (...@@ -278,7 +497,7 @@ const registerXAiPromptCompletionFallback = (
278 sessionId: string,497 sessionId: string,
279 promptId: string,498 promptId: string,
280) =>499) =>
281 Deferred.make<EffectAcpSchema.PromptResponse>().pipe(500 Deferred.make<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>().pipe(
282 Effect.tap((deferred) =>501 Effect.tap((deferred) =>
283 Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]),502 Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]),
284 ),503 ),
...@@ -287,7 +506,7 @@ const registerXAiPromptCompletionFallback = (...@@ -287,7 +506,7 @@ const registerXAiPromptCompletionFallback = (
287506
288const unregisterXAiPromptCompletionFallback = (507const unregisterXAiPromptCompletionFallback = (
289 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,508 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,
290 deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse>,509 deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>,
291) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred));510) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred));
292511
293const abortPendingPromptCompletions = (512const abortPendingPromptCompletions = (
...@@ -358,13 +577,48 @@ const resolveXAiPromptCompletionFallback = ({...@@ -358,13 +577,48 @@ const resolveXAiPromptCompletionFallback = ({
358 return [Effect.void, pending] as const;577 return [Effect.void, pending] as const;
359 }578 }
360 return [579 return [
361 Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid),580 settleXAiPromptCompletion(entry.deferred, notification),
362 [...pending.slice(0, index), ...pending.slice(index + 1)],581 [...pending.slice(0, index), ...pending.slice(index + 1)],
363 ] as const;582 ] as const;
364 }).pipe(Effect.flatten);583 }).pipe(Effect.flatten);
365 }),584 }),
366 );585 );
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
368const rememberCompletedXAiPromptId = (622const rememberCompletedXAiPromptId = (
369 completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>,623 completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>,
370 response: EffectAcpSchema.PromptResponse,624 response: EffectAcpSchema.PromptResponse,
apps/server/src/textGeneration/GrokTextGeneration.ts+10
...@@ -8,6 +8,7 @@ import type * as EffectAcpErrors from "effect-acp/errors";...@@ -8,6 +8,7 @@ import type * as EffectAcpErrors from "effect-acp/errors";
88
9import { type GrokSettings, type ModelSelection } from "@t3tools/contracts";9import { type GrokSettings, type ModelSelection } from "@t3tools/contracts";
10import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";10import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
11import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
11import { extractJsonObject } from "@t3tools/shared/schemaJson";12import { extractJsonObject } from "@t3tools/shared/schemaJson";
1213
13import { TextGenerationError } from "@t3tools/contracts";14import { TextGenerationError } from "@t3tools/contracts";
...@@ -26,6 +27,7 @@ import {...@@ -26,6 +27,7 @@ import {
26import {27import {
27 applyGrokAcpModelSelection,28 applyGrokAcpModelSelection,
28 currentGrokModelIdFromSessionSetup,29 currentGrokModelIdFromSessionSetup,
30 currentGrokReasoningEffortFromSessionSetup,
29 makeGrokAcpRuntime,31 makeGrokAcpRuntime,
30 resolveGrokAcpBaseModelId,32 resolveGrokAcpBaseModelId,
31} from "../provider/acp/GrokAcpSupport.ts";33} from "../provider/acp/GrokAcpSupport.ts";
...@@ -83,10 +85,18 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi...@@ -83,10 +85,18 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi
8385
84 const promptResult = yield* Effect.gen(function* () {86 const promptResult = yield* Effect.gen(function* () {
85 const started = yield* runtime.start();87 const started = yield* runtime.start();
88 const requestedReasoningEffort = getModelSelectionStringOptionValue(
89 modelSelection,
90 "reasoningEffort",
91 );
86 yield* applyGrokAcpModelSelection({92 yield* applyGrokAcpModelSelection({
87 runtime,93 runtime,
88 currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult),94 currentModelId: currentGrokModelIdFromSessionSetup(started.sessionSetupResult),
95 currentReasoningEffort: currentGrokReasoningEffortFromSessionSetup(
96 started.sessionSetupResult,
97 ),
89 requestedModelId: resolvedModel,98 requestedModelId: resolvedModel,
99 requestedReasoningEffort,
90 mapError: (cause) =>100 mapError: (cause) =>
91 new TextGenerationError({101 new TextGenerationError({
92 operation,102 operation,
apps/server/src/usage/UsageService.ts+22-5
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1/**1/**
2 * UsageService - scans provider transcripts and returns priced usage buckets.2 * UsageService - scans provider transcripts and returns priced usage buckets.
3 *3 *
4 * The scan reads the provider CLIs' own session files rather than T3 Code's4 * The scan reads the provider CLIs' own session files (Claude Code, Codex, and
5 * orchestration projections, so usage covers turns driven outside T3 Code too.5 * Grok Build) rather than T3 Code's orchestration projections, so usage covers
6 * This is the approach `ccusage` takes.6 * turns driven outside T3 Code too. This is the approach `ccusage` takes.
7 *7 *
8 * Transcripts are append-only, so parsed records are memoised per file by8 * Transcripts are append-only, so parsed records are memoised per file by
9 * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm9 * `(size, mtime)`. A cold 30-day scan of ~1.4 GB lands around 2-3 seconds; warm
...@@ -21,6 +21,7 @@ import {...@@ -21,6 +21,7 @@ import {
21 type UsageSummaryInput,21 type UsageSummaryInput,
22 UsageReadError,22 UsageReadError,
23} from "@t3tools/contracts";23} from "@t3tools/contracts";
24import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
24import * as Cause from "effect/Cause";25import * as Cause from "effect/Cause";
25import * as Clock from "effect/Clock";26import * as Clock from "effect/Clock";
26import * as Context from "effect/Context";27import * as Context from "effect/Context";
...@@ -34,6 +35,7 @@ import * as Schema from "effect/Schema";...@@ -34,6 +35,7 @@ import * as Schema from "effect/Schema";
34import { HttpClient, HttpClientResponse } from "effect/unstable/http";35import { HttpClient, HttpClientResponse } from "effect/unstable/http";
3536
36import { ServerConfig } from "../config.ts";37import { ServerConfig } from "../config.ts";
38import { expandHomePath } from "../pathExpansion.ts";
37import * as ServerSettings from "../serverSettings.ts";39import * as ServerSettings from "../serverSettings.ts";
38import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts";40import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts";
39import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";41import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
...@@ -123,6 +125,7 @@ export const make = Effect.gen(function* () {...@@ -123,6 +125,7 @@ export const make = Effect.gen(function* () {
123 const config = yield* ServerConfig;125 const config = yield* ServerConfig;
124 const settingsService = yield* ServerSettings.ServerSettingsService;126 const settingsService = yield* ServerSettings.ServerSettingsService;
125 const httpClient = yield* HttpClient.HttpClient;127 const httpClient = yield* HttpClient.HttpClient;
128 const hostEnvironment = yield* HostProcessEnvironment;
126129
127 const fileCache: ScanCache = new Map();130 const fileCache: ScanCache = new Map();
128 let cacheDirty = false;131 let cacheDirty = false;
...@@ -218,10 +221,22 @@ export const make = Effect.gen(function* () {...@@ -218,10 +221,22 @@ export const make = Effect.gen(function* () {
218 const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent);221 const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent);
219 const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome);222 const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome);
220 const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex);223 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
222 return [232 return [
223 { provider: "claude" as const, dir: claudeDir },233 { provider: "claude" as const, dir: claudeDir },
224 { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") },234 { 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 },
225 ];240 ];
226 });241 });
227242
...@@ -353,7 +368,7 @@ export const make = Effect.gen(function* () {...@@ -353,7 +368,7 @@ export const make = Effect.gen(function* () {
353 const livePaths = new Set<string>();368 const livePaths = new Set<string>();
354 const walkedRoots: string[] = [];369 const walkedRoots: string[] = [];
355370
356 for (const { provider, dir } of dirs) {371 for (const { provider, dir, fileName } of dirs) {
357 const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir));372 const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir));
358 const exists = yield* fileSystem373 const exists = yield* fileSystem
359 .exists(dir)374 .exists(dir)
...@@ -373,7 +388,9 @@ export const make = Effect.gen(function* () {...@@ -373,7 +388,9 @@ export const make = Effect.gen(function* () {
373 }388 }
374389
375 walkedRoots.push(dir);390 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 );
377 let scannedFiles = 0;394 let scannedFiles = 0;
378 let skippedFiles = 0;395 let skippedFiles = 0;
379 // Distinct per directory. Buckets carry per-cell session counts, but a396 // 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", () => {...@@ -42,12 +42,21 @@ describe("scan cache round trip", () => {
42 ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]],42 ["/a.jsonl", 100, [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5" })]],
43 ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]],43 ["/b.jsonl", 200, [record({ sessionId: "session-b", reportedCostUsd: 1.5 })]],
44 ]);44 ]);
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
46 const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original))));54 const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original))));
4755
48 expect(restored.size).toBe(2);56 expect(restored.size).toBe(3);
49 expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl"));57 expect(restored.get("/a.jsonl")).toEqual(original.get("/a.jsonl"));
50 expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl"));58 expect(restored.get("/b.jsonl")).toEqual(original.get("/b.jsonl"));
59 expect(restored.get("/grok.jsonl")).toEqual(original.get("/grok.jsonl"));
51 });60 });
5261
53 it("interns repeated model and session strings", () => {62 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 {...@@ -134,7 +134,7 @@ export function decodeScanCache(document: unknown): ScanCache {
134 if (typeof raw !== "object" || raw === null) continue;134 if (typeof raw !== "object" || raw === null) continue;
135 const entry = raw as Partial<SerializedFile>;135 const entry = raw as Partial<SerializedFile>;
136 if (typeof entry.s !== "number" || typeof entry.m !== "number") continue;136 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;
138 if (!isRecordArray(entry.r)) continue;138 if (!isRecordArray(entry.r)) continue;
139139
140 const provider: UsageProviderKind = entry.p;140 const provider: UsageProviderKind = entry.p;
apps/server/src/usage/usageTranscriptReader.ts+18-1
...@@ -22,6 +22,7 @@ import {...@@ -22,6 +22,7 @@ import {
22 mightCarryUsage,22 mightCarryUsage,
23 parseClaudeLine,23 parseClaudeLine,
24 parseCodexLine,24 parseCodexLine,
25 parseGrokLine,
25 type UsageRecord,26 type UsageRecord,
26} from "./usageTranscripts.ts";27} from "./usageTranscripts.ts";
2728
...@@ -37,12 +38,18 @@ export interface TranscriptFile {...@@ -37,12 +38,18 @@ export interface TranscriptFile {
37 * Errors on individual entries are swallowed: session files rotate and get38 * Errors on individual entries are swallowed: session files rotate and get
38 * removed while the walk is in flight, and a partial listing is far better than39 * removed while the walk is in flight, and a partial listing is far better than
39 * failing the page.40 * 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.
40 */45 */
41export async function listTranscriptFiles(46export async function listTranscriptFiles(
42 root: string,47 root: string,
43 sinceMs: number,48 sinceMs: number,
49 options?: { readonly fileName?: string },
44): Promise<readonly TranscriptFile[]> {50): Promise<readonly TranscriptFile[]> {
45 const found: TranscriptFile[] = [];51 const found: TranscriptFile[] = [];
52 const fileName = options?.fileName;
4653
47 const walk = async (dir: string): Promise<void> => {54 const walk = async (dir: string): Promise<void> => {
48 let entries;55 let entries;
...@@ -57,7 +64,11 @@ export async function listTranscriptFiles(...@@ -57,7 +64,11 @@ export async function listTranscriptFiles(
57 await walk(child);64 await walk(child);
58 continue;65 continue;
59 }66 }
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 }
61 try {72 try {
62 const stats = await NodeFSP.stat(child);73 const stats = await NodeFSP.stat(child);
63 if (stats.mtimeMs >= sinceMs) {74 if (stats.mtimeMs >= sinceMs) {
...@@ -129,6 +140,12 @@ export async function readTranscriptRecords(...@@ -129,6 +140,12 @@ export async function readTranscriptRecords(
129 continue;140 continue;
130 }141 }
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
132 if (!mightCarryUsage(line, provider)) continue;149 if (!mightCarryUsage(line, provider)) continue;
133 const record = parseClaudeLine(line);150 const record = parseClaudeLine(line);
134 if (record !== null) records.push(record);151 if (record !== null) records.push(record);
apps/server/src/usage/usageTranscripts.test.ts+315
...@@ -1,9 +1,11 @@...@@ -1,9 +1,11 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
22
3import {3import {
4 GROK_COST_USD_TICKS_PER_DOLLAR,
4 initialCodexScanState,5 initialCodexScanState,
5 parseClaudeLine,6 parseClaudeLine,
6 parseCodexLine,7 parseCodexLine,
8 parseGrokLine,
7 totalTokens,9 totalTokens,
8} from "./usageTranscripts.ts";10} from "./usageTranscripts.ts";
911
...@@ -249,3 +251,316 @@ describe("totalTokens", () => {...@@ -249,3 +251,316 @@ describe("totalTokens", () => {
249 ).toBe(100);251 ).toBe(100);
250 });252 });
251});253});
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 @@...@@ -1,8 +1,8 @@
1/**1/**
2 * Pure parsers for the provider CLIs' on-disk session transcripts.2 * Pure parsers for the provider CLIs' on-disk session transcripts.
3 *3 *
4 * Both parsers are line-at-a-time reducers so callers can stream large files4 * Each parser is a line-at-a-time reducer so callers can stream large files
5 * without materialising them. Neither touches the filesystem.5 * without materialising them. None of them touch the filesystem.
6 *6 *
7 * @module usageTranscripts7 * @module usageTranscripts
8 */8 */
...@@ -68,7 +68,20 @@ export function totalTokens(totals: UsageTokenTotals): number {...@@ -68,7 +68,20 @@ export function totalTokens(totals: UsageTokenTotals): number {
68 * an order of magnitude.68 * an order of magnitude.
69 */69 */
70export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean {70export 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;
72}85}
7386
74/* -------------------------------------------------------------------------- */87/* -------------------------------------------------------------------------- */
...@@ -297,4 +310,179 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord...@@ -297,4 +310,179 @@ export function parseCodexLine(line: string, state: CodexScanState): UsageRecord
297 };310 };
298}311}
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
300export { EMPTY_TOTALS };488export { EMPTY_TOTALS };
apps/web/src/components/usage/UsageProviderChart.test.ts+1
...@@ -86,6 +86,7 @@ describe("buildDayColumns", () => {...@@ -86,6 +86,7 @@ describe("buildDayColumns", () => {
86 expect(first?.bands).toEqual([86 expect(first?.bands).toEqual([
87 { provider: "codex", value: 10 },87 { provider: "codex", value: 10 },
88 { provider: "claude", value: 20 },88 { provider: "claude", value: 20 },
89 { provider: "grok", value: 0 },
89 ]);90 ]);
90 });91 });
9192
apps/web/src/components/usage/usageProviders.ts+7-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { UsageProviderKind } from "@t3tools/contracts";1import type { UsageProviderKind } from "@t3tools/contracts";
22
3import { ClaudeAI, type Icon, OpenAI } from "../Icons";3import { ClaudeAI, GrokIcon, type Icon, OpenAI } from "../Icons";
44
5type UsageProviderPresentation = {5type UsageProviderPresentation = {
6 readonly label: string;6 readonly label: string;
...@@ -24,6 +24,12 @@ export const PROVIDER_PRESENTATION = {...@@ -24,6 +24,12 @@ export const PROVIDER_PRESENTATION = {
24 color: "#d97757",24 color: "#d97757",
25 mark: ClaudeAI,25 mark: ClaudeAI,
26 },26 },
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 },
27} satisfies Record<UsageProviderKind, UsageProviderPresentation>;33} satisfies Record<UsageProviderKind, UsageProviderPresentation>;
2834
29/** Stable provider reading order across charts, summaries, tables, and hover rows. */35/** 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....@@ -68,6 +68,10 @@ them on in **Settings** → the provider's card when you want to use them.
68Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that68Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that
69T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`.69T3 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
71Run the login command on the machine running the T3 Code server, not on the device you browse75Run the login command on the machine running the T3 Code server, not on the device you browse
72from.76from.
7377
docs/user/permission-modes.md+7-3
...@@ -26,6 +26,9 @@ unattended until it finishes or asks a question of its own....@@ -26,6 +26,9 @@ unattended until it finishes or asks a question of its own.
26Approvals appear inline in the conversation. Approve or reject one and the agent continues from26Approvals appear inline in the conversation. Approve or reject one and the agent continues from
27there.27there.
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
29## Choosing a Mode32## Choosing a Mode
3033
31Use **Full access** for work in a worktree or a sandbox you can throw away.34Use **Full access** for work in a worktree or a sandbox you can throw away.
...@@ -40,8 +43,9 @@ shell commands....@@ -40,8 +43,9 @@ shell commands.
4043
41Each provider maps these modes onto its own approval and sandbox settings. Codex, for example,44Each provider maps these modes onto its own approval and sandbox settings. Codex, for example,
42translates the mode into its approval policy and sandbox level, so **Supervised** runs the CLI45translates 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. The46with prompting enabled and a restricted workspace while **Full access** disables both. Grok
44labels above describe what you get; the exact per-provider translation is internal and may47threads do the same: **Supervised** starts Grok in ask mode even if your Grok CLI config is
45change.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
47Mobile offers the same four modes with the same labels and descriptions.51Mobile offers the same four modes with the same labels and descriptions.
docs/user/usage.md+7-4
...@@ -1,9 +1,12 @@...@@ -1,9 +1,12 @@
1# Review usage1# Review usage
22
3The Usage page combines Codex and Claude Code activity from your connected environments. It reads3The Usage page combines Codex, Claude Code, and Grok Build activity from your connected
4the providers' local session history and shows API-equivalent token cost, processed tokens, cache4environments. It reads the providers' local session history and shows API-equivalent token cost,
5savings, provider shares, and model breakdowns. Subscription billing is separate from the raw token5processed tokens, cache savings, provider shares, and model breakdowns. Subscription billing is
6cost shown here.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
8Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**,11Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**,
9**30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the12**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 @@...@@ -2,10 +2,10 @@
2 * Usage reporting contract.2 * Usage reporting contract.
3 *3 *
4 * Each environment scans the provider CLIs' own on-disk session transcripts4 * Each environment scans the provider CLIs' own on-disk session transcripts
5 * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather than5 * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`,
6 * relying on T3 Code's own orchestration projections, so usage stays complete6 * `~/.grok/sessions/**\/updates.jsonl`) rather than relying on T3 Code's own
7 * even for turns that were never driven through T3 Code. This mirrors the7 * orchestration projections, so usage stays complete even for turns that were
8 * approach `ccusage` takes.8 * never driven through T3 Code. This mirrors the approach `ccusage` takes.
9 *9 *
10 * Environments return pre-aggregated `(day, hourStart?, provider, model)`10 * Environments return pre-aggregated `(day, hourStart?, provider, model)`
11 * buckets. Raw transcript records never cross the wire.11 * buckets. Raw transcript records never cross the wire.
...@@ -21,9 +21,18 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";...@@ -21,9 +21,18 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
21 * client renders partial coverage when an environment reports an older version21 * client renders partial coverage when an environment reports an older version
22 * rather than failing the whole page.22 * rather than failing the whole page.
23 */23 */
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"]);
27export type UsageProviderKind = typeof UsageProviderKind.Type;36export type UsageProviderKind = typeof UsageProviderKind.Type;
2837
29/**38/**
packages/shared/src/usageMerge.test.ts+27-1
...@@ -158,7 +158,7 @@ describe("mergeUsage", () => {...@@ -158,7 +158,7 @@ describe("mergeUsage", () => {
158 summary(158 summary(
159 [bucket()],159 [bucket()],
160 [{ provider: "claude", hostId: "linux", homePath: "/b" }],160 [{ provider: "claude", hostId: "linux", homePath: "/b" }],
161 USAGE_CONTRACT_VERSION - 1,161 USAGE_CONTRACT_VERSION - 2,
162 ),162 ),
163 ),163 ),
164 ],164 ],
...@@ -169,6 +169,32 @@ describe("mergeUsage", () => {...@@ -169,6 +169,32 @@ describe("mergeUsage", () => {
169 expect(merged.staleEnvironments).toEqual(["env-b"]);169 expect(merged.staleEnvironments).toEqual(["env-b"]);
170 });170 });
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
172 it("derives provider shares and cost quality", () => {198 it("derives provider shares and cost quality", () => {
173 const merged = mergeUsage(199 const merged = mergeUsage(
174 [200 [
packages/shared/src/usageMerge.ts+16-9
...@@ -6,12 +6,13 @@...@@ -6,12 +6,13 @@
6 *6 *
7 * @module usageMerge7 * @module usageMerge
8 */8 */
9import type {9import {
10 EnvironmentId,10 USAGE_MERGE_COMPATIBLE_SINCE,
11 UsageBucket,11 type EnvironmentId,
12 UsageProviderKind,12 type UsageBucket,
13 UsageSourceFingerprint,13 type UsageProviderKind,
14 UsageSummary,14 type UsageSourceFingerprint,
15 type UsageSummary,
15} from "@t3tools/contracts";16} from "@t3tools/contracts";
1617
17export interface EnvironmentUsage {18export interface EnvironmentUsage {
...@@ -172,6 +173,10 @@ function bucketTokens(bucket: UsageBucket): number {...@@ -172,6 +173,10 @@ function bucketTokens(bucket: UsageBucket): number {
172 );173 );
173}174}
174175
176function isCompatibleContractVersion(version: number, expected: number): boolean {
177 return version >= USAGE_MERGE_COMPATIBLE_SINCE && version <= expected;
178}
179
175const EMPTY_MERGED: MergedUsage = {180const EMPTY_MERGED: MergedUsage = {
176 costUsd: 0,181 costUsd: 0,
177 uncachedInputTokens: 0,182 uncachedInputTokens: 0,
...@@ -201,8 +206,10 @@ const EMPTY_MERGED: MergedUsage = {...@@ -201,8 +206,10 @@ const EMPTY_MERGED: MergedUsage = {
201 * Merges every connected environment's summary.206 * Merges every connected environment's summary.
202 *207 *
203 * `expectedContractVersion` guards against an environment running older server208 * `expectedContractVersion` guards against an environment running older server
204 * code: rather than blocking the page, its data is excluded and its id is209 * code: rather than blocking the page, incompatible data is excluded and its
205 * reported so the UI can say coverage is partial.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.
206 */213 */
207export function mergeUsage(214export function mergeUsage(
208 environments: readonly EnvironmentUsage[],215 environments: readonly EnvironmentUsage[],
...@@ -213,7 +220,7 @@ export function mergeUsage(...@@ -213,7 +220,7 @@ export function mergeUsage(
213 const current: EnvironmentUsage[] = [];220 const current: EnvironmentUsage[] = [];
214 const staleEnvironments: EnvironmentId[] = [];221 const staleEnvironments: EnvironmentId[] = [];
215 for (const environment of environments) {222 for (const environment of environments) {
216 if (environment.summary.contractVersion === expectedContractVersion) {223 if (isCompatibleContractVersion(environment.summary.contractVersion, expectedContractVersion)) {
217 current.push(environment);224 current.push(environment);
218 } else {225 } else {
219 staleEnvironments.push(environment.environmentId);226 staleEnvironments.push(environment.environmentId);