| author | |
| committer | |
| log | ead4ce52a1624d9c55461f61524580978fc8b719 |
| tree | fe89b8bc51157ff03f7e9371644411814b5425e3 |
| parent | d3c24a14b92ccd8d832466dc7f0f51d979a8a21d |
| signature | Signed by PGP key B5690EEEBB952194 |
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 this | 5 | * 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 | */ |
| 8 | export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude"]; | 8 | export const PROVIDER_ORDER: readonly UsageProviderKind[] = ["codex", "claude", "grok"]; |
| 9 | 9 | ||
| 10 | export const PROVIDER_LABEL: Record<UsageProviderKind, string> = { | 10 | export 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 | }; |
| 14 | 15 | ||
| 15 | /** | 16 | /** |
| 16 | * Claude's brand orange holds in both themes; Codex is neutral and must flip | 17 | * 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 | */ |
| 19 | export function useProviderColors(): Record<UsageProviderKind, string> { | 20 | export 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 = |
| 19 | const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; | 19 | const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; |
| 20 | const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; | 20 | const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; |
| 21 | const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; | 21 | const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; |
| 22 | const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1"; | ||
| 23 | const emitXAiPlanMdWrite = process.env.T3_ACP_EMIT_XAI_PLAN_MD_WRITE === "1"; | ||
| 22 | const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; | 24 | const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; |
| 25 | const emitXAiRateLimitThenHang = process.env.T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG === "1"; | ||
| 26 | const emitXAiAskUserQuestionThenHang = | ||
| 27 | process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG === "1"; | ||
| 28 | const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1"; | ||
| 29 | const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; | ||
| 30 | const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; | ||
| 23 | const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; | 31 | const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; |
| 24 | const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; | 32 | const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; |
| 25 | const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; | 33 | const 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"; |
| 39 | const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; | 47 | const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; |
| 40 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; | 48 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; |
| 41 | const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; | 49 | const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; |
| 50 | const initialGrokReasoningEffort = | ||
| 51 | process.env.T3_ACP_INITIAL_GROK_REASONING_EFFORT?.trim() || undefined; | ||
| 42 | const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); | 52 | const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); |
| 43 | const permissionOptionIds = { | 53 | const 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 | }; |
| 58 | const omitAllowAlways = process.env.T3_ACP_OMIT_ALLOW_ALWAYS === "1"; | ||
| 59 | const permissionRequestCount = Math.max( | ||
| 60 | 1, | ||
| 61 | Number(process.env.T3_ACP_PERMISSION_REQUEST_COUNT ?? "1") || 1, | ||
| 62 | ); | ||
| 48 | const sessionId = "mock-session-1"; | 63 | const sessionId = "mock-session-1"; |
| 49 | 64 | ||
| 50 | let currentModeId = "ask"; | 65 | let currentModeId = "ask"; |
| ... | @@ -279,7 +294,13 @@ function modeState(): AcpSchema.SessionModeState { | ... | @@ -279,7 +294,13 @@ function modeState(): AcpSchema.SessionModeState { |
| 279 | } | 294 | } |
| 280 | 295 | ||
| 281 | const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [ | 296 | const 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 | ]; |
| 285 | 306 | ||
| ... | @@ -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 | } |
| 524 | 545 | ||
| 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 | }); |
| 658 | 741 | ||
| 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 = |
| 686 | 787 | 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 | } | ||
| 690 | 794 | ||
| 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 | } |
| 775 | 879 | ||
| 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 | } |
| 809 | 913 | ||
| 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 | } |
| 812 | 994 |
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); |
| 115 | 116 | ||
| 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 @@ | ||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Sink from "effect/Sink"; | ||
| 5 | import * as Stream from "effect/Stream"; | ||
| 6 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 7 | |||
| 8 | import { discoverGrokSkills, parseGrokInspectSkills } from "./GrokSkills.ts"; | ||
| 9 | |||
| 10 | const inspectPayload = (skills: ReadonlyArray<unknown>) => JSON.stringify({ skills }); | ||
| 11 | |||
| 12 | describe("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 | |||
| 95 | describe("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 | */ | ||
| 18 | import type { GrokSettings, ServerProviderSkill } from "@t3tools/contracts"; | ||
| 19 | import * as Effect from "effect/Effect"; | ||
| 20 | import * as Option from "effect/Option"; | ||
| 21 | import * as Result from "effect/Result"; | ||
| 22 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 23 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 24 | |||
| 25 | import { spawnAndCollect } from "../providerSnapshot.ts"; | ||
| 26 | |||
| 27 | const 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 | */ | ||
| 34 | export 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 | */ | ||
| 83 | export 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"; |
| 27 | 27 | ||
| 28 | import { ServerConfig } from "../../config.ts"; | 28 | import { ServerConfig } from "../../config.ts"; |
| 29 | import { grokPromptSettlementBelongsToContext, makeGrokAdapter } from "./GrokAdapter.ts"; | 29 | import { |
| 30 | grokPromptSettlementBelongsToContext, | ||
| 31 | isGrokEnterPlanModeToolCall, | ||
| 32 | makeGrokAdapter, | ||
| 33 | nextGrokPlanModeActive, | ||
| 34 | selectGrokPermissionOptionId, | ||
| 35 | } from "./GrokAdapter.ts"; | ||
| 30 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | 36 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 31 | 37 | ||
| 32 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | 38 | const __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(), { |
| 89 | const makeTestAdapter = (binaryPath: string, options?: Parameters<typeof makeGrokAdapter>[1]) => | 95 | const makeTestAdapter = (binaryPath: string, options?: Parameters<typeof makeGrokAdapter>[1]) => |
| 90 | makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie); | 96 | makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie); |
| 91 | 97 | ||
| 98 | it("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 | |||
| 119 | it("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 | |||
| 138 | function 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 | |||
| 160 | it("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 | |||
| 171 | it("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 | |||
| 92 | it("requires a settlement to match the live Grok turn", () => { | 182 | it("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 | ); |
| 420 | 510 | ||
| 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 | ); |
| 945 | 1391 | ||
| 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 | ); |
| 1099 | 1603 | ||
| 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 | ); |
| 1161 | 1906 | ||
| 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"; |
| 15 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 16 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | ||
| 17 | import { stableStringify } from "@t3tools/shared/relaySigning"; | ||
| 18 | import * as Clock from "effect/Clock"; | ||
| 15 | import * as Crypto from "effect/Crypto"; | 19 | import * as Crypto from "effect/Crypto"; |
| 16 | import * as DateTime from "effect/DateTime"; | 20 | import * as DateTime from "effect/DateTime"; |
| 17 | import * as Deferred from "effect/Deferred"; | 21 | import * as Deferred from "effect/Deferred"; |
| 22 | import * as Duration from "effect/Duration"; | ||
| 18 | import * as Effect from "effect/Effect"; | 23 | import * as Effect from "effect/Effect"; |
| 19 | import * as Exit from "effect/Exit"; | 24 | import * as Exit from "effect/Exit"; |
| 20 | import * as Fiber from "effect/Fiber"; | 25 | import * 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"; |
| 22 | import * as Option from "effect/Option"; | 27 | import * as Option from "effect/Option"; |
| 23 | import * as Path from "effect/Path"; | 28 | import * as Path from "effect/Path"; |
| 24 | import * as PubSub from "effect/PubSub"; | 29 | import * as PubSub from "effect/PubSub"; |
| 30 | import * as Queue from "effect/Queue"; | ||
| 25 | import * as Ref from "effect/Ref"; | 31 | import * as Ref from "effect/Ref"; |
| 26 | import * as Schema from "effect/Schema"; | 32 | import * as Schema from "effect/Schema"; |
| 27 | import * as Scope from "effect/Scope"; | 33 | import * 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"; |
| 56 | import { | 62 | import { |
| 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"; |
| 62 | import { | 70 | import { |
| 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"; |
| 69 | import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; | 81 | import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; |
| 70 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 82 | import { 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 |
| 73 | 85 | ||
| 74 | const PROVIDER = ProviderDriverKind.make("grok"); | 86 | const PROVIDER = ProviderDriverKind.make("grok"); |
| 75 | const GROK_RESUME_VERSION = 1 as const; | 87 | const GROK_RESUME_VERSION = 1 as const; |
| 88 | const 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. | ||
| 92 | const 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. | ||
| 96 | const DEFAULT_GROK_ACTIVE_TOOL_INACTIVITY_TIMEOUT_MS = 30 * 60 * 1_000; | ||
| 76 | 97 | ||
| 77 | function encodeJsonStringForDiagnostics(input: unknown): string | undefined { | 98 | function 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 | } |
| 88 | 113 | ||
| 89 | interface PendingApproval { | 114 | interface 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 | } |
| 100 | 125 | ||
| 126 | interface GrokTurnLivenessSignal { | ||
| 127 | readonly turnId: TurnId; | ||
| 128 | } | ||
| 129 | |||
| 101 | interface GrokSessionContext { | 130 | interface 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 that | 153 | * >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 | } |
| 122 | 167 | ||
| ... | @@ -164,6 +209,54 @@ const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined | ... | @@ -164,6 +209,54 @@ const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined |
| 164 | 209 | ||
| 165 | const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; | 210 | const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; |
| 166 | 211 | ||
| 212 | function 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. */ | ||
| 219 | export 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. */ | ||
| 240 | export 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 | |||
| 167 | const resolveSessionCallbackTurnId = ( | 260 | const 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 | } |
| 181 | 274 | ||
| 182 | function selectPermissionOptionId( | 275 | export 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 | } |
| 195 | 300 | ||
| 196 | function selectAutoApprovedPermissionOption( | 301 | function 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 | } |
| 204 | 309 | ||
| ... | @@ -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 | }; | ||
| 243 | 354 | ||
| 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; | ||
| 247 | 373 | ||
| 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)); |
| 296 | 422 | ||
| 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 | }); |
| 434 | 705 | ||
| 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 | }); |
| 497 | 866 | ||
| 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 |
| 556 | 964 | ||
| 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 | ...(mcpSession | 990 | ...(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: selectedOptionId | 1208 | 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?.model | 1225 | 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 | }; |
| 783 | 1295 | ||
| ... | @@ -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(); |
| 811 | 1332 | ||
| 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 |
| 887 | 1431 | ||
| 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; |
| 891 | 1436 | ||
| 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 can | 1478 | // 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?.model | 1498 | 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 | }); | ||
| 958 | 1505 | ||
| 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 | } |
| 1005 | 1552 | ||
| 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 = currentModelId | 1568 | 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 | } | ||
| 1035 | 1601 | ||
| 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"; |
| 6 | import * as Schema from "effect/Schema"; | 6 | import * as Schema from "effect/Schema"; |
| 7 | import { GrokSettings } from "@t3tools/contracts"; | 7 | import { GrokSettings } from "@t3tools/contracts"; |
| 8 | 8 | ||
| 9 | import { buildInitialGrokProviderSnapshot, checkGrokProviderStatus } from "./GrokProvider.ts"; | 9 | import { |
| 10 | buildGrokModelCapabilities, | ||
| 11 | buildInitialGrokProviderSnapshot, | ||
| 12 | checkGrokProviderStatus, | ||
| 13 | } from "./GrokProvider.ts"; | ||
| 10 | 14 | ||
| 11 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | 15 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 12 | 16 | ||
| 17 | describe("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 | |||
| 13 | describe("buildInitialGrokProviderSnapshot", () => { | 186 | describe("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"; |
| 32 | import { makeGrokAcpRuntime, resolveGrokAcpBaseModelId } from "../acp/GrokAcpSupport.ts"; | 32 | import { |
| 33 | isValidGrokReasoningEffortToken, | ||
| 34 | makeGrokAcpRuntime, | ||
| 35 | resolveGrokAcpBaseModelId, | ||
| 36 | } from "../acp/GrokAcpSupport.ts"; | ||
| 37 | import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; | ||
| 33 | 38 | ||
| 34 | const GROK_PRESENTATION = { | 39 | const 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 | } |
| 101 | 106 | ||
| 107 | function nonEmptyString(value: unknown): string | undefined { | ||
| 108 | return typeof value === "string" ? value.trim() || undefined : undefined; | ||
| 109 | } | ||
| 110 | |||
| 111 | function isRecord(value: unknown): value is Record<string, unknown> { | ||
| 112 | return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 113 | } | ||
| 114 | |||
| 115 | function 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 | |||
| 183 | export 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 | |||
| 102 | function buildGrokDiscoveredModelsFromSessionModelState( | 205 | function 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 = ( |
| 161 | export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( | 264 | export 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 | } |
| 253 | 357 | ||
| 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 | ); |
| 332 | 332 | ||
| 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" }); |
| 342 | 342 | ||
| 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"; |
| 16 | 17 | ||
| ... | @@ -542,6 +543,130 @@ describe("AcpRuntimeModel", () => { | ... | @@ -542,6 +543,130 @@ describe("AcpRuntimeModel", () => { |
| 542 | }); | 543 | }); |
| 543 | }); | 544 | }); |
| 544 | 545 | ||
| 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; |
| 554 | 679 | ||
| 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 | }); |
| 574 | 710 | ||
| 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 | }); |
| 585 | 722 | ||
| 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 | } |
| 318 | 318 | ||
| 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`. | ||
| 323 | function 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 | |||
| 319 | function extractTextContentFromToolCallContent( | 345 | function 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 relative | 367 | return { |
| 342 | // order. The retained tail came from that text entry, so placing it there also preserves its | 368 | 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. | ||
| 377 | function 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 | } |
| 359 | 428 | ||
| 360 | function normalizeToolKind(kind: unknown): string | undefined { | 429 | function 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 | } |
| 520 | 589 | ||
| 590 | function 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. | ||
| 599 | export 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 | |||
| 521 | export function decideToolCallUpdateEmission( | 626 | export 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.emit | 898 | lastEmittedDetailLength: decision.emit |
| 896 | ? nextToolCall.detail?.length | 899 | ? 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 GrokAcpCliProbe | 3 | * Enable with: T3_GROK_ACP_PROBE=1 vp test run GrokAcpCliProbe |
| 4 | * Set T3_GROK_LIVE_TURN=1 to also send a small prompt to the real model. | ||
| 4 | * | 5 | * |
| 5 | * The probe assumes either `XAI_API_KEY` is set in the environment or | 6 | * The probe assumes either `XAI_API_KEY` is set in the environment or |
| 6 | * the user has previously run `grok login`. Without credentials the | 7 | * the user has previously run `grok login`. Without credentials the |
| ... | @@ -10,6 +11,10 @@ | ... | @@ -10,6 +11,10 @@ |
| 10 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 11 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 11 | import { it } from "@effect/vitest"; | 12 | import { it } from "@effect/vitest"; |
| 12 | import * as Effect from "effect/Effect"; | 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Deferred from "effect/Deferred"; | ||
| 15 | import * as Fiber from "effect/Fiber"; | ||
| 16 | import * as FileSystem from "effect/FileSystem"; | ||
| 17 | import * as Stream from "effect/Stream"; | ||
| 13 | import { ChildProcessSpawner } from "effect/unstable/process"; | 18 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 14 | import { describe, expect } from "vite-plus/test"; | 19 | import { describe, expect } from "vite-plus/test"; |
| 15 | 20 | ||
| ... | @@ -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"; |
| 5 | import { | 5 | import { |
| 6 | applyGrokAcpModelSelection, | 6 | applyGrokAcpModelSelection, |
| 7 | buildGrokAcpSpawnInput, | 7 | buildGrokAcpSpawnInput, |
| 8 | grokAcpSpawnArgs, | ||
| 9 | isValidGrokReasoningEffortToken, | ||
| 8 | resolveGrokAcpBaseModelId, | 10 | resolveGrokAcpBaseModelId, |
| 9 | } from "./GrokAcpSupport.ts"; | 11 | } from "./GrokAcpSupport.ts"; |
| 10 | 12 | ||
| ... | @@ -16,6 +18,35 @@ describe("resolveGrokAcpBaseModelId", () => { | ... | @@ -16,6 +18,35 @@ describe("resolveGrokAcpBaseModelId", () => { |
| 16 | }); | 18 | }); |
| 17 | }); | 19 | }); |
| 18 | 20 | ||
| 21 | describe("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 | |||
| 19 | describe("buildGrokAcpSpawnInput", () => { | 50 | describe("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 | |||
| 79 | describe("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 | }); |
| 37 | 88 | ||
| 38 | describe("applyGrokAcpModelSelection", () => { | 89 | describe("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 | ); |
| 65 | 119 | ||
| 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 @@ |
| 1 | import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; | 1 | import { type GrokSettings, ProviderDriverKind, type RuntimeMode } from "@t3tools/contracts"; |
| 2 | import * as Crypto from "effect/Crypto"; | 2 | import * as Crypto from "effect/Crypto"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * 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 | |||
| 33 | export 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 | } |
| 31 | 47 | ||
| 32 | export function buildGrokAcpSpawnInput( | 48 | export 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 | } |
| 84 | 106 | ||
| 107 | const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i; | ||
| 108 | |||
| 109 | export function isValidGrokReasoningEffortToken(value: string): boolean { | ||
| 110 | return GROK_REASONING_EFFORT_TOKEN.test(value); | ||
| 111 | } | ||
| 112 | |||
| 113 | export function normalizeGrokReasoningEffort(value: string | undefined): string | undefined { | ||
| 114 | const effort = value?.trim(); | ||
| 115 | return effort && isValidGrokReasoningEffortToken(effort) ? effort : undefined; | ||
| 116 | } | ||
| 117 | |||
| 85 | export function currentGrokModelIdFromSessionSetup( | 118 | export function currentGrokModelIdFromSessionSetup( |
| 86 | sessionSetupResult: | 119 | sessionSetupResult: |
| 87 | | EffectAcpSchema.LoadSessionResponse | 120 | | 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 | } |
| 93 | 126 | ||
| 127 | export 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 | |||
| 94 | export function applyGrokAcpModelSelection<E>(input: { | 150 | export 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.runtime | 177 | 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:off | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodeOS from "node:os"; | ||
| 2 | import * as NodePath from "node:path"; | 3 | import * as NodePath from "node:path"; |
| 3 | import * as NodeURL from "node:url"; | 4 | import * as NodeURL from "node:url"; |
| 4 | 5 | ||
| ... | @@ -9,11 +10,17 @@ import * as Schema from "effect/Schema"; | ... | @@ -9,11 +10,17 @@ import * as Schema from "effect/Schema"; |
| 9 | import { describe, expect } from "vite-plus/test"; | 10 | import { describe, expect } from "vite-plus/test"; |
| 10 | 11 | ||
| 11 | import { | 12 | import { |
| 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"; |
| 18 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 25 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| 19 | 26 | ||
| ... | @@ -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 | ); |
| 301 | 308 | ||
| 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 @@ |
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | |||
| 1 | import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; | 3 | import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; |
| 2 | import * as Deferred from "effect/Deferred"; | 4 | import * as Deferred from "effect/Deferred"; |
| 3 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 4 | import * as Ref from "effect/Ref"; | 6 | import * as Ref from "effect/Ref"; |
| 5 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 8 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 6 | import type * as EffectAcpSchema from "effect-acp/schema"; | 9 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 7 | 10 | ||
| 8 | import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 11 | import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| ... | @@ -19,11 +22,15 @@ type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type; | ... | @@ -19,11 +22,15 @@ type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type; |
| 19 | interface PendingXAiPromptCompletion { | 22 | interface 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 | } |
| 24 | 30 | ||
| 25 | const completedXAiPromptIdLimit = 128; | 31 | const completedXAiPromptIdLimit = 128; |
| 26 | const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; | 32 | const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; |
| 33 | const xAiRateLimitedErrorCode = -32003; | ||
| 27 | 34 | ||
| 28 | const XAiAskUserQuestionOption = Schema.Struct({ | 35 | const 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 | } |
| 198 | 205 | ||
| 206 | // --------------------------------------------------------------------------- | ||
| 207 | // x.ai/exit_plan_mode — plan approval gate (mirrors Grok Build TUI plan window) | ||
| 208 | // --------------------------------------------------------------------------- | ||
| 209 | |||
| 210 | const XAiExitPlanModeParams = Schema.Struct({ | ||
| 211 | sessionId: Schema.String, | ||
| 212 | toolCallId: Schema.String, | ||
| 213 | planContent: Schema.optional(Schema.NullOr(Schema.String)), | ||
| 214 | }); | ||
| 215 | |||
| 216 | const XAiWrappedExitPlanModeParams = Schema.Struct({ | ||
| 217 | method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]), | ||
| 218 | params: XAiExitPlanModeParams, | ||
| 219 | }); | ||
| 220 | |||
| 221 | export const XAiExitPlanModeRequest = Schema.Union([ | ||
| 222 | XAiExitPlanModeParams, | ||
| 223 | XAiWrappedExitPlanModeParams, | ||
| 224 | ]); | ||
| 225 | |||
| 226 | type XAiExitPlanModeRequestParams = typeof XAiExitPlanModeParams.Type; | ||
| 227 | type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type; | ||
| 228 | |||
| 229 | function 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. */ | ||
| 234 | export const XAI_EMPTY_PLAN_MARKDOWN = | ||
| 235 | "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)"; | ||
| 236 | |||
| 237 | export 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 | |||
| 253 | export type XAiExitPlanModeOutcome = "approved" | "abandoned" | "request_changes"; | ||
| 254 | |||
| 255 | export 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 | */ | ||
| 264 | export 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 | |||
| 273 | function normalizeFsPath(value: string): string { | ||
| 274 | return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); | ||
| 275 | } | ||
| 276 | |||
| 277 | function pathHasTraversalSegment(normalized: string): boolean { | ||
| 278 | return normalized.split("/").includes(".."); | ||
| 279 | } | ||
| 280 | |||
| 281 | function 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`. */ | ||
| 294 | export interface GrokPlanPathHost { | ||
| 295 | readonly platform: NodeJS.Platform; | ||
| 296 | readonly environment: NodeJS.ProcessEnv; | ||
| 297 | } | ||
| 298 | |||
| 299 | function 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 | |||
| 313 | const CANONICAL_HOME_GROK_SESSION_PATH = | ||
| 314 | /^(?:\/home\/[^/]+|\/Users\/[^/]+|[a-zA-Z]:\/Users\/[^/]+)\/\.grok\/sessions\/(?:[^/]+\/)+plan\.md$/; | ||
| 315 | const 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 | */ | ||
| 326 | export 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 | */ | ||
| 363 | export 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 | |||
| 414 | function 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 = ( |
| 287 | 506 | ||
| 288 | const unregisterXAiPromptCompletionFallback = ( | 507 | const 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)); |
| 292 | 511 | ||
| 293 | const abortPendingPromptCompletions = ( | 512 | const 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 | ); |
| 367 | 586 | ||
| 587 | const 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 | |||
| 611 | function 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 | |||
| 368 | const rememberCompletedXAiPromptId = ( | 622 | const 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"; |
| 8 | 8 | ||
| 9 | import { type GrokSettings, type ModelSelection } from "@t3tools/contracts"; | 9 | import { type GrokSettings, type ModelSelection } from "@t3tools/contracts"; |
| 10 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | 10 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; |
| 11 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | ||
| 11 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; | 12 | import { extractJsonObject } from "@t3tools/shared/schemaJson"; |
| 12 | 13 | ||
| 13 | import { TextGenerationError } from "@t3tools/contracts"; | 14 | import { TextGenerationError } from "@t3tools/contracts"; |
| ... | @@ -26,6 +27,7 @@ import { | ... | @@ -26,6 +27,7 @@ import { |
| 26 | import { | 27 | import { |
| 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 |
| 83 | 85 | ||
| 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's | 4 | * 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 by | 8 | * 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; warm | 9 | * `(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"; |
| 24 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | ||
| 24 | import * as Cause from "effect/Cause"; | 25 | import * as Cause from "effect/Cause"; |
| 25 | import * as Clock from "effect/Clock"; | 26 | import * as Clock from "effect/Clock"; |
| 26 | import * as Context from "effect/Context"; | 27 | import * 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"; |
| 34 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 35 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 35 | 36 | ||
| 36 | import { ServerConfig } from "../config.ts"; | 37 | import { ServerConfig } from "../config.ts"; |
| 38 | import { expandHomePath } from "../pathExpansion.ts"; | ||
| 37 | import * as ServerSettings from "../serverSettings.ts"; | 39 | import * as ServerSettings from "../serverSettings.ts"; |
| 38 | import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; | 40 | import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; |
| 39 | import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; | 41 | import { 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; | ||
| 126 | 129 | ||
| 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"); | ||
| 221 | 231 | ||
| 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 | }); |
| 227 | 242 | ||
| ... | @@ -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[] = []; |
| 355 | 370 | ||
| 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* fileSystem | 373 | 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 | } |
| 374 | 389 | ||
| 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 a | 396 | // 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 | }); | ||
| 45 | 53 | ||
| 46 | const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); | 54 | const restored = decodeScanCache(JSON.parse(JSON.stringify(encodeScanCache(original)))); |
| 47 | 55 | ||
| 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 | }); |
| 52 | 61 | ||
| 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; |
| 139 | 139 | ||
| 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"; |
| 27 | 28 | ||
| ... | @@ -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 get | 38 | * 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 than | 39 | * 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 | */ |
| 41 | export async function listTranscriptFiles( | 46 | export 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; | ||
| 46 | 53 | ||
| 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 | } |
| 131 | 142 | ||
| 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 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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"; |
| 9 | 11 | ||
| ... | @@ -249,3 +251,316 @@ describe("totalTokens", () => { | ... | @@ -249,3 +251,316 @@ describe("totalTokens", () => { |
| 249 | ).toBe(100); | 251 | ).toBe(100); |
| 250 | }); | 252 | }); |
| 251 | }); | 253 | }); |
| 254 | |||
| 255 | describe("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 files | 4 | * 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 usageTranscripts | 7 | * @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 | */ |
| 70 | export function mightCarryUsage(line: string, provider: UsageProviderKind): boolean { | 70 | export 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 | */ | ||
| 80 | export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000; | ||
| 81 | |||
| 82 | export 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 | } |
| 73 | 86 | ||
| 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 | } |
| 299 | 312 | ||
| 313 | /* -------------------------------------------------------------------------- */ | ||
| 314 | /* Grok Build */ | ||
| 315 | /* -------------------------------------------------------------------------- */ | ||
| 316 | |||
| 317 | interface 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 | |||
| 326 | function 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 | |||
| 342 | function 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 | */ | ||
| 369 | export 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 | |||
| 300 | export { EMPTY_TOTALS }; | 488 | export { 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 | }); |
| 91 | 92 |
apps/web/src/components/usage/usageProviders.ts+7-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import type { UsageProviderKind } from "@t3tools/contracts"; | 1 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import { ClaudeAI, type Icon, OpenAI } from "../Icons"; | 3 | import { ClaudeAI, GrokIcon, type Icon, OpenAI } from "../Icons"; |
| 4 | 4 | ||
| 5 | type UsageProviderPresentation = { | 5 | type 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>; |
| 28 | 34 | ||
| 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. |
| 68 | Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that | 68 | Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that |
| 69 | T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. | 69 | T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. |
| 70 | 70 | ||
| 71 | Grok models that support adjustable reasoning show a **Reasoning** control beside the model picker. | ||
| 72 | The available levels and default come from the installed Grok Build CLI, so they can vary by model | ||
| 73 | and CLI version. | ||
| 74 | |||
| 71 | Run the login command on the machine running the T3 Code server, not on the device you browse | 75 | Run the login command on the machine running the T3 Code server, not on the device you browse |
| 72 | from. | 76 | from. |
| 73 | 77 |
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. |
| 26 | Approvals appear inline in the conversation. Approve or reject one and the agent continues from | 26 | Approvals appear inline in the conversation. Approve or reject one and the agent continues from |
| 27 | there. | 27 | there. |
| 28 | 28 | ||
| 29 | For Grok, **Always allow this session** remembers the matching command or tool input. Other | ||
| 30 | actions still ask for approval. It does not change the thread to **Full access**. | ||
| 31 | |||
| 29 | ## Choosing a Mode | 32 | ## Choosing a Mode |
| 30 | 33 | ||
| 31 | Use **Full access** for work in a worktree or a sandbox you can throw away. | 34 | Use **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. |
| 40 | 43 | ||
| 41 | Each provider maps these modes onto its own approval and sandbox settings. Codex, for example, | 44 | Each provider maps these modes onto its own approval and sandbox settings. Codex, for example, |
| 42 | translates the mode into its approval policy and sandbox level, so **Supervised** runs the CLI | 45 | translates the mode into its approval policy and sandbox level, so **Supervised** runs the CLI |
| 43 | with prompting enabled and a restricted workspace while **Full access** disables both. The | 46 | with prompting enabled and a restricted workspace while **Full access** disables both. Grok |
| 44 | labels above describe what you get; the exact per-provider translation is internal and may | 47 | threads do the same: **Supervised** starts Grok in ask mode even if your Grok CLI config is |
| 45 | change. | 48 | set to always-approve, and **Full access** starts Grok with always-approve. The labels above |
| 49 | describe what you get; the exact per-provider translation is internal and may change. | ||
| 46 | 50 | ||
| 47 | Mobile offers the same four modes with the same labels and descriptions. | 51 | Mobile 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 usage | 1 | # Review usage |
| 2 | 2 | ||
| 3 | The Usage page combines Codex and Claude Code activity from your connected environments. It reads | 3 | The Usage page combines Codex, Claude Code, and Grok Build activity from your connected |
| 4 | the providers' local session history and shows API-equivalent token cost, processed tokens, cache | 4 | environments. It reads the providers' local session history and shows API-equivalent token cost, |
| 5 | savings, provider shares, and model breakdowns. Subscription billing is separate from the raw token | 5 | processed tokens, cache savings, provider shares, and model breakdowns. Subscription billing is |
| 6 | cost shown here. | 6 | separate from the raw token cost shown here. |
| 7 | |||
| 8 | Grok Build totals come from persisted session updates. Interactive turns that never wrote a | ||
| 9 | completed-turn record will not appear. | ||
| 7 | 10 | ||
| 8 | Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, | 11 | Use **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 the | 12 | **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 transcripts | 4 | * Each environment scans the provider CLIs' own on-disk session transcripts |
| 5 | * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`) rather than | 5 | * (`~/.claude/projects/**\/*.jsonl`, `~/.codex/sessions/**\/*.jsonl`, |
| 6 | * relying on T3 Code's own orchestration projections, so usage stays complete | 6 | * `~/.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 the | 7 | * 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 version | 21 | * 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 | */ |
| 24 | export const USAGE_CONTRACT_VERSION = 4 as const; | 24 | export const USAGE_CONTRACT_VERSION = 5 as const; |
| 25 | 25 | ||
| 26 | export 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 | */ | ||
| 33 | export const USAGE_MERGE_COMPATIBLE_SINCE = 4 as const; | ||
| 34 | |||
| 35 | export const UsageProviderKind = Schema.Literals(["claude", "codex", "grok"]); | ||
| 27 | export type UsageProviderKind = typeof UsageProviderKind.Type; | 36 | export type UsageProviderKind = typeof UsageProviderKind.Type; |
| 28 | 37 | ||
| 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 | }); |
| 171 | 171 | ||
| 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 usageMerge | 7 | * @module usageMerge |
| 8 | */ | 8 | */ |
| 9 | import type { | 9 | import { |
| 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"; |
| 16 | 17 | ||
| 17 | export interface EnvironmentUsage { | 18 | export interface EnvironmentUsage { |
| ... | @@ -172,6 +173,10 @@ function bucketTokens(bucket: UsageBucket): number { | ... | @@ -172,6 +173,10 @@ function bucketTokens(bucket: UsageBucket): number { |
| 172 | ); | 173 | ); |
| 173 | } | 174 | } |
| 174 | 175 | ||
| 176 | function isCompatibleContractVersion(version: number, expected: number): boolean { | ||
| 177 | return version >= USAGE_MERGE_COMPATIBLE_SINCE && version <= expected; | ||
| 178 | } | ||
| 179 | |||
| 175 | const EMPTY_MERGED: MergedUsage = { | 180 | const 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 server | 208 | * `expectedContractVersion` guards against an environment running older server |
| 204 | * code: rather than blocking the page, its data is excluded and its id is | 209 | * 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 | */ |
| 207 | export function mergeUsage( | 214 | export 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); |