History

fix(orchestrator): harden Grok v2 lifecycle (#3578)

Integrate the current Grok settlement, monitor/subagent lifecycle, interrupt containment, steer visibility, and image support stack into the orchestration-v2 branch.

Mike Olson committed 3 months ago

Commit 72c65661 parent 25a912552 files · +15942 −1999Browse files

Unified · Split

52 files
  1. .github/workflows/ci.yml +10 −0
  2. apps/mobile/src/features/threads/ThreadActivityInspector.tsx +3 −2
  3. apps/mobile/src/features/threads/ThreadFeed.tsx +4 −1
  4. apps/mobile/src/lib/threadActivityInspector.test.ts +47 −18
  5. apps/mobile/src/lib/threadActivityInspector.ts +5 −1
  6. apps/server/scripts/acp-mock-agent.ts +454 −195
  7. apps/server/scripts/acp-thread-spawn-helper.c +37 −0
  8. apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +6081 −73
  9. apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +3000 −363
  10. apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +40 −0
  11. apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +25 −18
  12. apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +253 −2
  13. apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +75 −7
  14. apps/server/src/orchestration-v2/EffectWorker.test.ts +39 −1
  15. apps/server/src/orchestration-v2/EffectWorker.ts +31 −2
  16. apps/server/src/orchestration-v2/ProjectionStore.ts +2 −0
  17. apps/server/src/orchestration-v2/ProviderAdapter.ts +2 −0
  18. apps/server/src/orchestration-v2/ProviderContinuationRequests.ts +4 −0
  19. apps/server/src/orchestration-v2/ProviderContinuationService.test.ts +193 −0
  20. apps/server/src/orchestration-v2/ProviderContinuationService.ts +12 −1
  21. apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +93 −0
  22. apps/server/src/orchestration-v2/ProviderSessionManager.ts +28 −12
  23. apps/server/src/orchestration-v2/ProviderTurnControlService.ts +43 −5
  24. apps/server/src/orchestration-v2/ProviderTurnStartService.ts +17 −0
  25. apps/server/src/orchestration-v2/RunExecutionService.test.ts +189 −1
  26. apps/server/src/orchestration-v2/RunExecutionService.ts +49 −23
  27. apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +6 −1
  28. apps/server/src/orchestration-v2/testkit/fixtures/message_steering/cursor_output.ts +6 −5
  29. apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_output.ts +6 −5
  30. apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +34 −0
  31. apps/server/src/provider/acp/AcpRuntimeModel.test.ts +93 −0
  32. apps/server/src/provider/acp/AcpRuntimeModel.ts +30 −0
  33. apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts +1030 −0
  34. apps/server/src/provider/acp/AcpSessionRuntime.ts +1299 −41
  35. apps/server/src/provider/acp/GrokAcpSupport.test.ts +28 −105
  36. apps/server/src/provider/acp/GrokAcpSupport.ts +29 −82
  37. apps/server/src/provider/acp/XAiAcpExtension.test.ts +1086 −57
  38. apps/server/src/provider/acp/XAiAcpExtension.ts +875 −398
  39. apps/web/src/components/ChatView.logic.test.ts +112 −230
  40. apps/web/src/components/ChatView.logic.ts +17 −1
  41. apps/web/src/components/ChatView.tsx +13 −276
  42. apps/web/src/components/chat/MessagesTimeline.tsx +20 −14
  43. apps/web/src/keybindings.test.ts +7 −0
  44. apps/web/src/keybindings.ts +5 −1
  45. packages/client-runtime/src/state/orchestrationV2Projection.ts +1 −0
  46. packages/effect-acp/src/client.ts +14 −22
  47. packages/effect-acp/src/protocol.test.ts +200 −0
  48. packages/effect-acp/src/protocol.ts +224 −31
  49. packages/shared/src/agentAwareness.test.ts +16 −0
  50. packages/shared/src/agentAwareness.ts +4 −1
  51. packages/shared/src/orchestrationV2Timeline.test.ts +33 −2
  52. packages/shared/src/orchestrationV2Timeline.ts +18 −2
.github/workflows/ci.yml+10−0
@@ -82,6 +82,16 @@ jobs: · 81 unmodified lines
82 !/.repos/82 !/.repos/
83 sparse-checkout-cone-mode: false83 sparse-checkout-cone-mode: false
84 84
85 # Blacksmith boots GitHub's Ubuntu runner image (gcc is usually present),
86 # but ACP process-tree live tests compile a small pthread fixture with `cc`
87 # and soft-skip when it is missing. Install build-essential so that path
88 # always runs in CI instead of silently no-oping.
89 - name: Install C toolchain for process-tree fixtures
90 run: |
91 sudo apt-get update
92 sudo apt-get install -y --no-install-recommends build-essential
93 command -v cc
94
85 - name: Setup Vite+95 - name: Setup Vite+
86 uses: voidzero-dev/setup-vp@v196 uses: voidzero-dev/setup-vp@v1
87 with:97 with:
apps/mobile/src/features/threads/ThreadActivityInspector.tsx+3−2
@@ -16,6 +16,7 @@ import { useV2ItemSupport } from "../../state/v2-item-support"; · 15 unmodified lines
16 16
17export function ThreadActivityInspector(props: {17export function ThreadActivityInspector(props: {
18 readonly activity: ThreadFeedActivity;18 readonly activity: ThreadFeedActivity;
19 readonly currentThreadId: ThreadId;
19 readonly environmentId: EnvironmentId;20 readonly environmentId: EnvironmentId;
20 readonly iconColor: ColorValue;21 readonly iconColor: ColorValue;
21 readonly workspaceRoot?: string | null;22 readonly workspaceRoot?: string | null;
@@ -28,8 +29,8 @@ export function ThreadActivityInspector(props: { · 6 unmodified lines
28 sourceItemId: row.sourceItemId,29 sourceItemId: row.sourceItemId,
29 });30 });
30 const model = useMemo(31 const model = useMemo(
31 () => buildThreadActivityInspector(props.activity, support),32 () => buildThreadActivityInspector(props.activity, support, props.currentThreadId),
32 [props.activity, support],33 [props.activity, props.currentThreadId, support],
33 );34 );
34 const revertCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, {35 const revertCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, {
35 label: "checkpoint rollback",36 label: "checkpoint rollback",
apps/mobile/src/features/threads/ThreadFeed.tsx+4−1
@@ -1055,7 +1055,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 1054 unmodified lines
1055 1055
1056function renderFeedEntry(1056function renderFeedEntry(
1057 info: { item: ThreadFeedEntry; index: number },1057 info: { item: ThreadFeedEntry; index: number },
1058 props: Pick<ThreadFeedProps, "environmentId" | "skills" | "workspaceRoot"> & {1058 props: Pick<ThreadFeedProps, "environmentId" | "skills" | "threadId" | "workspaceRoot"> & {
1059 readonly copiedRowId: string | null;1059 readonly copiedRowId: string | null;
1060 readonly expandedWorkRows: Record<string, boolean>;1060 readonly expandedWorkRows: Record<string, boolean>;
1061 readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;1061 readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
@@ -1298,6 +1298,7 @@ function renderFeedEntry( · 236 unmodified lines
1298 activities={entry.activities}1298 activities={entry.activities}
1299 anchorKey={entry.id}1299 anchorKey={entry.id}
1300 copiedRowId={props.copiedRowId}1300 copiedRowId={props.copiedRowId}
1301 currentThreadId={props.threadId}
1301 environmentId={props.environmentId}1302 environmentId={props.environmentId}
1302 expanded={props.expandedWorkGroups[entry.id] ?? false}1303 expanded={props.expandedWorkGroups[entry.id] ?? false}
1303 expandedRows={props.expandedWorkRows}1304 expandedRows={props.expandedWorkRows}
@@ -2117,6 +2118,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 813 unmodified lines
2117 (info: { item: ThreadFeedEntry; index: number }) =>2118 (info: { item: ThreadFeedEntry; index: number }) =>
2118 renderFeedEntry(info, {2119 renderFeedEntry(info, {
2119 environmentId: props.environmentId,2120 environmentId: props.environmentId,
2121 threadId: props.threadId,
2120 copiedRowId,2122 copiedRowId,
2121 expandedWorkRows,2123 expandedWorkRows,
2122 terminalAssistantMessageIds,2124 terminalAssistantMessageIds,
@@ -2158,6 +2160,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 35 unmodified lines
2158 onToggleWorkGroup,2160 onToggleWorkGroup,
2159 onToggleWorkRow,2161 onToggleWorkRow,
2160 props.environmentId,2162 props.environmentId,
2163 props.threadId,
2161 props.threadTitle,2164 props.threadTitle,
2162 props.skills,2165 props.skills,
2163 props.workspaceRoot,2166 props.workspaceRoot,
apps/mobile/src/lib/threadActivityInspector.test.ts+47−18
@@ -93,7 +93,7 @@ describe("buildThreadActivityInspector", () => { · 92 unmodified lines
93 } as never,93 } as never,
94 };94 };
95 95
96 const model = buildThreadActivityInspector(activityFor(item), support);96 const model = buildThreadActivityInspector(activityFor(item), support, sourceThreadId);
97 expect(model.fields).toEqual(97 expect(model.fields).toEqual(
98 expect.arrayContaining([98 expect.arrayContaining([
99 { label: "Duration", value: "2.0s" },99 { label: "Duration", value: "2.0s" },
@@ -119,7 +119,11 @@ describe("buildThreadActivityInspector", () => { · 19 unmodified lines
119 pattern: "resolveSession",119 pattern: "resolveSession",
120 results: [{ fileName: "src/session.ts", line: 42, preview: "function resolveSession()" }],120 results: [{ fileName: "src/session.ts", line: 42, preview: "function resolveSession()" }],
121 };121 };
122 const fileModel = buildThreadActivityInspector(activityFor(fileSearch), EMPTY_V2_ITEM_SUPPORT);122 const fileModel = buildThreadActivityInspector(
123 activityFor(fileSearch),
124 EMPTY_V2_ITEM_SUPPORT,
125 sourceThreadId,
126 );
123 expect(fileModel.fileLinks).toEqual([127 expect(fileModel.fileLinks).toEqual([
124 {128 {
125 label: "src/session.ts — function resolveSession()",129 label: "src/session.ts — function resolveSession()",
@@ -136,7 +140,11 @@ describe("buildThreadActivityInspector", () => { · 10 unmodified lines
136 { title: "Schema", url: "https://effect.website/docs/schema", snippet: "Typed schemas" },140 { title: "Schema", url: "https://effect.website/docs/schema", snippet: "Typed schemas" },
137 ],141 ],
138 };142 };
139 const webModel = buildThreadActivityInspector(activityFor(webSearch), EMPTY_V2_ITEM_SUPPORT);143 const webModel = buildThreadActivityInspector(
144 activityFor(webSearch),
145 EMPTY_V2_ITEM_SUPPORT,
146 sourceThreadId,
147 );
140 expect(webModel.webLinks).toEqual([148 expect(webModel.webLinks).toEqual([
141 { label: "Schema", url: "https://effect.website/docs/schema" },149 { label: "Schema", url: "https://effect.website/docs/schema" },
142 ]);150 ]);
@@ -156,6 +164,7 @@ describe("buildThreadActivityInspector", () => { · 13 unmodified lines
156 const dynamicModel = buildThreadActivityInspector(164 const dynamicModel = buildThreadActivityInspector(
157 activityFor(dynamicTool),165 activityFor(dynamicTool),
158 EMPTY_V2_ITEM_SUPPORT,166 EMPTY_V2_ITEM_SUPPORT,
167 sourceThreadId,
159 );168 );
160 expect(dynamicModel.blocks).toEqual(169 expect(dynamicModel.blocks).toEqual(
161 expect.arrayContaining([170 expect.arrayContaining([
@@ -177,11 +186,15 @@ describe("buildThreadActivityInspector", () => { · 15 unmodified lines
177 files: [{ path: "src/main.ts", kind: "modified", additions: 2, deletions: 1 }],186 files: [{ path: "src/main.ts", kind: "modified", additions: 2, deletions: 1 }],
178 };187 };
179 const checkpoint = { id: checkpointId, scopeId, status: "ready" } as never;188 const checkpoint = { id: checkpointId, scopeId, status: "ready" } as never;
180 const model = buildThreadActivityInspector(activityFor(item), {189 const model = buildThreadActivityInspector(
181 ...EMPTY_V2_ITEM_SUPPORT,190 activityFor(item),
182 item,191 {
183 checkpoint,192 ...EMPTY_V2_ITEM_SUPPORT,
184 });193 item,
194 checkpoint,
195 },
196 sourceThreadId,
197 );
185 198
186 expect(model.canRollback).toBe(true);199 expect(model.canRollback).toBe(true);
187 expect(model.rollbackTarget).toEqual({200 expect(model.rollbackTarget).toEqual({
@@ -189,6 +202,18 @@ describe("buildThreadActivityInspector", () => { · 1 unmodified line
189 checkpointId,202 checkpointId,
190 scopeId,203 scopeId,
191 });204 });
205
206 const inheritedModel = buildThreadActivityInspector(
207 activityFor(item),
208 {
209 ...EMPTY_V2_ITEM_SUPPORT,
210 item,
211 checkpoint,
212 },
213 ThreadId.make("child-thread"),
214 );
215 expect(inheritedModel.canRollback).toBe(false);
216 expect(inheritedModel.rollbackTarget).toBeNull();
192 });217 });
193 218
194 it("prefers live subagent progress from supporting state", () => {219 it("prefers live subagent progress from supporting state", () => {
@@ -204,16 +229,20 @@ describe("buildThreadActivityInspector", () => { · 9 unmodified lines
204 progress: "Starting",229 progress: "Starting",
205 result: null,230 result: null,
206 };231 };
207 const model = buildThreadActivityInspector(activityFor(item), {232 const model = buildThreadActivityInspector(
208 ...EMPTY_V2_ITEM_SUPPORT,233 activityFor(item),
209 item,234 {
210 subagent: {235 ...EMPTY_V2_ITEM_SUPPORT,
211 origin: "provider_native",236 item,
212 status: "running",237 subagent: {
213 progress: "Reading projection tests",238 origin: "provider_native",
214 result: null,239 status: "running",
215 } as never,240 progress: "Reading projection tests",
216 });241 result: null,
242 } as never,
243 },
244 sourceThreadId,
245 );
217 246
218 expect(model.fields).toContainEqual({247 expect(model.fields).toContainEqual({
219 label: "Delegated task",248 label: "Delegated task",
apps/mobile/src/lib/threadActivityInspector.ts+5−1
@@ -73,6 +73,7 @@ function addBlock( · 72 unmodified lines
73export function buildThreadActivityInspector(73export function buildThreadActivityInspector(
74 activity: ThreadFeedActivity,74 activity: ThreadFeedActivity,
75 support: V2ItemSupport,75 support: V2ItemSupport,
76 currentThreadId: ThreadId,
76): ThreadActivityInspectorModel {77): ThreadActivityInspectorModel {
77 const row = activity.projectedItem;78 const row = activity.projectedItem;
78 const item = row.item;79 const item = row.item;
@@ -279,7 +280,10 @@ export function buildThreadActivityInspector( · 200 unmodified lines
279 break;280 break;
280 }281 }
281 282
282 const checkpoint = item.type === "checkpoint" ? support.checkpoint : null;283 const checkpoint =
284 item.type === "checkpoint" && row.sourceThreadId === currentThreadId
285 ? support.checkpoint
286 : null;
283 return {287 return {
284 fields,288 fields,
285 blocks,289 blocks,
apps/server/scripts/acp-mock-agent.ts+454−195
@@ -1,5 +1,6 @@
1#!/usr/bin/env node1#!/usr/bin/env node
2// @effect-diagnostics nodeBuiltinImport:off2// @effect-diagnostics nodeBuiltinImport:off
3import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";4import * as NodeFS from "node:fs";
4 5
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
@@ -17,21 +18,36 @@ const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; · 11 unmodified lines
17const emitInterleavedAssistantToolCalls =18const emitInterleavedAssistantToolCalls =
18 process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";19 process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
19const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";20const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
21const emitPostSettleMonitorFlow = process.env.T3_ACP_EMIT_POST_SETTLE_MONITOR_FLOW === "1";
22const emitInTurnTaskOutputThenLateDuplicate =
23 process.env.T3_ACP_EMIT_IN_TURN_TASKOUTPUT_THEN_LATE_DUPLICATE === "1";
24const injectedReportTriggerPath = process.env.T3_ACP_INJECTED_REPORT_TRIGGER_PATH;
20const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";25const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
26const emitElicitation = process.env.T3_ACP_EMIT_ELICITATION === "1";
27const emitUrlElicitation = process.env.T3_ACP_EMIT_URL_ELICITATION === "1";
21const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";28const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";
22const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1";
23const emitXAiPlanMdWrite = process.env.T3_ACP_EMIT_XAI_PLAN_MD_WRITE === "1";
24const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1";29const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1";
25const emitXAiRateLimitThenHang = process.env.T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG === "1";
26const emitXAiAskUserQuestionThenHang =
27 process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG === "1";
28const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1";
29const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1";
30const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1";
31const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1";30const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1";
32const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1";31const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1";
32const hangAfterPermission = process.env.T3_ACP_HANG_AFTER_PERMISSION === "1";
33const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1";33const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1";
34const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1";34const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1";
35const emitTaskBackgroundedAfterCancel =
36 process.env.T3_ACP_EMIT_TASK_BACKGROUNDED_AFTER_CANCEL === "1";
37const residualCallbackResponseLogPath = process.env.T3_ACP_RESIDUAL_CALLBACK_RESPONSE_LOG_PATH;
38const residualCallbackTriggerPath = process.env.T3_ACP_RESIDUAL_CALLBACK_TRIGGER_PATH;
39const exitAfterResidualCallbacks = process.env.T3_ACP_EXIT_AFTER_RESIDUAL_CALLBACKS === "1";
40const emitRunningCommandThenHang = process.env.T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG === "1";
41const emitRunningCommandThenHangOnFirstPrompt =
42 process.env.T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG_FIRST_PROMPT === "1";
43const emitEmptySuccessfulBash = process.env.T3_ACP_EMIT_EMPTY_SUCCESSFUL_BASH === "1";
44const emitEmptySuccessfulBashThenHang =
45 process.env.T3_ACP_EMIT_EMPTY_SUCCESSFUL_BASH_THEN_HANG === "1";
46const exitOnCancel = process.env.T3_ACP_EXIT_ON_CANCEL === "1";
47const runningCommandIgnoresTerm = process.env.T3_ACP_RUNNING_COMMAND_IGNORE_TERM === "1";
48const runningCommandPidPath = process.env.T3_ACP_RUNNING_COMMAND_PID_PATH;
49const runningCommandSeparateSession = process.env.T3_ACP_RUNNING_COMMAND_SEPARATE_SESSION === "1";
50const exitAfterRunningCommandLaunch = process.env.T3_ACP_EXIT_AFTER_RUNNING_COMMAND_LAUNCH === "1";
35const omitXAiPromptCompleteStopReason =51const omitXAiPromptCompleteStopReason =
36 process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1";52 process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1";
37const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1";53const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1";
@@ -47,8 +63,6 @@ const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; · 9 unmodified lines
47const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";63const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
48const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";64const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
49const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;65const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
50const initialGrokReasoningEffort =
51 process.env.T3_ACP_INITIAL_GROK_REASONING_EFFORT?.trim() || undefined;
52const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0");66const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0");
53const supportsSessionLifecycle = process.env.T3_ACP_SESSION_LIFECYCLE === "1";67const supportsSessionLifecycle = process.env.T3_ACP_SESSION_LIFECYCLE === "1";
54const advertisedAuthMethodId = process.env.T3_ACP_AUTH_METHOD_ID?.trim();68const advertisedAuthMethodId = process.env.T3_ACP_AUTH_METHOD_ID?.trim();
@@ -58,11 +72,6 @@ const permissionOptionIds = { · 3 unmodified lines
58 allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",72 allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",
59 rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once",73 rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once",
60};74};
61const omitAllowAlways = process.env.T3_ACP_OMIT_ALLOW_ALWAYS === "1";
62const permissionRequestCount = Math.max(
63 1,
64 Number(process.env.T3_ACP_PERMISSION_REQUEST_COUNT ?? "1") || 1,
65);
66const sessionId = "mock-session-1";75const sessionId = "mock-session-1";
67 76
68let currentModeId = "ask";77let currentModeId = "ask";
@@ -81,6 +90,11 @@ function logExit(reason: string): void { · 12 unmodified lines
81 appendFileSync(exitLogPath, `${reason}\n`, "utf8");90 appendFileSync(exitLogPath, `${reason}\n`, "utf8");
82}91}
83 92
93function logResidualCallbackResponse(kind: string): void {
94 if (!residualCallbackResponseLogPath) return;
95 NodeFS.appendFileSync(residualCallbackResponseLogPath, `${kind}\n`, "utf8");
96}
97
84process.once("SIGTERM", () => {98process.once("SIGTERM", () => {
85 logExit("SIGTERM");99 logExit("SIGTERM");
86 process.exit(0);100 process.exit(0);
@@ -254,20 +268,7 @@ function modeState(): AcpSchema.SessionModeState { · 167 unmodified lines
254// Mirrors the real Grok ACP: it advertises versioned model ids, never the CLI's own268// Mirrors the real Grok ACP: it advertises versioned model ids, never the CLI's own
255// "grok-build" product name, and it rejects unknown ids in session/set_model.269// "grok-build" product name, and it rejects unknown ids in session/set_model.
256const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [270const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [
257 {271 { modelId: "grok-build", name: "Grok Build" },
258 modelId: "grok-4.6",
259 name: "Grok 4.6",
260 _meta: {
261 totalContextTokens: 500_000,
262 supportsReasoningEffort: true,
263 reasoningEffort: initialGrokReasoningEffort ?? "high",
264 reasoningEfforts: [
265 { id: "xhigh", value: "xhigh", label: "Extra High Effort", default: false },
266 { id: "high", value: "high", label: "High Effort", default: true },
267 { id: "low", value: "low", label: "Low Effort", default: false },
268 ],
269 },
270 },
271 { modelId: "grok-mock-alt", name: "Grok Mock Alt" },272 { modelId: "grok-mock-alt", name: "Grok Mock Alt" },
272];273];
273 274
@@ -491,8 +492,54 @@ const program = Effect.gen(function* () { · 217 unmodified lines
491 );492 );
492 493
493 yield* agent.handleCancel(({ sessionId }) =>494 yield* agent.handleCancel(({ sessionId }) =>
494 Effect.sync(() => {495 Effect.gen(function* () {
495 cancelledSessions.add(String(sessionId ?? "mock-session-1"));496 const cancelledSessionId = String(sessionId ?? "mock-session-1");
497 cancelledSessions.add(cancelledSessionId);
498 if (exitOnCancel) {
499 return yield* Effect.sync(() => process.exit(0));
500 }
501 if (emitLateUpdateAfterCancel) {
502 yield* Effect.sleep("50 millis");
503 yield* Effect.sync(() => {
504 writeJsonRpcNotification("session/update", {
505 sessionId: cancelledSessionId,
506 update: {
507 sessionUpdate: "agent_message_chunk",
508 content: { type: "text", text: "late after cancel" },
509 },
510 });
511 });
512 }
513 if (emitTaskBackgroundedAfterCancel) {
514 // Grok cancel-as-detach: the foreground command is re-run as a
515 // background task that later completes on its own.
516 yield* Effect.sync(() => {
517 writeJsonRpcNotification("_x.ai/task_backgrounded", {
518 sessionId: cancelledSessionId,
519 update: {
520 sessionUpdate: "task_backgrounded",
521 tool_call_id: "task-bg-1",
522 task_id: "task-bg-1",
523 command: "sleep 30",
524 },
525 });
526 });
527 yield* Effect.sleep("1200 millis")
528 .pipe(
529 Effect.andThen(
530 Effect.sync(() => {
531 writeJsonRpcNotification("_x.ai/task_completed", {
532 sessionId: cancelledSessionId,
533 update: {
534 sessionUpdate: "task_completed",
535 task_snapshot: { task_id: "task-bg-1", command: "sleep 30" },
536 },
537 });
538 }),
539 ),
540 )
541 .pipe(Effect.forkDetach);
542 }
496 }),543 }),
497 );544 );
498 545
@@ -501,6 +548,80 @@ const program = Effect.gen(function* () { · 2 unmodified lines
501 const requestedSessionId = String(request.sessionId ?? sessionId);548 const requestedSessionId = String(request.sessionId ?? sessionId);
502 promptCount += 1;549 promptCount += 1;
503 550
551 if (residualCallbackTriggerPath !== undefined) {
552 yield* Effect.gen(function* () {
553 while (!(yield* Effect.sync(() => NodeFS.existsSync(residualCallbackTriggerPath)))) {
554 yield* Effect.sleep("20 millis");
555 }
556 yield* Effect.sync(() => {
557 writeJsonRpcNotification("session/update", {
558 sessionId: requestedSessionId,
559 update: {
560 sessionUpdate: "agent_message_chunk",
561 content: { type: "text", text: "residual assistant callback" },
562 },
563 });
564 writeJsonRpcNotification("session/update", {
565 sessionId: requestedSessionId,
566 update: {
567 sessionUpdate: "tool_call",
568 toolCallId: "residual-tool-call",
569 title: "Residual tool callback",
570 kind: "other",
571 status: "pending",
572 rawInput: {},
573 },
574 });
575 writeJsonRpcNotification("session/update", {
576 sessionId: requestedSessionId,
577 update: {
578 sessionUpdate: "plan",
579 entries: [
580 { content: "Residual plan callback", priority: "high", status: "pending" },
581 ],
582 },
583 });
584 });
585 yield* agent.client
586 .requestPermission({
587 sessionId: requestedSessionId,
588 toolCall: {
589 toolCallId: "residual-permission",
590 title: "Residual permission callback",
591 },
592 options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
593 })
594 .pipe(
595 Effect.exit,
596 Effect.tap(() => Effect.sync(() => logResidualCallbackResponse("permission"))),
597 Effect.ignore,
598 Effect.forkDetach,
599 );
600 yield* agent.client
601 .elicit({
602 sessionId: requestedSessionId,
603 message: "Residual elicitation callback",
604 mode: "form",
605 requestedSchema: {
606 type: "object",
607 properties: {
608 approved: { type: "boolean", title: "Approved" },
609 },
610 },
611 })
612 .pipe(
613 Effect.exit,
614 Effect.tap(() => Effect.sync(() => logResidualCallbackResponse("elicitation"))),
615 Effect.ignore,
616 Effect.forkDetach,
617 );
618 if (exitAfterResidualCallbacks) {
619 yield* Effect.sleep("100 millis");
620 return yield* Effect.sync(() => process.exit(0));
621 }
622 }).pipe(Effect.forkDetach);
623 }
624
504 if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) {625 if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) {
505 yield* Effect.sleep(`${promptDelayMs} millis`);626 yield* Effect.sleep(`${promptDelayMs} millis`);
506 }627 }
@@ -562,70 +683,124 @@ const program = Effect.gen(function* () { · 55 unmodified lines
562 return yield* Effect.never;683 return yield* Effect.never;
563 }684 }
564 685
565 if (hangPromptForever || (hangFirstPromptForever && promptCount === 1)) {686 if (
687 hangPromptForever ||
688 (hangFirstPromptForever && promptCount === 1) ||
689 (emitEmptySuccessfulBashThenHang && promptCount === 2)
690 ) {
566 return yield* Effect.never;691 return yield* Effect.never;
567 }692 }
568 693
569 if (emitXAiRateLimitThenHang) {694 if (
570 writeJsonRpcNotification("_x.ai/session/prompt_complete", {695 emitRunningCommandThenHang ||
571 sessionId: requestedSessionId,696 (emitRunningCommandThenHangOnFirstPrompt && promptCount === 1)
572 promptId: promptIdFromRequestMeta(request) ?? "mock-xai-rate-limit-prompt-1",697 ) {
573 stopReason: "rate_limit",698 const toolCallId = "tool-call-running-1";
574 agentResult: null,699 if (runningCommandPidPath !== undefined) {
575 });700 const command = runningCommandIgnoresTerm
576 return yield* Effect.never;701 ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s\\n" "$$" "$child" > "$1"; wait "$child"'
577 }702 : 'sleep 120 & child=$!; printf "%s %s\\n" "$$" "$child" > "$1"; wait "$child"';
578 703 if (runningCommandSeparateSession) {
579 if (emitContentThenHang) {704 const launcher = [
705 'const { spawn } = require("node:child_process");',
706 "const child = spawn(process.argv[1], process.argv.slice(2), { stdio: 'ignore' });",
707 "child.once('exit', (code, signal) => process.exitCode = code ?? (signal ? 1 : 0));",
708 ].join(" ");
709 const detachedCommand = runningCommandIgnoresTerm
710 ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s %s\\n" "$PPID" "$$" "$child" > "$1"; wait "$child"'
711 : 'sleep 120 & child=$!; printf "%s %s %s\\n" "$PPID" "$$" "$child" > "$1"; wait "$child"';
712 // Nested bash publishes "$PPID $$ $child" once it starts. Do not
713 // write the launcher PID alone here: that races with bash and can
714 // clobber the triple that interrupt tests wait for.
715 const detachedLauncher = NodeChildProcess.spawn(
716 process.execPath,
717 ["-e", launcher, "bash", "-c", detachedCommand, "bash", runningCommandPidPath],
718 { detached: true, stdio: "ignore" },
719 );
720 detachedLauncher.unref();
721 } else {
722 NodeChildProcess.spawn("bash", ["-c", command, "bash", runningCommandPidPath], {
723 stdio: "ignore",
724 });
725 }
726 }
580 yield* agent.client.sessionUpdate({727 yield* agent.client.sessionUpdate({
581 sessionId: requestedSessionId,728 sessionId: requestedSessionId,
582 update: {729 update: {
583 sessionUpdate: "agent_message_chunk",730 sessionUpdate: "tool_call",
584 content: { type: "text", text: "partial before stall" },731 toolCallId,
732 title: "Terminal",
733 kind: "execute",
734 status: "pending",
735 rawInput: {
736 command: ["sleep", "120"],
737 },
585 },738 },
586 });739 });
587 return yield* Effect.never;
588 }
589
590 if (emitPlanThenHang) {
591 yield* agent.client.sessionUpdate({740 yield* agent.client.sessionUpdate({
592 sessionId: requestedSessionId,741 sessionId: requestedSessionId,
593 update: {742 update: {
594 sessionUpdate: "plan",743 sessionUpdate: "tool_call_update",
595 entries: [744 toolCallId,
596 {745 title: "Terminal",
597 content: "Wait for more ACP progress",746 kind: "execute",
598 priority: "high",747 status: "in_progress",
599 status: "in_progress",748 rawInput: {
600 },749 command: ["sleep", "120"],
601 ],750 },
751 // Grok-like mid-stream Bash re-report: exit_code 0 while still running.
752 rawOutput: { type: "Bash", exit_code: 0 },
602 },753 },
603 });754 });
604 return yield* Effect.never;
605 }
606
607 if (emitActiveToolThenHang) {
608 const toolCallId = "tool-call-long-running-1";
609 yield* agent.client.sessionUpdate({755 yield* agent.client.sessionUpdate({
610 sessionId: requestedSessionId,756 sessionId: requestedSessionId,
611 update: {757 update: {
612 sessionUpdate: "tool_call",758 sessionUpdate: "tool_call",
613 toolCallId,759 toolCallId: "tool-call-output-1",
614 title: "Long-running tool",760 title: "get_command_or_subagent_output",
615 kind: "execute",761 kind: "other",
616 status: "pending",762 status: "pending",
617 rawInput: { command: ["long-running-tool"] },763 rawInput: { task_id: "task-running-1" },
618 },764 },
619 });765 });
620 yield* agent.client.sessionUpdate({766 yield* agent.client.sessionUpdate({
621 sessionId: requestedSessionId,767 sessionId: requestedSessionId,
622 update: {768 update: {
623 sessionUpdate: "tool_call_update",769 sessionUpdate: "tool_call_update",
624 toolCallId,770 toolCallId: "tool-call-output-1",
625 status: "in_progress",771 status: "in_progress",
772 rawOutput: { task_id: "task-running-1", status: "running" },
626 },773 },
627 });774 });
628 return yield* Effect.never;775 if (exitAfterRunningCommandLaunch) {
776 yield* Effect.sleep("100 millis");
777 return yield* Effect.sync(() => process.exit(0));
778 }
779 // Stay open until session/cancel so interrupt tests can observe a running tool.
780 while (!cancelledSessions.has(requestedSessionId)) {
781 yield* Effect.sleep("25 millis");
782 }
783 cancelledSessions.delete(requestedSessionId);
784 return { stopReason: "cancelled" };
785 }
786
787 if (emitEmptySuccessfulBash || (emitEmptySuccessfulBashThenHang && promptCount === 1)) {
788 const update = {
789 sessionId: requestedSessionId,
790 update: {
791 sessionUpdate: "tool_call_update",
792 toolCallId: "tool-call-empty-success-1",
793 title: "Terminal",
794 kind: "execute",
795 status: "completed",
796 rawInput: { command: "true" },
797 rawOutput: { type: "Bash", exit_code: 0 },
798 },
799 } as const;
800 yield* agent.client.sessionUpdate(update);
801 yield* Effect.sleep("25 millis");
802 yield* agent.client.sessionUpdate(update);
803 return { stopReason: "end_turn" };
629 }804 }
630 805
631 if (emitXAiPromptCompleteThenHang) {806 if (emitXAiPromptCompleteThenHang) {
@@ -736,6 +911,32 @@ const program = Effect.gen(function* () { · 104 unmodified lines
736 return { stopReason: "end_turn" };911 return { stopReason: "end_turn" };
737 }912 }
738 913
914 if (emitElicitation) {
915 yield* agent.client.elicit({
916 sessionId: requestedSessionId,
917 message: "Approve this request?",
918 mode: "form",
919 requestedSchema: {
920 type: "object",
921 properties: {
922 approved: { type: "boolean", title: "Approved" },
923 },
924 },
925 });
926 return { stopReason: "end_turn" };
927 }
928
929 if (emitUrlElicitation) {
930 yield* agent.client.elicit({
931 sessionId: requestedSessionId,
932 message: "Open authentication page",
933 mode: "url",
934 url: "https://example.com/auth",
935 elicitationId: "url-elicitation-1",
936 });
937 return { stopReason: "end_turn" };
938 }
939
739 if (emitToolCalls) {940 if (emitToolCalls) {
740 const toolCallId = "tool-call-1";941 const toolCallId = "tool-call-1";
741 942
@@ -762,57 +963,40 @@ const program = Effect.gen(function* () { · 20 unmodified lines
762 },963 },
763 });964 });
764 965
765 const permissionOptions: Array<AcpSchema.PermissionOption> = [966 const permission = yield* agent.client.requestPermission({
766 { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" },967 sessionId: requestedSessionId,
767 ...(omitAllowAlways968 toolCall: {
768 ? []969 toolCallId,
769 : [970 title: "`cat server/package.json`",
770 {971 kind: "execute",
771 optionId: permissionOptionIds.allowAlways,972 status: "pending",
772 name: "Allow always",973 content: [
773 kind: "allow_always" as const,974 {
975 type: "content",
976 content: {
977 type: "text",
978 text: "Not in allowlist: cat server/package.json",
774 },979 },
775 ]),
776 { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" },
777 ];
778
779 let cancelled = cancelledSessions.delete(requestedSessionId);
780 for (let index = 0; index < permissionRequestCount; index++) {
781 const command =
782 index > 0
783 ? (process.env.T3_ACP_SECOND_PERMISSION_COMMAND ?? "cat server/package.json")
784 : "cat server/package.json";
785 const permission = yield* agent.client.requestPermission({
786 sessionId: requestedSessionId,
787 toolCall: {
788 toolCallId: index === 0 ? toolCallId : `${toolCallId}-${index + 1}`,
789 title: process.env.T3_ACP_PERMISSION_TITLE ?? `\`${command}\``,
790 kind: "execute",
791 status: "pending",
792 rawInput: {
793 variant: "Bash",
794 command,
795 description: index === 0 ? "Read package metadata" : "Read it again",
796 },980 },
797 content: [981 ],
798 {982 },
799 type: "content",983 options: [
800 content: {984 { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" },
801 type: "text",985 {
802 text: `Not in allowlist: ${command}`,986 optionId: permissionOptionIds.allowAlways,
803 },987 name: "Allow always",
804 },988 kind: "allow_always",
805 ],
806 },989 },
807 options: permissionOptions,990 { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" },
808 });991 ],
809 cancelled =992 });
810 cancelled ||993
811 cancelledSessions.delete(requestedSessionId) ||994 const cancelled =
812 permission.outcome.outcome === "cancelled";995 cancelledSessions.delete(requestedSessionId) ||
813 if (cancelled) {996 permission.outcome.outcome === "cancelled";
814 break;997
815 }998 if (hangAfterPermission) {
999 return yield* Effect.never;
816 }1000 }
817 1001
818 yield* agent.client.sessionUpdate({1002 yield* agent.client.sessionUpdate({
@@ -881,6 +1065,159 @@ const program = Effect.gen(function* () { · 62 unmodified lines
881 return { stopReason: "end_turn" };1065 return { stopReason: "end_turn" };
882 }1066 }
883 1067
1068 // In-turn monitor + TaskOutput hydrate, then a late post-finalize
1069 // duplicate terminal TaskOutput for the same task. Exercises the
1070 // already-handled short-circuit: must not pin hasPendingBackgroundWork.
1071 if (emitInTurnTaskOutputThenLateDuplicate) {
1072 const monitorToolCallId = "tool-call-monitor-1";
1073 yield* agent.client.sessionUpdate({
1074 sessionId: requestedSessionId,
1075 update: {
1076 sessionUpdate: "tool_call",
1077 toolCallId: monitorToolCallId,
1078 title: "Monitor: mock background task",
1079 kind: "execute",
1080 status: "pending",
1081 rawInput: {},
1082 },
1083 });
1084 yield* agent.client.sessionUpdate({
1085 sessionId: requestedSessionId,
1086 update: {
1087 sessionUpdate: "tool_call_update",
1088 toolCallId: monitorToolCallId,
1089 status: "in_progress",
1090 },
1091 });
1092 yield* agent.client.sessionUpdate({
1093 sessionId: requestedSessionId,
1094 update: {
1095 sessionUpdate: "tool_call",
1096 toolCallId: "tool-call-fetch-1",
1097 title: "get_command_or_subagent_output",
1098 kind: "other",
1099 status: "pending",
1100 rawInput: { task_id: "task-monitor-1" },
1101 },
1102 });
1103 yield* agent.client.sessionUpdate({
1104 sessionId: requestedSessionId,
1105 update: {
1106 sessionUpdate: "tool_call_update",
1107 toolCallId: "tool-call-fetch-1",
1108 status: "completed",
1109 rawOutput: { output: "MONITOR_LISTING_TOKEN" },
1110 },
1111 });
1112 yield* agent.client.sessionUpdate({
1113 sessionId: requestedSessionId,
1114 update: {
1115 sessionUpdate: "agent_message_chunk",
1116 content: { type: "text", text: "Monitor listing ready in-turn." },
1117 },
1118 });
1119 // After deferred finalize (~2s) clears activeTurn, re-emit a terminal
1120 // TaskOutput for the same task so bufferPostSettleWake sees
1121 // alreadyHandledToolUpdate with a non-empty wake path.
1122 yield* Effect.gen(function* () {
1123 yield* Effect.sleep("2500 millis");
1124 yield* Effect.sync(() => {
1125 writeJsonRpcNotification("session/update", {
1126 sessionId: requestedSessionId,
1127 update: {
1128 sessionUpdate: "tool_call_update",
1129 toolCallId: "tool-call-fetch-1",
1130 title: "get_command_or_subagent_output",
1131 kind: "other",
1132 status: "completed",
1133 rawOutput: { output: "MONITOR_LISTING_TOKEN_LATE" },
1134 },
1135 });
1136 });
1137 }).pipe(Effect.forkDetach);
1138 return { stopReason: "end_turn" };
1139 }
1140
1141 if (emitPostSettleMonitorFlow) {
1142 const monitorToolCallId = "tool-call-monitor-1";
1143
1144 yield* agent.client.sessionUpdate({
1145 sessionId: requestedSessionId,
1146 update: {
1147 sessionUpdate: "tool_call",
1148 toolCallId: monitorToolCallId,
1149 title: "Monitor: mock background task",
1150 kind: "execute",
1151 status: "pending",
1152 rawInput: {},
1153 },
1154 });
1155
1156 yield* agent.client.sessionUpdate({
1157 sessionId: requestedSessionId,
1158 update: {
1159 sessionUpdate: "tool_call_update",
1160 toolCallId: monitorToolCallId,
1161 status: "in_progress",
1162 },
1163 });
1164
1165 // After the prompt settles, replay the CLI-injected monitor-event
1166 // turn: end notice, TaskOutput hydration, then (once the trigger
1167 // file exists) the report chunk. Detached fiber on the real clock;
1168 // it outlives the prompt handler.
1169 yield* Effect.gen(function* () {
1170 yield* Effect.sleep("150 millis");
1171 yield* Effect.sync(() => {
1172 writeJsonRpcNotification("session/update", {
1173 sessionId: requestedSessionId,
1174 update: {
1175 sessionUpdate: "user_message_chunk",
1176 content: {
1177 type: "text",
1178 text: 'Monitor "task-monitor-1" ended: [monitor ended: exit 0]',
1179 },
1180 },
1181 });
1182 writeJsonRpcNotification("session/update", {
1183 sessionId: requestedSessionId,
1184 update: {
1185 sessionUpdate: "tool_call",
1186 toolCallId: "tool-call-fetch-1",
1187 title: "get_command_or_subagent_output",
1188 kind: "other",
1189 status: "pending",
1190 rawInput: { task_id: "task-monitor-1" },
1191 },
1192 });
1193 writeJsonRpcNotification("session/update", {
1194 sessionId: requestedSessionId,
1195 update: {
1196 sessionUpdate: "tool_call_update",
1197 toolCallId: "tool-call-fetch-1",
1198 status: "completed",
1199 rawOutput: { output: "MONITOR_LISTING_TOKEN" },
1200 },
1201 });
1202 });
1203 if (injectedReportTriggerPath === undefined) return;
1204 while (!(yield* Effect.sync(() => NodeFS.existsSync(injectedReportTriggerPath)))) {
1205 yield* Effect.sleep("20 millis");
1206 }
1207 yield* Effect.sync(() => {
1208 writeJsonRpcNotification("session/update", {
1209 sessionId: requestedSessionId,
1210 update: {
1211 sessionUpdate: "agent_message_chunk",
1212 content: { type: "text", text: "Monitor finished. MONITOR_REPORT_TOKEN" },
1213 },
1214 });
1215 });
1216 }).pipe(Effect.forkDetach);
1217
1218 return { stopReason: "end_turn" };
1219 }
1220
884 if (emitAskQuestion) {1221 if (emitAskQuestion) {
885 yield* agent.client.extRequest("cursor/ask_question", {1222 yield* agent.client.extRequest("cursor/ask_question", {
886 toolCallId: "ask-question-tool-call-1",1223 toolCallId: "ask-question-tool-call-1",
@@ -900,7 +1237,7 @@ const program = Effect.gen(function* () { · 13 unmodified lines
900 return { stopReason: "end_turn" };1237 return { stopReason: "end_turn" };
901 }1238 }
902 1239
903 if (emitXAiAskUserQuestion || emitXAiAskUserQuestionThenHang) {1240 if (emitXAiAskUserQuestion) {
904 const result = yield* agent.client.extRequest("_x.ai/ask_user_question", {1241 const result = yield* agent.client.extRequest("_x.ai/ask_user_question", {
905 method: "x.ai/ask_user_question",1242 method: "x.ai/ask_user_question",
906 params: {1243 params: {
@@ -934,84 +1271,6 @@ const program = Effect.gen(function* () { · 27 unmodified lines
934 throw new Error("Expected accepted _x.ai/ask_user_question response answers.");1271 throw new Error("Expected accepted _x.ai/ask_user_question response answers.");
935 }1272 }
936 1273
937 if (emitXAiAskUserQuestionThenHang) {
938 return yield* Effect.never;
939 }
940
941 return { stopReason: "end_turn" };
942 }
943
944 if (emitXAiPlanMdWrite) {
945 // Match Grok's real session layout so isGrokPlanMarkdownPath accepts it.
946 const planRoot = process.env.T3_ACP_PLAN_ROOT ?? "/tmp/mock-home/.grok";
947 const planPath = `${planRoot}/sessions/${requestedSessionId}/plan.md`;
948 const planBody = "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it\n";
949 // enter_plan_mode first so the adapter arms planModeActive.
950 yield* agent.client.sessionUpdate({
951 sessionId: requestedSessionId,
952 update: {
953 sessionUpdate: "tool_call",
954 toolCallId: "enter-plan-mode-1",
955 title: "enter_plan_mode",
956 kind: "other",
957 status: "completed",
958 rawInput: { variant: "EnterPlanMode" },
959 },
960 });
961 yield* agent.client.sessionUpdate({
962 sessionId: requestedSessionId,
963 update: {
964 sessionUpdate: "tool_call",
965 toolCallId: "plan-md-write-1",
966 title: "write",
967 kind: "edit",
968 status: "pending",
969 rawInput: { file_path: planPath, content: planBody },
970 },
971 });
972 yield* agent.client.sessionUpdate({
973 sessionId: requestedSessionId,
974 update: {
975 sessionUpdate: "tool_call_update",
976 toolCallId: "plan-md-write-1",
977 kind: "edit",
978 status: "completed",
979 title: `Write \`${planPath}\``,
980 rawInput: { file_path: planPath, content: planBody },
981 content: [
982 {
983 type: "diff",
984 path: planPath,
985 oldText: "",
986 newText: planBody,
987 },
988 ],
989 },
990 });
991 return { stopReason: "end_turn" };
992 }
993
994 if (emitXAiExitPlanMode) {
995 const result = yield* agent.client.extRequest("_x.ai/exit_plan_mode", {
996 method: "x.ai/exit_plan_mode",
997 params: {
998 sessionId: requestedSessionId,
999 toolCallId: "exit-plan-mode-tool-call-1",
1000 planContent: "# Exit plan\n\n- Step one\n- Step two\n",
1001 },
1002 });
1003 if (typeof result !== "object" || result === null || !("outcome" in result)) {
1004 throw new Error("Expected _x.ai/exit_plan_mode response outcome.");
1005 }
1006 if (
1007 result.outcome !== "abandoned" &&
1008 result.outcome !== "approved" &&
1009 result.outcome !== "request_changes"
1010 ) {
1011 throw new Error(
1012 `Expected exit_plan_mode outcome abandoned|approved|request_changes, got ${String(result.outcome)}`,
1013 );
1014 }
1015 return { stopReason: "end_turn" };1274 return { stopReason: "end_turn" };
1016 }1275 }
1017 1276
apps/server/scripts/acp-thread-spawn-helper.c+37−0
@@ -0,0 +1,37 @@
1#define _GNU_SOURCE
2
3#include <pthread.h>
4#include <stdio.h>
5#include <stdlib.h>
6#include <sys/syscall.h>
7#include <sys/types.h>
8#include <sys/wait.h>
9#include <unistd.h>
10
11static const char *pid_path;
12
13static void *spawn_child(void *unused) {
14 (void)unused;
15 pid_t child = fork();
16 if (child == 0) {
17 execlp("sleep", "sleep", "120", NULL);
18 _exit(127);
19 }
20 if (child < 0) return NULL;
21 FILE *file = fopen(pid_path, "w");
22 if (file != NULL) {
23 fprintf(file, "%ld %d\n", syscall(SYS_gettid), child);
24 fclose(file);
25 }
26 waitpid(child, NULL, 0);
27 return NULL;
28}
29
30int main(int argc, char **argv) {
31 if (argc != 2) return 2;
32 pid_path = argv[1];
33 pthread_t worker;
34 if (pthread_create(&worker, NULL, spawn_child, NULL) != 0) return 3;
35 if (pthread_join(worker, NULL) != 0) return 4;
36 return 0;
37}
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+6081−73
@@ -1,3 +1,7 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";
4
1import * as NodeServices from "@effect/platform-node/NodeServices";5import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";6import { assert, describe, it } from "@effect/vitest";
3import {7import {
@@ -16,31 +20,58 @@ import { · 12 unmodified lines
16 type OrchestrationV2ProviderThread,20 type OrchestrationV2ProviderThread,
17} from "@t3tools/contracts";21} from "@t3tools/contracts";
18import * as DateTime from "effect/DateTime";22import * as DateTime from "effect/DateTime";
23import * as Crypto from "effect/Crypto";
24import * as Deferred from "effect/Deferred";
25import type * as Duration from "effect/Duration";
26import * as Cause from "effect/Cause";
27import * as Clock from "effect/Clock";
19import * as Effect from "effect/Effect";28import * as Effect from "effect/Effect";
29import * as Exit from "effect/Exit";
20import * as FileSystem from "effect/FileSystem";30import * as FileSystem from "effect/FileSystem";
21import * as Fiber from "effect/Fiber";31import * as Fiber from "effect/Fiber";
22import * as Layer from "effect/Layer";32import * as Layer from "effect/Layer";
23import * as Option from "effect/Option";33import * as Option from "effect/Option";
24import * as Path from "effect/Path";34import * as Path from "effect/Path";
35import * as PlatformError from "effect/PlatformError";
25import * as Queue from "effect/Queue";36import * as Queue from "effect/Queue";
26import * as Schema from "effect/Schema";37import * as Schema from "effect/Schema";
38import * as Sink from "effect/Sink";
27import * as Stream from "effect/Stream";39import * as Stream from "effect/Stream";
40import * as Scope from "effect/Scope";
28import * as TestClock from "effect/testing/TestClock";41import * as TestClock from "effect/testing/TestClock";
29import { ChildProcessSpawner } from "effect/unstable/process";42import { ChildProcessSpawner } from "effect/unstable/process";
43import * as EffectAcpErrors from "effect-acp/errors";
30import type * as EffectAcpProtocol from "effect-acp/protocol";44import type * as EffectAcpProtocol from "effect-acp/protocol";
45import type * as EffectAcpSchema from "effect-acp/schema";
31 46
32import { ServerConfig } from "../../config.ts";47import { ServerConfig } from "../../config.ts";
33import * as McpProviderSession from "../../mcp/McpProviderSession.ts";48import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
34import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";49import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
50import {
51 normalizeXAiAcpToolCallState,
52 registerXAiBackgroundTaskTracking,
53} from "../../provider/acp/XAiAcpExtension.ts";
35import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts";54import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts";
36import {55import {
37 ProviderAdapterV2RuntimePolicy,56 ProviderAdapterV2RuntimePolicy,
57 type ProviderAdapterV2Event,
38 type ProviderAdapterV2TurnInput,58 type ProviderAdapterV2TurnInput,
39} from "../ProviderAdapter.ts";59} from "../ProviderAdapter.ts";
60import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts";
40import {61import {
41 AcpProviderCapabilitiesV2,62 AcpProviderCapabilitiesV2,
63 acpCanonicalJson,
64 acpClaimNativeTransportRequest,
65 acpNativeUserInputRequestMatches,
66 acpPostSettleContinuationOfferEvidence,
67 acpPostSettleMonitorPromptShouldSuppress,
68 acpPostSettleWakeEvidence,
69 acpPostSettleWakeShouldBuffer,
70 acpProjectedCommandExitCode,
42 makeAcpAdapterV2,71 makeAcpAdapterV2,
72 type AcpAdapterV2ExtensionContext,
43 type AcpAdapterV2Flavor,73 type AcpAdapterV2Flavor,
74 type AcpAdapterV2RuntimeInput,
44} from "./AcpAdapterV2.ts";75} from "./AcpAdapterV2.ts";
45 76
46const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {77const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
@@ -51,14 +82,202 @@ const testLayer = Layer.mergeAll(NodeServices.layer, idAllocatorLayer, serverCon · 4 unmodified lines
51const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test");82const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test");
52const decodeUnknownJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);83const decodeUnknownJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
53 84
85describe("acpProjectedCommandExitCode", () => {
86 const successOutput = { type: "Bash", exit_code: 0 };
87 const failedOutput = { type: "Bash", exit_code: 1 };
88
89 it("omits exit codes for non-terminal and interrupted tool statuses", () => {
90 assert.equal(acpProjectedCommandExitCode("pending", successOutput), undefined);
91 assert.equal(acpProjectedCommandExitCode("running", successOutput), undefined);
92 assert.equal(acpProjectedCommandExitCode("interrupted", successOutput), undefined);
93 });
94
95 it("projects real exit codes only for completed and failed tools", () => {
96 assert.equal(acpProjectedCommandExitCode("completed", successOutput), 0);
97 assert.equal(acpProjectedCommandExitCode("completed", failedOutput), 1);
98 assert.equal(acpProjectedCommandExitCode("failed", failedOutput), 1);
99 assert.equal(acpProjectedCommandExitCode("completed", {}), undefined);
100 });
101});
102
103const taskkillPlatformError = (method: string) =>
104 PlatformError.systemError({ _tag: "Unknown", module: "taskkill-test", method });
105
106function makeTaskkillSpawner(input: {
107 readonly exitCode?: number;
108 readonly exitFailure?: boolean;
109 readonly output?: string;
110 readonly outputFailure?: boolean;
111 readonly spawnFailure?: boolean;
112 readonly commands?: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }>;
113}) {
114 return ChildProcessSpawner.make((command) => {
115 const value = command as unknown as {
116 readonly command: string;
117 readonly args: ReadonlyArray<string>;
118 };
119 input.commands?.push({ command: value.command, args: value.args });
120 if (input.spawnFailure === true) return Effect.fail(taskkillPlatformError("spawn"));
121 const output = input.outputFailure
122 ? Stream.fail(taskkillPlatformError("output"))
123 : Stream.encodeText(Stream.make(input.output ?? ""));
124 return Effect.succeed(
125 ChildProcessSpawner.makeHandle({
126 pid: ChildProcessSpawner.ProcessId(1234),
127 exitCode: input.exitFailure
128 ? Effect.fail(taskkillPlatformError("exitCode"))
129 : Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)),
130 isRunning: Effect.succeed(false),
131 kill: () => Effect.void,
132 unref: Effect.succeed(Effect.void),
133 stdin: Sink.drain,
134 stdout: Stream.empty,
135 stderr: Stream.empty,
136 all: output,
137 getInputFd: () => Sink.drain,
138 getOutputFd: () => Stream.empty,
139 }),
140 );
141 });
142}
143
144function processExists(pid: number): boolean {
145 try {
146 process.kill(pid, 0);
147 return true;
148 } catch {
149 return false;
150 }
151}
152
153const waitForProcesses = (pids: ReadonlyArray<number>) =>
154 Effect.gen(function* () {
155 while (!pids.every(processExists)) {
156 yield* Effect.sleep("10 millis");
157 }
158 }).pipe(Effect.timeoutOption("2 seconds"));
159
160const waitForProcessesToExit = (pids: ReadonlyArray<number>) =>
161 Effect.gen(function* () {
162 while (pids.some(processExists)) {
163 yield* Effect.sleep("10 millis");
164 }
165 }).pipe(Effect.timeoutOption("2 seconds"));
166
167function linuxProcessStart(pid: number): string | undefined {
168 try {
169 const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8");
170 const commandEnd = stat.lastIndexOf(")");
171 return commandEnd < 0
172 ? undefined
173 : stat
174 .slice(commandEnd + 2)
175 .trim()
176 .split(/\s+/)[19];
177 } catch {
178 return undefined;
179 }
180}
181
182function cleanupPublishedDetachedFixture(path: string): void {
183 let published: Array<number>;
184 try {
185 published = NodeFS.readFileSync(path, "utf8")
186 .trim()
187 .split(/\s+/)
188 .map(Number)
189 .filter((pid) => Number.isSafeInteger(pid) && pid > 1);
190 } catch {
191 return;
192 }
193 const roots = published.filter((pid) => {
194 try {
195 return NodeFS.readFileSync(`/proc/${pid}/cmdline`, "utf8").includes(path);
196 } catch {
197 return false;
198 }
199 });
200 const owned = new Map<number, string>();
201 const pending = [...roots];
202 while (pending.length > 0) {
203 const pid = pending.shift();
204 if (pid === undefined || owned.has(pid)) continue;
205 const start = linuxProcessStart(pid);
206 if (start === undefined) continue;
207 owned.set(pid, start);
208 try {
209 pending.push(
210 ...NodeFS.readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8")
211 .trim()
212 .split(/\s+/)
213 .filter(Boolean)
214 .map(Number),
215 );
216 } catch {
217 // The process already exited.
218 }
219 }
220 for (const [pid, start] of [...owned.entries()].toReversed()) {
221 if (linuxProcessStart(pid) !== start) continue;
222 try {
223 process.kill(pid, "SIGKILL");
224 } catch {
225 // The exact fixture process already exited.
226 }
227 }
228}
229
230const waitForPublishedProcessIds = (
231 fileSystem: FileSystem.FileSystem,
232 path: string,
233 count: number,
234) =>
235 Effect.gen(function* () {
236 while (true) {
237 const ids = (yield* fileSystem.readFileString(path))
238 .trim()
239 .split(/\s+/)
240 .filter(Boolean)
241 .map(Number);
242 if (ids.length === count && ids.every((pid) => Number.isSafeInteger(pid) && pid > 1)) {
243 return ids;
244 }
245 yield* Effect.sleep("10 millis");
246 }
247 }).pipe(Effect.timeoutOption("2 seconds"));
248
54function makeMockRuntime(input: {249function makeMockRuntime(input: {
55 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];250 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
56 readonly mockAgentPath: string;251 readonly mockAgentPath: string;
57 readonly environment?: Readonly<Record<string, string>>;252 readonly environment?:
253 | Readonly<Record<string, string>>
254 | ((runtimeOrdinal: number) => Readonly<Record<string, string>>);
58 readonly protocolEvents?: Queue.Queue<EffectAcpProtocol.AcpProtocolLogEvent>;255 readonly protocolEvents?: Queue.Queue<EffectAcpProtocol.AcpProtocolLogEvent>;
256 readonly ownDescendantProcessGroups?: boolean;
257 readonly ownDetachedProcessGroup?: boolean;
258 readonly processGroupPlatform?: NodeJS.Platform;
259 readonly processGroupTerminationGrace?: Duration.Input;
260 readonly linuxCgroupController?: AcpSessionRuntime.AcpSessionRuntimeOptions["linuxCgroupController"];
261 readonly posixProcessTreeController?: AcpSessionRuntime.AcpSessionRuntimeOptions["posixProcessTreeController"];
262 readonly windowsProcessTreeTerminator?: AcpSessionRuntime.AcpSessionRuntimeOptions["windowsProcessTreeTerminator"];
263 readonly wrapCancel?: (
264 cancel: AcpSessionRuntime.AcpSessionRuntime["Service"]["cancel"],
265 ) => AcpSessionRuntime.AcpSessionRuntime["Service"]["cancel"];
266 readonly wrapOutgoingResponse?: (
267 onOutgoingResponse: NonNullable<AcpAdapterV2RuntimeInput["onOutgoingResponse"]>,
268 ) => NonNullable<AcpAdapterV2RuntimeInput["onOutgoingResponse"]>;
269 readonly wrapIncomingRequest?: (
270 onIncomingRequest: NonNullable<AcpAdapterV2RuntimeInput["onIncomingRequest"]>,
271 ) => NonNullable<AcpAdapterV2RuntimeInput["onIncomingRequest"]>;
272 readonly wrapRuntime?: (
273 runtime: AcpSessionRuntime.AcpSessionRuntime["Service"],
274 runtimeOrdinal: number,
275 ) => AcpSessionRuntime.AcpSessionRuntime["Service"];
59}): AcpAdapterV2Flavor["makeRuntime"] {276}): AcpAdapterV2Flavor["makeRuntime"] {
277 let runtimeOrdinal = 0;
60 return (runtimeInput) =>278 return (runtimeInput) =>
61 Effect.gen(function* () {279 Effect.gen(function* () {
280 runtimeOrdinal += 1;
62 const protocolEvents = input.protocolEvents;281 const protocolEvents = input.protocolEvents;
63 const protocolLogging =282 const protocolLogging =
64 protocolEvents === undefined283 protocolEvents === undefined
@@ -74,23 +293,66 @@ function makeMockRuntime(input: { · 9 unmodified lines
74 const context = yield* Layer.build(293 const context = yield* Layer.build(
75 AcpSessionRuntime.layer({294 AcpSessionRuntime.layer({
76 ...runtimeInput,295 ...runtimeInput,
296 ...(input.ownDetachedProcessGroup === undefined
297 ? {}
298 : { ownDetachedProcessGroup: input.ownDetachedProcessGroup }),
299 ...(input.ownDescendantProcessGroups === undefined
300 ? {}
301 : { ownDescendantProcessGroups: input.ownDescendantProcessGroups }),
302 ...(input.ownDetachedProcessGroup === true
303 ? { processGroupPlatform: input.processGroupPlatform ?? "linux" }
304 : {}),
305 ...(input.processGroupTerminationGrace === undefined
306 ? {}
307 : { processGroupTerminationGrace: input.processGroupTerminationGrace }),
308 ...(input.linuxCgroupController === undefined
309 ? {}
310 : { linuxCgroupController: input.linuxCgroupController }),
311 ...(input.posixProcessTreeController === undefined
312 ? {}
313 : { posixProcessTreeController: input.posixProcessTreeController }),
314 ...(input.windowsProcessTreeTerminator === undefined
315 ? {}
316 : { windowsProcessTreeTerminator: input.windowsProcessTreeTerminator }),
77 protocolLogging,317 protocolLogging,
78 spawn: {318 spawn: {
79 command: process.execPath,319 command: process.execPath,
80 args: [input.mockAgentPath],320 args: [input.mockAgentPath],
81 cwd: runtimeInput.cwd,321 cwd: runtimeInput.cwd,
82 env: { T3_ACP_SESSION_LIFECYCLE: "1", ...input.environment },322 env: {
323 T3_ACP_SESSION_LIFECYCLE: "1",
324 ...(typeof input.environment === "function"
325 ? input.environment(runtimeOrdinal)
326 : input.environment),
327 },
83 },328 },
84 authMethodId: "test",329 authMethodId: "test",
330 ...(input.wrapIncomingRequest === undefined ||
331 runtimeInput.onIncomingRequest === undefined
332 ? {}
333 : {
334 onIncomingRequest: input.wrapIncomingRequest(runtimeInput.onIncomingRequest),
335 }),
336 ...(input.wrapOutgoingResponse === undefined ||
337 runtimeInput.onOutgoingResponse === undefined
338 ? {}
339 : {
340 onOutgoingResponse: input.wrapOutgoingResponse(runtimeInput.onOutgoingResponse),
341 }),
85 }).pipe(342 }).pipe(
86 Layer.provide(343 Layer.provide(
87 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),344 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
88 ),345 ),
89 ),346 ),
90 );347 );
91 return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(348 const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
92 Effect.provide(context),349 Effect.provide(context),
93 );350 );
351 const wrapped = {
352 ...runtime,
353 ...(input.wrapCancel === undefined ? {} : { cancel: input.wrapCancel(runtime.cancel) }),
354 };
355 return input.wrapRuntime?.(wrapped, runtimeOrdinal) ?? wrapped;
94 });356 });
95}357}
96 358
@@ -123,6 +385,20 @@ function rawProtocolRequest( · 26 unmodified lines
123 return undefined;385 return undefined;
124}386}
125 387
388const pollProtocolMethods = (events: Queue.Queue<EffectAcpProtocol.AcpProtocolLogEvent>) =>
389 Effect.gen(function* () {
390 const methods: string[] = [];
391 let polled = 0;
392 let event = yield* Queue.poll(events);
393 while (Option.isSome(event) && polled < 256) {
394 polled += 1;
395 const method = rawProtocolMethod(event.value);
396 if (method !== undefined) methods.push(method);
397 event = yield* Queue.poll(events);
398 }
399 return methods;
400 });
401
126function makeTurnInput(input: {402function makeTurnInput(input: {
127 readonly threadId: ThreadId;403 readonly threadId: ThreadId;
128 readonly providerThread: OrchestrationV2ProviderThread;404 readonly providerThread: OrchestrationV2ProviderThread;
@@ -181,6 +457,418 @@ function makeTurnInput(input: { · 52 unmodified lines
181}457}
182 458
183describe("AcpAdapterV2", () => {459describe("AcpAdapterV2", () => {
460 it.live("cleans detached fixtures when an assertion aborts the test scope", () =>
461 Effect.gen(function* () {
462 const fileSystem = yield* FileSystem.FileSystem;
463 let published: ReadonlyArray<number> = [];
464 const failed = yield* Effect.scoped(
465 Effect.gen(function* () {
466 const commandPidPath = yield* fileSystem.makeTempFileScoped({
467 prefix: "t3-acp-forced-failure-command-",
468 });
469 yield* Effect.addFinalizer(() =>
470 Effect.sync(() => cleanupPublishedDetachedFixture(commandPidPath)),
471 );
472 const fixture = NodeChildProcess.spawn(
473 "bash",
474 [
475 "-c",
476 'sleep 120 & child=$!; printf "%s %s\\n" "$$" "$child" > "$1"; wait "$child"',
477 "bash",
478 commandPidPath,
479 ],
480 { detached: true, stdio: "ignore" },
481 );
482 fixture.unref();
483 published = Option.getOrThrow(
484 yield* waitForPublishedProcessIds(fileSystem, commandPidPath, 2),
485 );
486 return yield* Effect.fail("forced assertion failure");
487 }),
488 ).pipe(Effect.exit);
489
490 assert.isTrue(Exit.isFailure(failed));
491 assert.isTrue(
492 Option.isSome(yield* waitForProcessesToExit(published)),
493 "detached cleanup finalizer must reap the Bash and sleep fixture",
494 );
495 }).pipe(Effect.provide(testLayer)),
496 );
497
498 it("matches xAI native request identities exactly across shared prefixes", () => {
499 const request = {
500 nativeMethod: "x.ai/ask_user_question",
501 nativeRequestId: "1",
502 nativeSessionId: "session-1",
503 };
504 assert.isTrue(
505 acpNativeUserInputRequestMatches(request, {
506 method: "x.ai/ask_user_question",
507 payload: { sessionId: "session-1", toolCallId: 1 },
508 }),
509 );
510 assert.isFalse(
511 acpNativeUserInputRequestMatches(request, {
512 method: "x.ai/ask_user_question",
513 payload: { sessionId: "session-1", toolCallId: "10", note: "request 1" },
514 }),
515 );
516 for (const incomplete of [
517 { ...request, nativeMethod: "" },
518 { ...request, nativeRequestId: "" },
519 { ...request, nativeSessionId: "" },
520 ]) {
521 assert.isFalse(
522 acpNativeUserInputRequestMatches(incomplete, {
523 method: "x.ai/ask_user_question",
524 payload: { sessionId: "session-1", toolCallId: "1" },
525 }),
526 );
527 }
528 assert.isFalse(
529 acpNativeUserInputRequestMatches(request, {
530 method: "_x.ai/ask_user_question",
531 payload: { sessionId: "session-1", toolCallId: "1" },
532 }),
533 );
534 assert.isFalse(
535 acpNativeUserInputRequestMatches(request, {
536 method: "x.ai/ask_user_question",
537 payload: {
538 method: "x.ai/ask_user_question",
539 params: { sessionId: "session-10", toolCallId: "1" },
540 },
541 }),
542 );
543 });
544
545 it("claims concurrent identical native requests in per-runtime sequence order", () => {
546 const requests = [
547 { generation: 2, requestId: "second", sequence: 8, identity: "shared" },
548 { generation: 1, requestId: "stale", sequence: 1, identity: "shared" },
549 { generation: 2, requestId: "first", sequence: 7, identity: "shared" },
550 { generation: 2, requestId: "other", sequence: 6, identity: "other" },
551 ];
552 const [firstId, afterFirst] = acpClaimNativeTransportRequest(
553 requests,
554 2,
555 (request) => request.identity === "shared",
556 );
557 const [secondId, afterSecond] = acpClaimNativeTransportRequest(
558 afterFirst,
559 2,
560 (request) => request.identity === "shared",
561 );
562
563 assert.equal(firstId, "first");
564 assert.equal(secondId, "second");
565 assert.deepEqual(
566 afterSecond.map((request) => request.requestId),
567 ["stale", "other"],
568 );
569 });
570
571 it("canonicalizes nested elicitation schemas independently of object key order", () => {
572 assert.equal(
573 acpCanonicalJson({
574 type: "object",
575 properties: { answer: { type: "string", title: "Answer", enum: ["a", "b"] } },
576 }),
577 acpCanonicalJson({
578 properties: { answer: { enum: ["a", "b"], title: "Answer", type: "string" } },
579 type: "object",
580 }),
581 );
582 });
583
584 it.live("replaces an unexpectedly terminated ACP runtime before the next turn", () =>
585 Effect.gen(function* () {
586 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
587 const fileSystem = yield* FileSystem.FileSystem;
588 const idAllocator = yield* IdAllocatorV2;
589 const path = yield* Path.Path;
590 const serverConfig = yield* ServerConfig;
591 const mockAgentPath = yield* path.fromFileUrl(
592 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
593 );
594 const runtimeInputs: AcpAdapterV2RuntimeInput[] = [];
595 let runtimeOrdinalSeen = 0;
596 const baseMakeRuntime = makeMockRuntime({
597 childProcessSpawner,
598 mockAgentPath,
599 environment: (runtimeOrdinal) => {
600 runtimeOrdinalSeen = runtimeOrdinal;
601 return {};
602 },
603 });
604 const instanceId = ProviderInstanceId.make("acp-test-unexpected-termination");
605 const adapter = makeAcpAdapterV2({
606 instanceId,
607 flavor: {
608 driver: ACP_TEST_DRIVER,
609 capabilities: AcpProviderCapabilitiesV2,
610 makeRuntime: (runtimeInput) =>
611 Effect.sync(() => {
612 runtimeInputs.push(runtimeInput);
613 }).pipe(Effect.andThen(baseMakeRuntime(runtimeInput))),
614 },
615 fileSystem,
616 idAllocator,
617 serverConfig,
618 });
619 const threadId = ThreadId.make("thread-acp-unexpected-termination");
620 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
621 runtimeMode: "full-access",
622 interactionMode: "default",
623 cwd: process.cwd(),
624 });
625 const modelSelection = { instanceId, model: "default" } as const;
626 const runtime = yield* adapter.openSession({
627 threadId,
628 providerSessionId: ProviderSessionId.make("provider-session-acp-unexpected-termination"),
629 modelSelection,
630 runtimePolicy,
631 });
632 const providerThread = yield* runtime.ensureThread({
633 threadId,
634 modelSelection,
635 runtimePolicy,
636 });
637 assert.equal(runtimeOrdinalSeen, 1);
638 yield* runtimeInputs[0]!.onTermination!(
639 new EffectAcpErrors.AcpTransportError({
640 detail: "Injected unexpected writer termination",
641 cause: "test",
642 }),
643 );
644
645 yield* runtime.startTurn(
646 makeTurnInput({
647 threadId,
648 providerThread,
649 instanceId,
650 runtimePolicy,
651 now: yield* DateTime.now,
652 }),
653 );
654 yield* runtime.events.pipe(
655 Stream.filter((event) => event.type === "turn.terminal"),
656 Stream.runHead,
657 );
658 assert.equal(runtimeOrdinalSeen, 2);
659 assert.lengthOf(runtimeInputs, 2);
660 }).pipe(Effect.provide(testLayer), Effect.scoped),
661 );
662
663 it.live("reaps detached native work when the provider exits before explicit teardown", () =>
664 Effect.gen(function* () {
665 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
666 const fileSystem = yield* FileSystem.FileSystem;
667 const idAllocator = yield* IdAllocatorV2;
668 const path = yield* Path.Path;
669 const serverConfig = yield* ServerConfig;
670 const mockAgentPath = yield* path.fromFileUrl(
671 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
672 );
673 const commandPidPath = yield* fileSystem.makeTempFileScoped({
674 prefix: "t3-acp-provider-exit-command-",
675 });
676 yield* Effect.addFinalizer(() =>
677 Effect.sync(() => cleanupPublishedDetachedFixture(commandPidPath)),
678 );
679 const instanceId = ProviderInstanceId.make("acp-test-provider-exit");
680 const adapter = makeAcpAdapterV2({
681 instanceId,
682 flavor: {
683 driver: ACP_TEST_DRIVER,
684 capabilities: AcpProviderCapabilitiesV2,
685 terminateRuntimeProcessGroupOnInterrupt: true,
686 makeRuntime: makeMockRuntime({
687 childProcessSpawner,
688 mockAgentPath,
689 ownDescendantProcessGroups: true,
690 ownDetachedProcessGroup: true,
691 processGroupTerminationGrace: 0,
692 environment: {
693 T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG: "1",
694 T3_ACP_EXIT_AFTER_RUNNING_COMMAND_LAUNCH: "1",
695 T3_ACP_RUNNING_COMMAND_PID_PATH: commandPidPath,
696 T3_ACP_RUNNING_COMMAND_SEPARATE_SESSION: "1",
697 },
698 }),
699 },
700 fileSystem,
701 idAllocator,
702 serverConfig,
703 });
704 const threadId = ThreadId.make("thread-acp-provider-exit-running-command");
705 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
706 runtimeMode: "full-access",
707 interactionMode: "default",
708 cwd: process.cwd(),
709 });
710 const modelSelection = { instanceId, model: "default" } as const;
711 const runtime = yield* adapter.openSession({
712 threadId,
713 providerSessionId: ProviderSessionId.make("provider-session-acp-provider-exit"),
714 modelSelection,
715 runtimePolicy,
716 });
717 const providerThread = yield* runtime.ensureThread({
718 threadId,
719 modelSelection,
720 runtimePolicy,
721 });
722 yield* runtime
723 .startTurn(
724 makeTurnInput({
725 threadId,
726 providerThread,
727 instanceId,
728 runtimePolicy,
729 now: yield* DateTime.now,
730 }),
731 )
732 .pipe(Effect.exit, Effect.forkScoped);
733
734 const published = yield* waitForPublishedProcessIds(fileSystem, commandPidPath, 3);
735 assert.isTrue(Option.isSome(published), "detached fixture must publish all process IDs");
736 const pids = Option.getOrThrow(published);
737 assert.isTrue(
738 Option.isSome(yield* waitForProcessesToExit(pids)),
739 "provider termination must reap the detached launcher, Bash, and sleep processes",
740 );
741 }).pipe(Effect.provide(testLayer), Effect.scoped),
742 );
743
744 it.live("surfaces reduced guarantee when delegated cgroup containment is unavailable", () =>
745 Effect.gen(function* () {
746 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
747 const fileSystem = yield* FileSystem.FileSystem;
748 const idAllocator = yield* IdAllocatorV2;
749 const path = yield* Path.Path;
750 const serverConfig = yield* ServerConfig;
751 const mockAgentPath = yield* path.fromFileUrl(
752 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
753 );
754 let containment:
755 | AcpSessionRuntime.AcpSessionRuntime["Service"]["processContainment"]
756 | undefined;
757 const instanceId = ProviderInstanceId.make("acp-test-cgroup-unavailable");
758 const adapter = makeAcpAdapterV2({
759 instanceId,
760 flavor: {
761 driver: ACP_TEST_DRIVER,
762 capabilities: AcpProviderCapabilitiesV2,
763 makeRuntime: makeMockRuntime({
764 childProcessSpawner,
765 linuxCgroupController: null,
766 mockAgentPath,
767 ownDescendantProcessGroups: true,
768 ownDetachedProcessGroup: true,
769 wrapRuntime: (runtime) => {
770 containment = runtime.processContainment;
771 return runtime;
772 },
773 }),
774 },
775 fileSystem,
776 idAllocator,
777 serverConfig,
778 });
779 const threadId = ThreadId.make("thread-acp-cgroup-unavailable");
780 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
781 runtimeMode: "full-access",
782 interactionMode: "default",
783 cwd: process.cwd(),
784 });
785 const modelSelection = { instanceId, model: "default" } as const;
786 const runtime = yield* adapter.openSession({
787 threadId,
788 providerSessionId: ProviderSessionId.make("provider-session-acp-cgroup-unavailable"),
789 modelSelection,
790 runtimePolicy,
791 });
792 yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy });
793 assert.equal(containment, "process-ledger-reduced-guarantee");
794 }).pipe(Effect.provide(testLayer), Effect.scoped),
795 );
796
797 it.live("cleans a cgroup lease when the pre-exec join wrapper fails", () =>
798 Effect.gen(function* () {
799 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
800 const fileSystem = yield* FileSystem.FileSystem;
801 const idAllocator = yield* IdAllocatorV2;
802 const path = yield* Path.Path;
803 const serverConfig = yield* ServerConfig;
804 const mockAgentPath = yield* path.fromFileUrl(
805 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
806 );
807 let createCalls = 0;
808 let cgroupExists = true;
809 let killCalls = 0;
810 let removeCalls = 0;
811 const cgroupController: AcpSessionRuntime.AcpLinuxCgroupController = {
812 create: () => {
813 createCalls += 1;
814 return {
815 contains: () => false,
816 exists: () => cgroupExists,
817 path: "/definitely-missing/t3-acp-cgroup",
818 relativePath: "/definitely-missing/t3-acp-cgroup",
819 kill: () => {
820 killCalls += 1;
821 },
822 populated: () => false,
823 remove: () => {
824 removeCalls += 1;
825 cgroupExists = false;
826 },
827 };
828 },
829 };
830 const instanceId = ProviderInstanceId.make("acp-test-cgroup-join-failure");
831 const adapter = makeAcpAdapterV2({
832 instanceId,
833 flavor: {
834 driver: ACP_TEST_DRIVER,
835 capabilities: AcpProviderCapabilitiesV2,
836 makeRuntime: makeMockRuntime({
837 childProcessSpawner,
838 linuxCgroupController: cgroupController,
839 mockAgentPath,
840 ownDescendantProcessGroups: true,
841 ownDetachedProcessGroup: true,
842 }),
843 },
844 fileSystem,
845 idAllocator,
846 serverConfig,
847 });
848 const threadId = ThreadId.make("thread-acp-cgroup-join-failure");
849 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
850 runtimeMode: "full-access",
851 interactionMode: "default",
852 cwd: process.cwd(),
853 });
854 const modelSelection = { instanceId, model: "default" } as const;
855 const sessionScope = yield* Scope.make();
856 const opened = yield* adapter
857 .openSession({
858 threadId,
859 providerSessionId: ProviderSessionId.make("provider-session-acp-cgroup-join-failure"),
860 modelSelection,
861 runtimePolicy,
862 })
863 .pipe(Effect.provideService(Scope.Scope, sessionScope), Effect.exit);
864 assert.isTrue(Exit.isFailure(opened));
865 yield* Scope.close(sessionScope, Exit.void);
866 assert.equal(createCalls, 1);
867 assert.isAtLeast(killCalls, 1);
868 assert.isAtLeast(removeCalls, 1);
869 }).pipe(Effect.provide(testLayer), Effect.scoped),
870 );
871
184 it.effect("negotiates and executes optional native session forks through the ACP runtime", () =>872 it.effect("negotiates and executes optional native session forks through the ACP runtime", () =>
185 Effect.gen(function* () {873 Effect.gen(function* () {
186 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;874 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -244,16 +932,18 @@ describe("AcpAdapterV2", () => { · 57 unmodified lines
244 sourceProviderThread,932 sourceProviderThread,
245 targetThreadId,933 targetThreadId,
246 });934 });
247 const forkRequest = Option.getOrThrow(935 const forkRequestEvent = Option.getOrThrow(
248 yield* Stream.fromQueue(protocolEvents).pipe(936 yield* Stream.fromQueue(protocolEvents).pipe(
249 Stream.filter(937 Stream.filter(
250 (event) =>938 (event) =>
251 event.direction === "outgoing" && rawProtocolMethod(event) === "session/fork",939 event.direction === "outgoing" && rawProtocolMethod(event) === "session/fork",
252 ),940 ),
253 Stream.map(rawProtocolRequest),
254 Stream.runHead,941 Stream.runHead,
255 ),942 ),
256 );943 );
944 const forkRequest = Option.getOrThrow(
945 Option.fromNullishOr(rawProtocolRequest(forkRequestEvent)),
946 );
257 947
258 assert.equal(sourceProviderThread.nativeThreadRef?.nativeId, "mock-session-1");948 assert.equal(sourceProviderThread.nativeThreadRef?.nativeId, "mock-session-1");
259 assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, "mock-session-1-fork");949 assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, "mock-session-1-fork");
@@ -274,7 +964,7 @@ describe("AcpAdapterV2", () => { · 14 unmodified lines
274 }).pipe(Effect.provide(testLayer), Effect.scoped),964 }).pipe(Effect.provide(testLayer), Effect.scoped),
275 );965 );
276 966
277 it.effect("rejects requested options that the active ACP session does not expose", () =>967 it.effect("closes an idle ACP session exactly once through the transition permit", () =>
278 Effect.gen(function* () {968 Effect.gen(function* () {
279 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;969 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
280 const fileSystem = yield* FileSystem.FileSystem;970 const fileSystem = yield* FileSystem.FileSystem;
@@ -284,44 +974,43 @@ describe("AcpAdapterV2", () => { · 3 unmodified lines
284 const mockAgentPath = yield* path.fromFileUrl(974 const mockAgentPath = yield* path.fromFileUrl(
285 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),975 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
286 );976 );
977 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
287 const instanceId = ProviderInstanceId.make("acp-test");978 const instanceId = ProviderInstanceId.make("acp-test");
288 const adapter = makeAcpAdapterV2({979 const adapter = makeAcpAdapterV2({
289 instanceId,980 instanceId,
290 flavor: {981 flavor: {
291 driver: ACP_TEST_DRIVER,982 driver: ACP_TEST_DRIVER,
292 capabilities: AcpProviderCapabilitiesV2,983 capabilities: AcpProviderCapabilitiesV2,
293 makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath }),984 makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }),
294 },985 },
295 fileSystem,986 fileSystem,
296 idAllocator,987 idAllocator,
297 serverConfig,988 serverConfig,
298 });989 });
299 const threadId = ThreadId.make("thread-acp-unsupported-option");990 const threadId = ThreadId.make("thread-acp-idle-finalizer");
300 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({991 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
301 runtimeMode: "full-access",992 runtimeMode: "full-access",
302 interactionMode: "default",993 interactionMode: "default",
303 cwd: process.cwd(),994 cwd: process.cwd(),
304 });995 });
305 const error = yield* adapter996 const modelSelection = { instanceId, model: "default" } as const;
997 const sessionScope = yield* Scope.make();
998 yield* adapter
306 .openSession({999 .openSession({
307 threadId,1000 threadId,
308 providerSessionId: ProviderSessionId.make("provider-session-acp-unsupported-option"),1001 providerSessionId: ProviderSessionId.make("provider-session-acp-idle-finalizer"),
309 modelSelection: {1002 modelSelection,
310 instanceId,
311 model: "default",
312 options: [{ id: "missing-option", value: "high" }],
313 },
314 runtimePolicy,1003 runtimePolicy,
315 })1004 })
316 .pipe(Effect.flip);1005 .pipe(Effect.provideService(Scope.Scope, sessionScope));
317 1006 yield* pollProtocolMethods(protocolEvents);
318 assert.equal(error._tag, "ProviderAdapterOpenSessionError");1007 yield* Scope.close(sessionScope, Exit.void);
319 assert.include(String(error.cause), "does not expose requested configuration option(s)");1008 const finalizerMethods = yield* pollProtocolMethods(protocolEvents);
320 assert.include(String(error.cause), "missing-option");1009 assert.equal(finalizerMethods.filter((method) => method === "session/close").length, 1);
321 }).pipe(Effect.provide(testLayer), Effect.scoped),1010 }).pipe(Effect.provide(testLayer)),
322 );1011 );
323 1012
324 it.effect("reconfigures a loaded ACP session from its own active setup metadata", () =>1013 it.effect("rejects requested options that the active ACP session does not expose", () =>
325 Effect.gen(function* () {1014 Effect.gen(function* () {
326 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;1015 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
327 const fileSystem = yield* FileSystem.FileSystem;1016 const fileSystem = yield* FileSystem.FileSystem;
@@ -331,20 +1020,67 @@ describe("AcpAdapterV2", () => { · 3 unmodified lines
331 const mockAgentPath = yield* path.fromFileUrl(1020 const mockAgentPath = yield* path.fromFileUrl(
332 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),1021 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
333 );1022 );
334 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
335 const instanceId = ProviderInstanceId.make("acp-test");1023 const instanceId = ProviderInstanceId.make("acp-test");
336 const adapter = makeAcpAdapterV2({1024 const adapter = makeAcpAdapterV2({
337 instanceId,1025 instanceId,
338 flavor: {1026 flavor: {
339 driver: ACP_TEST_DRIVER,1027 driver: ACP_TEST_DRIVER,
340 capabilities: AcpProviderCapabilitiesV2,1028 capabilities: AcpProviderCapabilitiesV2,
341 makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }),1029 makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath }),
342 },1030 },
343 fileSystem,1031 fileSystem,
344 idAllocator,1032 idAllocator,
345 serverConfig,1033 serverConfig,
346 });1034 });
347 const firstThreadId = ThreadId.make("thread-acp-active-setup:first");1035 const threadId = ThreadId.make("thread-acp-unsupported-option");
1036 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
1037 runtimeMode: "full-access",
1038 interactionMode: "default",
1039 cwd: process.cwd(),
1040 });
1041 const error = yield* adapter
1042 .openSession({
1043 threadId,
1044 providerSessionId: ProviderSessionId.make("provider-session-acp-unsupported-option"),
1045 modelSelection: {
1046 instanceId,
1047 model: "default",
1048 options: [{ id: "missing-option", value: "high" }],
1049 },
1050 runtimePolicy,
1051 })
1052 .pipe(Effect.flip);
1053
1054 assert.equal(error._tag, "ProviderAdapterOpenSessionError");
1055 assert.include(String(error.cause), "does not expose requested configuration option(s)");
1056 assert.include(String(error.cause), "missing-option");
1057 }).pipe(Effect.provide(testLayer), Effect.scoped),
1058 );
1059
1060 it.effect("reconfigures a loaded ACP session from its own active setup metadata", () =>
1061 Effect.gen(function* () {
1062 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1063 const fileSystem = yield* FileSystem.FileSystem;
1064 const idAllocator = yield* IdAllocatorV2;
1065 const path = yield* Path.Path;
1066 const serverConfig = yield* ServerConfig;
1067 const mockAgentPath = yield* path.fromFileUrl(
1068 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
1069 );
1070 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
1071 const instanceId = ProviderInstanceId.make("acp-test");
1072 const adapter = makeAcpAdapterV2({
1073 instanceId,
1074 flavor: {
1075 driver: ACP_TEST_DRIVER,
1076 capabilities: AcpProviderCapabilitiesV2,
1077 makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }),
1078 },
1079 fileSystem,
1080 idAllocator,
1081 serverConfig,
1082 });
1083 const firstThreadId = ThreadId.make("thread-acp-active-setup:first");
348 const secondThreadId = ThreadId.make("thread-acp-active-setup:second");1084 const secondThreadId = ThreadId.make("thread-acp-active-setup:second");
349 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({1085 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
350 runtimeMode: "full-access",1086 runtimeMode: "full-access",
@@ -424,6 +1160,149 @@ describe("AcpAdapterV2", () => { · 73 unmodified lines
424 }).pipe(Effect.provide(testLayer), Effect.scoped),1160 }).pipe(Effect.provide(testLayer), Effect.scoped),
425 );1161 );
426 1162
1163 it.live("terminalizes an empty successful foreground Bash tool when the turn completes", () =>
1164 Effect.gen(function* () {
1165 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1166 const fileSystem = yield* FileSystem.FileSystem;
1167 const idAllocator = yield* IdAllocatorV2;
1168 const path = yield* Path.Path;
1169 const serverConfig = yield* ServerConfig;
1170 const mockAgentPath = yield* path.fromFileUrl(
1171 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
1172 );
1173 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
1174 const instanceId = ProviderInstanceId.make("acp-test");
1175 const adapter = makeAcpAdapterV2({
1176 instanceId,
1177 flavor: {
1178 driver: ACP_TEST_DRIVER,
1179 capabilities: AcpProviderCapabilitiesV2,
1180 makeRuntime: makeMockRuntime({
1181 childProcessSpawner,
1182 mockAgentPath,
1183 environment: (runtimeOrdinal) =>
1184 runtimeOrdinal === 1 ? { T3_ACP_EMIT_EMPTY_SUCCESSFUL_BASH_THEN_HANG: "1" } : {},
1185 ownDetachedProcessGroup: true,
1186 protocolEvents,
1187 }),
1188 normalizeToolCall: normalizeXAiAcpToolCallState,
1189 restartRuntimeAfterInterrupt: true,
1190 terminateRuntimeProcessGroupOnInterrupt: true,
1191 },
1192 fileSystem,
1193 idAllocator,
1194 serverConfig,
1195 });
1196 const threadId = ThreadId.make("thread-acp-empty-successful-bash");
1197 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
1198 runtimeMode: "full-access",
1199 interactionMode: "default",
1200 cwd: process.cwd(),
1201 });
1202 const modelSelection = { instanceId, model: "default" } as const;
1203 const runtime = yield* adapter.openSession({
1204 threadId,
1205 providerSessionId: ProviderSessionId.make("provider-session-acp-empty-successful-bash"),
1206 modelSelection,
1207 runtimePolicy,
1208 });
1209 const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
1210 yield* runtime.events.pipe(
1211 Stream.runForEach((event) => Queue.offer(events, event)),
1212 Effect.forkScoped,
1213 );
1214 const providerThread = yield* runtime.ensureThread({
1215 threadId,
1216 modelSelection,
1217 runtimePolicy,
1218 });
1219 const now = yield* DateTime.now;
1220 yield* runtime.startTurn(
1221 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
1222 );
1223
1224 const statuses: string[] = [];
1225 let runningStartedAt: DateTime.Utc | null = null;
1226 let completedStartedAt: DateTime.Utc | null = null;
1227 let completedAt: DateTime.Utc | null = null;
1228 let completedInput: string | null = null;
1229 let completedOutput: string | null | undefined;
1230 let completedExitCode: number | null | undefined;
1231 let runningProjectedExitCode: number | undefined = undefined;
1232 let terminal = false;
1233 while (!terminal) {
1234 const event = yield* Queue.take(events);
1235 if (
1236 event.type === "turn_item.updated" &&
1237 event.turnItem.nativeItemRef?.nativeId === "tool-call-empty-success-1"
1238 ) {
1239 statuses.push(event.turnItem.status);
1240 if (event.turnItem.status === "running") {
1241 runningStartedAt ??= event.turnItem.startedAt;
1242 if (event.turnItem.type === "command_execution") {
1243 runningProjectedExitCode = event.turnItem.exitCode;
1244 }
1245 }
1246 if (event.turnItem.status === "completed") {
1247 completedStartedAt = event.turnItem.startedAt;
1248 completedAt = event.turnItem.completedAt;
1249 if (event.turnItem.type === "command_execution") {
1250 completedInput = event.turnItem.input;
1251 completedOutput = event.turnItem.output;
1252 completedExitCode = event.turnItem.exitCode;
1253 }
1254 }
1255 }
1256 if (event.type === "turn.terminal") terminal = true;
1257 }
1258
1259 assert.deepEqual(statuses, ["running", "running", "completed"]);
1260 assert.deepEqual(completedStartedAt, runningStartedAt);
1261 assert.isNotNull(completedAt);
1262 assert.equal(completedInput, "true");
1263 assert.equal(completedOutput, undefined);
1264 assert.equal(
1265 runningProjectedExitCode,
1266 undefined,
1267 "mid-stream exit_code must not project until the tool is terminal",
1268 );
1269 assert.equal(completedExitCode, 0);
1270
1271 yield* Queue.takeAll(protocolEvents);
1272 yield* runtime
1273 .startTurn(
1274 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
1275 )
1276 .pipe(Effect.forkScoped);
1277 yield* Stream.fromQueue(protocolEvents).pipe(
1278 Stream.filter(
1279 (event) =>
1280 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
1281 ),
1282 Stream.runHead,
1283 );
1284 const secondProviderTurnId = idAllocator.derive.providerTurn({
1285 driver: ACP_TEST_DRIVER,
1286 nativeTurnId: "mock-session-1:turn:2",
1287 });
1288 yield* runtime.interruptTurn({
1289 providerThread,
1290 providerTurnId: secondProviderTurnId,
1291 requestRuntimeRestart: true,
1292 });
1293 yield* runtime.startTurn(
1294 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 3 }),
1295 );
1296 const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe(
1297 Stream.filter(
1298 (event) => event.direction === "outgoing" && rawProtocolMethod(event) === "session/load",
1299 ),
1300 Stream.runHead,
1301 );
1302 assert.isTrue(Option.isSome(loadAfterRestart));
1303 }).pipe(Effect.provide(testLayer), Effect.scoped),
1304 );
1305
427 it.effect("cancels pending permission requests while interrupting an ACP turn", () =>1306 it.effect("cancels pending permission requests while interrupting an ACP turn", () =>
428 Effect.gen(function* () {1307 Effect.gen(function* () {
429 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;1308 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -434,6 +1313,7 @@ describe("AcpAdapterV2", () => { · 4 unmodified lines
434 const mockAgentPath = yield* path.fromFileUrl(1313 const mockAgentPath = yield* path.fromFileUrl(
435 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),1314 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
436 );1315 );
1316 const releaseCancel = yield* Deferred.make<void>();
437 const instanceId = ProviderInstanceId.make("acp-test");1317 const instanceId = ProviderInstanceId.make("acp-test");
438 const adapter = makeAcpAdapterV2({1318 const adapter = makeAcpAdapterV2({
439 instanceId,1319 instanceId,
@@ -444,6 +1324,7 @@ describe("AcpAdapterV2", () => { · 4 unmodified lines
444 childProcessSpawner,1324 childProcessSpawner,
445 mockAgentPath,1325 mockAgentPath,
446 environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },1326 environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
1327 wrapCancel: (cancel) => Deferred.await(releaseCancel).pipe(Effect.andThen(cancel)),
447 }),1328 }),
448 },1329 },
449 fileSystem,1330 fileSystem,
@@ -489,10 +1370,12 @@ describe("AcpAdapterV2", () => { · 39 unmodified lines
489 return yield* Effect.die("Expected a pending ACP permission request with a provider turn");1370 return yield* Effect.die("Expected a pending ACP permission request with a provider turn");
490 }1371 }
491 1372
492 yield* runtime.interruptTurn({1373 const interruptFiber = yield* runtime
493 providerThread,1374 .interruptTurn({
494 providerTurnId: pendingRequest.runtimeRequest.providerTurnId,1375 providerThread,
495 });1376 providerTurnId: pendingRequest.runtimeRequest.providerTurnId,
1377 })
1378 .pipe(Effect.forkScoped);
496 1379
497 const cancelledRequest = Option.getOrThrow(1380 const cancelledRequest = Option.getOrThrow(
498 yield* runtime.events.pipe(1381 yield* runtime.events.pipe(
@@ -506,10 +1389,19 @@ describe("AcpAdapterV2", () => { · 7 unmodified lines
506 ),1389 ),
507 );1390 );
508 assert.equal(cancelledRequest.type, "runtime_request.updated");1391 assert.equal(cancelledRequest.type, "runtime_request.updated");
1392 yield* Deferred.succeed(releaseCancel, undefined);
1393 yield* Fiber.join(interruptFiber);
1394 const terminal = Option.getOrThrow(
1395 yield* runtime.events.pipe(
1396 Stream.filter((event) => event.type === "turn.terminal"),
1397 Stream.runHead,
1398 ),
1399 );
1400 assert.equal(terminal.type, "turn.terminal");
509 }).pipe(Effect.provide(testLayer), Effect.scoped),1401 }).pipe(Effect.provide(testLayer), Effect.scoped),
510 );1402 );
511 1403
512 it.effect("releases an ACP turn when cancellation times out", () =>1404 it.live("keeps hard teardown excluded until a permission response is enqueued", () =>
513 Effect.gen(function* () {1405 Effect.gen(function* () {
514 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;1406 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
515 const fileSystem = yield* FileSystem.FileSystem;1407 const fileSystem = yield* FileSystem.FileSystem;
@@ -519,8 +1411,9 @@ describe("AcpAdapterV2", () => { · 3 unmodified lines
519 const mockAgentPath = yield* path.fromFileUrl(1411 const mockAgentPath = yield* path.fromFileUrl(
520 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),1412 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
521 );1413 );
1414 const responseEnqueued = yield* Deferred.make<void>();
1415 const releaseResponseAcknowledgement = yield* Deferred.make<void>();
522 const instanceId = ProviderInstanceId.make("acp-test");1416 const instanceId = ProviderInstanceId.make("acp-test");
523 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
524 const adapter = makeAcpAdapterV2({1417 const adapter = makeAcpAdapterV2({
525 instanceId,1418 instanceId,
526 flavor: {1419 flavor: {
@@ -529,24 +1422,28 @@ describe("AcpAdapterV2", () => { · 2 unmodified lines
529 makeRuntime: makeMockRuntime({1422 makeRuntime: makeMockRuntime({
530 childProcessSpawner,1423 childProcessSpawner,
531 mockAgentPath,1424 mockAgentPath,
532 environment: { T3_ACP_PROMPT_DELAY_MS: "5000" },1425 environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
533 protocolEvents,1426 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
1427 Deferred.succeed(responseEnqueued, undefined).pipe(
1428 Effect.andThen(Deferred.await(releaseResponseAcknowledgement)),
1429 Effect.andThen(onOutgoingResponse(requestId)),
1430 ),
534 }),1431 }),
535 },1432 },
536 fileSystem,1433 fileSystem,
537 idAllocator,1434 idAllocator,
538 serverConfig,1435 serverConfig,
539 });1436 });
540 const threadId = ThreadId.make("thread-acp-cancel-timeout");1437 const threadId = ThreadId.make("thread-acp-response-wins-permission");
541 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({1438 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
542 runtimeMode: "full-access",1439 runtimeMode: "approval-required",
543 interactionMode: "default",1440 interactionMode: "default",
544 cwd: process.cwd(),1441 cwd: process.cwd(),
545 });1442 });
546 const modelSelection = { instanceId, model: "default" } as const;1443 const modelSelection = { instanceId, model: "default" } as const;
547 const runtime = yield* adapter.openSession({1444 const runtime = yield* adapter.openSession({
548 threadId,1445 threadId,
549 providerSessionId: ProviderSessionId.make("provider-session-acp-cancel-timeout"),1446 providerSessionId: ProviderSessionId.make("provider-session-acp-response-wins-permission"),
550 modelSelection,1447 modelSelection,
551 runtimePolicy,1448 runtimePolicy,
552 });1449 });
@@ -555,57 +1452,5168 @@ describe("AcpAdapterV2", () => { · 2 unmodified lines
555 modelSelection,1452 modelSelection,
556 runtimePolicy,1453 runtimePolicy,
557 });1454 });
558 const now = yield* DateTime.now;1455 yield* runtime.startTurn(
559 const firstTurn = makeTurnInput({1456 makeTurnInput({
560 threadId,1457 threadId,
561 providerThread,1458 providerThread,
562 instanceId,1459 instanceId,
563 runtimePolicy,1460 runtimePolicy,
564 now,1461 now: yield* DateTime.now,
565 });1462 }),
566 yield* runtime.startTurn(firstTurn);
567 yield* Stream.fromQueue(protocolEvents).pipe(
568 Stream.filter(
569 (event) =>
570 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
571 ),
572 Stream.runHead,
573 );1463 );
574 const providerTurnId = idAllocator.derive.providerTurn({1464 const pending = Option.getOrThrow(
575 driver: ACP_TEST_DRIVER,1465 yield* runtime.events.pipe(
576 nativeTurnId: "mock-session-1:turn:1",1466 Stream.filter(
577 });1467 (event) =>
578 const interruptFiber = yield* runtime1468 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
579 .interruptTurn({ providerThread, providerTurnId })1469 ),
580 .pipe(Effect.flip, Effect.forkScoped);1470 Stream.runHead,
581 yield* Stream.fromQueue(protocolEvents).pipe(
582 Stream.filter(
583 (event) =>
584 event.direction === "outgoing" && rawProtocolMethod(event) === "session/cancel",
585 ),1471 ),
586 Stream.runHead,
587 );1472 );
588 yield* TestClock.adjust("10 seconds");1473 if (
589 const interruptError = yield* Fiber.join(interruptFiber);1474 pending.type !== "runtime_request.updated" ||
590 assert.equal(interruptError._tag, "ProviderAdapterInterruptError");1475 pending.runtimeRequest.providerTurnId === null
1476 ) {
1477 return yield* Effect.die("Expected a pending permission request");
1478 }
1479 const responseFiber = yield* runtime
1480 .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" })
1481 .pipe(Effect.forkScoped);
1482 yield* Deferred.await(responseEnqueued);
1483 const interruptFiber = yield* runtime
1484 .interruptTurn({
1485 providerThread,
1486 providerTurnId: pending.runtimeRequest.providerTurnId,
1487 requestRuntimeRestart: true,
1488 })
1489 .pipe(Effect.forkScoped);
1490 yield* Effect.yieldNow;
1491 assert.isUndefined(responseFiber.pollUnsafe());
1492 assert.isUndefined(interruptFiber.pollUnsafe());
1493
1494 yield* Deferred.succeed(releaseResponseAcknowledgement, undefined);
1495 yield* Fiber.join(responseFiber);
1496 yield* Fiber.join(interruptFiber);
1497 }).pipe(Effect.provide(testLayer), Effect.scoped),
1498 );
591 1499
1500 it.live("correlates reordered elicitation schemas through the completed stdout write", () =>
1501 Effect.gen(function* () {
1502 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1503 const fileSystem = yield* FileSystem.FileSystem;
1504 const idAllocator = yield* IdAllocatorV2;
1505 const path = yield* Path.Path;
1506 const serverConfig = yield* ServerConfig;
1507 const mockAgentPath = yield* path.fromFileUrl(
1508 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
1509 );
1510 const responseWritten = yield* Deferred.make<void>();
1511 const releaseResponseAcknowledgement = yield* Deferred.make<void>();
1512 const instanceId = ProviderInstanceId.make("acp-test-reordered-elicitation");
1513 const adapter = makeAcpAdapterV2({
1514 instanceId,
1515 flavor: {
1516 driver: ACP_TEST_DRIVER,
1517 capabilities: AcpProviderCapabilitiesV2,
1518 makeRuntime: makeMockRuntime({
1519 childProcessSpawner,
1520 mockAgentPath,
1521 environment: { T3_ACP_EMIT_ELICITATION: "1" },
1522 wrapIncomingRequest: (onIncomingRequest) => (requestId, method, payload) => {
1523 if (method !== "session/elicitation") {
1524 return onIncomingRequest(requestId, method, payload);
1525 }
1526 const record = payload as Record<string, unknown>;
1527 return onIncomingRequest(requestId, method, {
1528 ...record,
1529 requestedSchema: {
1530 properties: {
1531 approved: { title: "Approved", type: "boolean" },
1532 },
1533 type: "object",
1534 },
1535 });
1536 },
1537 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
1538 Deferred.succeed(responseWritten, undefined).pipe(
1539 Effect.andThen(Deferred.await(releaseResponseAcknowledgement)),
1540 Effect.andThen(onOutgoingResponse(requestId)),
1541 ),
1542 }),
1543 },
1544 fileSystem,
1545 idAllocator,
1546 serverConfig,
1547 });
1548 const threadId = ThreadId.make("thread-acp-reordered-elicitation");
1549 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
1550 runtimeMode: "approval-required",
1551 interactionMode: "default",
1552 cwd: process.cwd(),
1553 });
1554 const modelSelection = { instanceId, model: "default" } as const;
1555 const runtime = yield* adapter.openSession({
1556 threadId,
1557 providerSessionId: ProviderSessionId.make("provider-session-acp-reordered-elicitation"),
1558 modelSelection,
1559 runtimePolicy,
1560 });
1561 const providerThread = yield* runtime.ensureThread({
1562 threadId,
1563 modelSelection,
1564 runtimePolicy,
1565 });
592 yield* runtime.startTurn(1566 yield* runtime.startTurn(
593 makeTurnInput({1567 makeTurnInput({
594 threadId,1568 threadId,
595 providerThread,1569 providerThread,
596 instanceId,1570 instanceId,
597 runtimePolicy,1571 runtimePolicy,
598 now,1572 now: yield* DateTime.now,
599 ordinal: 2,
600 }),1573 }),
601 );1574 );
602 yield* Stream.fromQueue(protocolEvents).pipe(1575 const pending = Option.getOrThrow(
603 Stream.filter(1576 yield* runtime.events.pipe(
604 (event) =>1577 Stream.filter(
605 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",1578 (event) =>
1579 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
1580 ),
1581 Stream.runHead,
606 ),1582 ),
607 Stream.runHead,
608 );1583 );
1584 if (pending.type !== "runtime_request.updated") {
1585 return yield* Effect.die("Expected a pending elicitation request");
1586 }
1587 const responseFiber = yield* runtime
1588 .respondToRuntimeRequest({
1589 requestId: pending.runtimeRequest.id,
1590 answers: { approved: ["true"] },
1591 })
1592 .pipe(Effect.forkScoped);
1593
1594 yield* Deferred.await(responseWritten);
1595 assert.isUndefined(responseFiber.pollUnsafe());
1596 yield* Deferred.succeed(releaseResponseAcknowledgement, undefined);
1597 yield* Fiber.join(responseFiber);
609 }).pipe(Effect.provide(testLayer), Effect.scoped),1598 }).pipe(Effect.provide(testLayer), Effect.scoped),
610 );1599 );
1600
1601 it.live("fails a held native response acknowledgement before normal session close", () =>
1602 Effect.gen(function* () {
1603 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1604 const fileSystem = yield* FileSystem.FileSystem;
1605 const idAllocator = yield* IdAllocatorV2;
1606 const path = yield* Path.Path;
1607 const serverConfig = yield* ServerConfig;
1608 const mockAgentPath = yield* path.fromFileUrl(
1609 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
1610 );
1611 const responseWritten = yield* Deferred.make<void>();
1612 const releaseResponseAcknowledgement = yield* Deferred.make<void>();
1613 const responseLifecycle: Array<string> = [];
1614 const instanceId = ProviderInstanceId.make("acp-test-normal-close-held-response");
1615 const adapter = makeAcpAdapterV2({
1616 instanceId,
1617 flavor: {
1618 driver: ACP_TEST_DRIVER,
1619 capabilities: AcpProviderCapabilitiesV2,
1620 makeRuntime: makeMockRuntime({
1621 childProcessSpawner,
1622 mockAgentPath,
1623 environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
1624 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
1625 Deferred.succeed(responseWritten, undefined).pipe(
1626 Effect.andThen(Deferred.await(releaseResponseAcknowledgement)),
1627 Effect.andThen(onOutgoingResponse(requestId)),
1628 ),
1629 }),
1630 },
1631 fileSystem,
1632 idAllocator,
1633 serverConfig,
1634 testHooks: {
1635 onNativeResponseLifecycle: (event) =>
1636 Effect.sync(() => {
1637 responseLifecycle.push(event.type);
1638 }),
1639 },
1640 });
1641 const threadId = ThreadId.make("thread-acp-normal-close-held-response");
1642 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
1643 runtimeMode: "approval-required",
1644 interactionMode: "default",
1645 cwd: process.cwd(),
1646 });
1647 const modelSelection = { instanceId, model: "default" } as const;
1648 const sessionScope = yield* Scope.make();
1649 const runtime = yield* adapter
1650 .openSession({
1651 threadId,
1652 providerSessionId: ProviderSessionId.make(
1653 "provider-session-acp-normal-close-held-response",
1654 ),
1655 modelSelection,
1656 runtimePolicy,
1657 })
1658 .pipe(Effect.provideService(Scope.Scope, sessionScope));
1659 const providerThread = yield* runtime.ensureThread({
1660 threadId,
1661 modelSelection,
1662 runtimePolicy,
1663 });
1664 yield* runtime.startTurn(
1665 makeTurnInput({
1666 threadId,
1667 providerThread,
1668 instanceId,
1669 runtimePolicy,
1670 now: yield* DateTime.now,
1671 }),
1672 );
1673 const pending = Option.getOrThrow(
1674 yield* runtime.events.pipe(
1675 Stream.filter(
1676 (event) =>
1677 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
1678 ),
1679 Stream.runHead,
1680 ),
1681 );
1682 if (pending.type !== "runtime_request.updated") {
1683 return yield* Effect.die("Expected a pending permission request");
1684 }
1685 const responseFiber = yield* runtime
1686 .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" })
1687 .pipe(Effect.exit, Effect.forkScoped);
1688 yield* Deferred.await(responseWritten);
1689
1690 const closeFiber = yield* Scope.close(sessionScope, Exit.void).pipe(Effect.forkScoped);
1691 while (!responseLifecycle.includes("failed")) {
1692 yield* Effect.yieldNow;
1693 }
1694 const responseExit = yield* Fiber.join(responseFiber);
1695 if (Exit.isSuccess(responseExit)) {
1696 assert.fail("normal close must fail a response whose transport acknowledgement is held");
1697 }
1698 assert.include(Cause.pretty(responseExit.cause), "ACP session transport closed");
1699 assert.include(responseLifecycle, "removed");
1700 assert.include(responseLifecycle, "failed");
1701 yield* Deferred.succeed(releaseResponseAcknowledgement, undefined);
1702 yield* Fiber.join(closeFiber);
1703 }).pipe(Effect.provide(testLayer), Effect.scoped),
1704 );
1705
1706 it.live("rejects delayed native response registration when normal close wins the permit", () =>
1707 Effect.gen(function* () {
1708 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1709 const fileSystem = yield* FileSystem.FileSystem;
1710 const idAllocator = yield* IdAllocatorV2;
1711 const path = yield* Path.Path;
1712 const serverConfig = yield* ServerConfig;
1713 const mockAgentPath = yield* path.fromFileUrl(
1714 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
1715 );
1716 const registrationStarted = yield* Deferred.make<void>();
1717 const releaseRegistration = yield* Deferred.make<void>();
1718 const transportClosed = yield* Deferred.make<void>();
1719 const releaseTransportClose = yield* Deferred.make<void>();
1720 const responseLifecycle: Array<string> = [];
1721 const instanceId = ProviderInstanceId.make("acp-test-close-wins-registration");
1722 const adapter = makeAcpAdapterV2({
1723 instanceId,
1724 flavor: {
1725 driver: ACP_TEST_DRIVER,
1726 capabilities: AcpProviderCapabilitiesV2,
1727 makeRuntime: makeMockRuntime({
1728 childProcessSpawner,
1729 mockAgentPath,
1730 environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
1731 }),
1732 },
1733 fileSystem,
1734 idAllocator,
1735 serverConfig,
1736 testHooks: {
1737 afterNativeResponseTransportClosed: () =>
1738 Deferred.succeed(transportClosed, undefined).pipe(
1739 Effect.andThen(Deferred.await(releaseTransportClose)),
1740 ),
1741 beforeNativeResponseAdmissionCheck: () =>
1742 Deferred.succeed(registrationStarted, undefined).pipe(
1743 Effect.andThen(Deferred.await(releaseRegistration)),
1744 ),
1745 onNativeResponseLifecycle: (event) =>
1746 Effect.sync(() => {
1747 responseLifecycle.push(event.type);
1748 }),
1749 },
1750 });
1751 const threadId = ThreadId.make("thread-acp-close-wins-registration");
1752 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
1753 runtimeMode: "approval-required",
1754 interactionMode: "default",
1755 cwd: process.cwd(),
1756 });
1757 const modelSelection = { instanceId, model: "default" } as const;
1758 const sessionScope = yield* Scope.make();
1759 const runtime = yield* adapter
1760 .openSession({
1761 threadId,
1762 providerSessionId: ProviderSessionId.make("provider-session-acp-close-wins-registration"),
1763 modelSelection,
1764 runtimePolicy,
1765 })
1766 .pipe(Effect.provideService(Scope.Scope, sessionScope));
1767 const providerThread = yield* runtime.ensureThread({
1768 threadId,
1769 modelSelection,
1770 runtimePolicy,
1771 });
1772 yield* runtime.startTurn(
1773 makeTurnInput({
1774 threadId,
1775 providerThread,
1776 instanceId,
1777 runtimePolicy,
1778 now: yield* DateTime.now,
1779 }),
1780 );
1781 const pending = Option.getOrThrow(
1782 yield* runtime.events.pipe(
1783 Stream.filter(
1784 (event) =>
1785 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
1786 ),
1787 Stream.runHead,
1788 ),
1789 );
1790 if (pending.type !== "runtime_request.updated") {
1791 return yield* Effect.die("Expected a pending permission request");
1792 }
1793 const responseFiber = yield* runtime
1794 .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" })
1795 .pipe(Effect.exit, Effect.forkScoped);
1796 yield* Deferred.await(registrationStarted);
1797
1798 const closeFiber = yield* Scope.close(sessionScope, Exit.void).pipe(Effect.forkScoped);
1799 yield* Deferred.await(transportClosed);
1800 yield* Deferred.succeed(releaseRegistration, undefined);
1801 while (!responseLifecycle.includes("admission_rejected")) {
1802 yield* Effect.yieldNow;
1803 }
1804 const responseExit = yield* Fiber.join(responseFiber);
1805 if (Exit.isSuccess(responseExit)) {
1806 assert.fail("normal close must reject a response delayed before transport registration");
1807 }
1808 assert.include(Cause.pretty(responseExit.cause), "ACP session transport closed");
1809 assert.equal(responseLifecycle.filter((event) => event === "registered").length, 1);
1810 assert.equal(responseLifecycle.filter((event) => event === "removed").length, 1);
1811 assert.equal(responseLifecycle.filter((event) => event === "admission_rejected").length, 1);
1812 yield* Deferred.succeed(releaseTransportClose, undefined);
1813 yield* Fiber.join(closeFiber);
1814 }).pipe(Effect.provide(testLayer), Effect.scoped),
1815 );
1816
1817 it.live("bounds a missing pending permission response acknowledgement", () =>
1818 Effect.gen(function* () {
1819 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1820 const fileSystem = yield* FileSystem.FileSystem;
1821 const idAllocator = yield* IdAllocatorV2;
1822 const path = yield* Path.Path;
1823 const serverConfig = yield* ServerConfig;
1824 const mockAgentPath = yield* path.fromFileUrl(
1825 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
1826 );
1827 const responseEnqueued = yield* Deferred.make<void>();
1828 const releaseNativeHook = yield* Deferred.make<void>();
1829 const responseLifecycle: Array<string> = [];
1830 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
1831 const instanceId = ProviderInstanceId.make("acp-test-pending-response-timeout");
1832 const adapter = makeAcpAdapterV2({
1833 instanceId,
1834 flavor: {
1835 driver: ACP_TEST_DRIVER,
1836 capabilities: AcpProviderCapabilitiesV2,
1837 restartRuntimeAfterInterrupt: true,
1838 terminateRuntimeProcessGroupOnInterrupt: true,
1839 makeRuntime: makeMockRuntime({
1840 childProcessSpawner,
1841 mockAgentPath,
1842 environment: {
1843 T3_ACP_EMIT_TOOL_CALLS: "1",
1844 T3_ACP_HANG_AFTER_PERMISSION: "1",
1845 },
1846 ownDetachedProcessGroup: true,
1847 processGroupPlatform: "win32",
1848 protocolEvents,
1849 windowsProcessTreeTerminator: (pid) =>
1850 Deferred.succeed(releaseNativeHook, undefined).pipe(
1851 Effect.andThen(
1852 Effect.sync(() => {
1853 process.kill(pid, "SIGTERM");
1854 }),
1855 ),
1856 ),
1857 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
1858 Deferred.succeed(responseEnqueued, undefined).pipe(
1859 Effect.andThen(Deferred.await(releaseNativeHook)),
1860 Effect.andThen(onOutgoingResponse(requestId)),
1861 ),
1862 }),
1863 },
1864 fileSystem,
1865 idAllocator,
1866 serverConfig,
1867 testHooks: {
1868 onNativeResponseLifecycle: (event) =>
1869 Effect.sync(() => {
1870 responseLifecycle.push(event.type);
1871 }),
1872 },
1873 });
1874 const threadId = ThreadId.make("thread-acp-pending-response-timeout");
1875 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
1876 runtimeMode: "approval-required",
1877 interactionMode: "default",
1878 cwd: process.cwd(),
1879 });
1880 const modelSelection = { instanceId, model: "default" } as const;
1881 const sessionScope = yield* Scope.make();
1882 const runtime = yield* adapter
1883 .openSession({
1884 threadId,
1885 providerSessionId: ProviderSessionId.make(
1886 "provider-session-acp-pending-response-timeout",
1887 ),
1888 modelSelection,
1889 runtimePolicy,
1890 })
1891 .pipe(Effect.provideService(Scope.Scope, sessionScope));
1892 yield* pollProtocolMethods(protocolEvents);
1893 const providerThread = yield* runtime.ensureThread({
1894 threadId,
1895 modelSelection,
1896 runtimePolicy,
1897 });
1898 yield* runtime.startTurn(
1899 makeTurnInput({
1900 threadId,
1901 providerThread,
1902 instanceId,
1903 runtimePolicy,
1904 now: yield* DateTime.now,
1905 }),
1906 );
1907 const pending = Option.getOrThrow(
1908 yield* runtime.events.pipe(
1909 Stream.filter(
1910 (event) =>
1911 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
1912 ),
1913 Stream.runHead,
1914 ),
1915 );
1916 if (
1917 pending.type !== "runtime_request.updated" ||
1918 pending.runtimeRequest.providerTurnId === null
1919 ) {
1920 return yield* Effect.die("Expected a pending permission request");
1921 }
1922 const responseExit = yield* runtime
1923 .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" })
1924 .pipe(Effect.exit);
1925 if (Exit.isSuccess(responseExit)) {
1926 assert.fail("missing native response acknowledgement must fail the pending response");
1927 }
1928 assert.include(Cause.pretty(responseExit.cause), "Native response acknowledgement timed out");
1929 assert.isTrue(yield* Deferred.isDone(responseEnqueued));
1930 assert.isFalse(yield* Deferred.isDone(releaseNativeHook));
1931 assert.isBelow(responseLifecycle.indexOf("removed"), responseLifecycle.indexOf("failed"));
1932 assert.includeMembers(responseLifecycle, [
1933 "registered",
1934 "removed",
1935 "failed",
1936 "timer_exited",
1937 "timer_started",
1938 "watcher_exited",
1939 "watcher_started",
1940 ]);
1941
1942 yield* runtime.interruptTurn({
1943 providerThread,
1944 providerTurnId: pending.runtimeRequest.providerTurnId,
1945 requestRuntimeRestart: true,
1946 });
1947 assert.isTrue(yield* Deferred.isDone(releaseNativeHook));
1948 yield* Effect.sleep("50 millis");
1949 assert.include(responseLifecycle, "late_noop");
1950 yield* Scope.close(sessionScope, Exit.void);
1951 assert.notInclude(yield* pollProtocolMethods(protocolEvents), "session/close");
1952 }).pipe(Effect.provide(testLayer), Effect.scoped),
1953 );
1954
1955 it.live("defers caller cancellation until a pending response acknowledgement is bounded", () =>
1956 Effect.gen(function* () {
1957 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1958 const fileSystem = yield* FileSystem.FileSystem;
1959 const idAllocator = yield* IdAllocatorV2;
1960 const path = yield* Path.Path;
1961 const serverConfig = yield* ServerConfig;
1962 const mockAgentPath = yield* path.fromFileUrl(
1963 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
1964 );
1965 const responseEnqueued = yield* Deferred.make<void>();
1966 const releaseNativeHook = yield* Deferred.make<void>();
1967 const instanceId = ProviderInstanceId.make("acp-test-pending-response-cancel");
1968 const adapter = makeAcpAdapterV2({
1969 instanceId,
1970 flavor: {
1971 driver: ACP_TEST_DRIVER,
1972 capabilities: AcpProviderCapabilitiesV2,
1973 restartRuntimeAfterInterrupt: true,
1974 terminateRuntimeProcessGroupOnInterrupt: true,
1975 makeRuntime: makeMockRuntime({
1976 childProcessSpawner,
1977 mockAgentPath,
1978 environment: {
1979 T3_ACP_EMIT_TOOL_CALLS: "1",
1980 T3_ACP_HANG_AFTER_PERMISSION: "1",
1981 },
1982 ownDetachedProcessGroup: true,
1983 processGroupPlatform: "win32",
1984 windowsProcessTreeTerminator: (pid) =>
1985 Deferred.succeed(releaseNativeHook, undefined).pipe(
1986 Effect.andThen(
1987 Effect.sync(() => {
1988 process.kill(pid, "SIGTERM");
1989 }),
1990 ),
1991 ),
1992 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
1993 Deferred.succeed(responseEnqueued, undefined).pipe(
1994 Effect.andThen(Deferred.await(releaseNativeHook)),
1995 Effect.andThen(onOutgoingResponse(requestId)),
1996 ),
1997 }),
1998 },
1999 fileSystem,
2000 idAllocator,
2001 serverConfig,
2002 });
2003 const threadId = ThreadId.make("thread-acp-pending-response-cancel");
2004 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
2005 runtimeMode: "approval-required",
2006 interactionMode: "default",
2007 cwd: process.cwd(),
2008 });
2009 const modelSelection = { instanceId, model: "default" } as const;
2010 const runtime = yield* adapter.openSession({
2011 threadId,
2012 providerSessionId: ProviderSessionId.make("provider-session-acp-pending-response-cancel"),
2013 modelSelection,
2014 runtimePolicy,
2015 });
2016 const providerThread = yield* runtime.ensureThread({
2017 threadId,
2018 modelSelection,
2019 runtimePolicy,
2020 });
2021 yield* runtime.startTurn(
2022 makeTurnInput({
2023 threadId,
2024 providerThread,
2025 instanceId,
2026 runtimePolicy,
2027 now: yield* DateTime.now,
2028 }),
2029 );
2030 const pending = Option.getOrThrow(
2031 yield* runtime.events.pipe(
2032 Stream.filter(
2033 (event) =>
2034 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
2035 ),
2036 Stream.runHead,
2037 ),
2038 );
2039 if (
2040 pending.type !== "runtime_request.updated" ||
2041 pending.runtimeRequest.providerTurnId === null
2042 ) {
2043 return yield* Effect.die("Expected a pending permission request");
2044 }
2045 const responseFiber = yield* runtime
2046 .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" })
2047 .pipe(Effect.forkScoped);
2048 yield* Deferred.await(responseEnqueued);
2049 const cancellationFiber = yield* Fiber.interrupt(responseFiber).pipe(Effect.forkScoped);
2050 const interruptFiber = yield* runtime
2051 .interruptTurn({
2052 providerThread,
2053 providerTurnId: pending.runtimeRequest.providerTurnId,
2054 requestRuntimeRestart: true,
2055 })
2056 .pipe(Effect.forkScoped);
2057 yield* Effect.sleep("100 millis");
2058 assert.isUndefined(cancellationFiber.pollUnsafe());
2059 assert.isUndefined(interruptFiber.pollUnsafe());
2060 assert.isFalse(yield* Deferred.isDone(releaseNativeHook));
2061
2062 yield* Fiber.join(cancellationFiber);
2063 yield* Fiber.join(interruptFiber);
2064 assert.isTrue(yield* Deferred.isDone(releaseNativeHook));
2065 }).pipe(Effect.provide(testLayer), Effect.scoped),
2066 );
2067
2068 it.live("waits for immediate allow and deny permission responses before hard teardown", () =>
2069 Effect.gen(function* () {
2070 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2071 const fileSystem = yield* FileSystem.FileSystem;
2072 const idAllocator = yield* IdAllocatorV2;
2073 const path = yield* Path.Path;
2074 const serverConfig = yield* ServerConfig;
2075 const mockAgentPath = yield* path.fromFileUrl(
2076 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2077 );
2078
2079 for (const [name, sandboxPolicy] of [
2080 ["allow", undefined],
2081 ["deny", { type: "readOnly" } as const],
2082 ] as const) {
2083 yield* Effect.gen(function* () {
2084 const responseEnqueued = yield* Deferred.make<void>();
2085 const releaseResponseAcknowledgement = yield* Deferred.make<void>();
2086 const instanceId = ProviderInstanceId.make(`acp-test-${name}`);
2087 const adapter = makeAcpAdapterV2({
2088 instanceId,
2089 flavor: {
2090 driver: ACP_TEST_DRIVER,
2091 capabilities: AcpProviderCapabilitiesV2,
2092 restartRuntimeAfterInterrupt: true,
2093 terminateRuntimeProcessGroupOnInterrupt: true,
2094 makeRuntime: makeMockRuntime({
2095 childProcessSpawner,
2096 mockAgentPath,
2097 environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
2098 ownDetachedProcessGroup: true,
2099 processGroupPlatform: "win32",
2100 windowsProcessTreeTerminator: (pid) =>
2101 Effect.sync(() => {
2102 process.kill(pid, "SIGTERM");
2103 }),
2104 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
2105 Deferred.succeed(responseEnqueued, undefined).pipe(
2106 Effect.andThen(Deferred.await(releaseResponseAcknowledgement)),
2107 Effect.andThen(onOutgoingResponse(requestId)),
2108 ),
2109 }),
2110 },
2111 fileSystem,
2112 idAllocator,
2113 serverConfig,
2114 });
2115 const threadId = ThreadId.make(`thread-acp-immediate-permission-${name}`);
2116 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
2117 runtimeMode: "full-access",
2118 interactionMode: "default",
2119 approvalPolicy: "never",
2120 cwd: process.cwd(),
2121 ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }),
2122 });
2123 const modelSelection = { instanceId, model: "default" } as const;
2124 const runtime = yield* adapter.openSession({
2125 threadId,
2126 providerSessionId: ProviderSessionId.make(
2127 `provider-session-acp-immediate-permission-${name}`,
2128 ),
2129 modelSelection,
2130 runtimePolicy,
2131 });
2132 const providerThread = yield* runtime.ensureThread({
2133 threadId,
2134 modelSelection,
2135 runtimePolicy,
2136 });
2137 const turnFiber = yield* runtime
2138 .startTurn(
2139 makeTurnInput({
2140 threadId,
2141 providerThread,
2142 instanceId,
2143 runtimePolicy,
2144 now: yield* DateTime.now,
2145 }),
2146 )
2147 .pipe(Effect.forkDetach);
2148 yield* Deferred.await(responseEnqueued);
2149 const interruptFiber = yield* runtime
2150 .interruptTurn({
2151 providerThread,
2152 providerTurnId: idAllocator.derive.providerTurn({
2153 driver: ACP_TEST_DRIVER,
2154 nativeTurnId: "mock-session-1:turn:1",
2155 }),
2156 requestRuntimeRestart: true,
2157 })
2158 .pipe(Effect.forkScoped);
2159 yield* Effect.yieldNow;
2160 assert.isUndefined(interruptFiber.pollUnsafe());
2161
2162 yield* Deferred.succeed(releaseResponseAcknowledgement, undefined);
2163 yield* Fiber.join(interruptFiber);
2164 yield* Fiber.interrupt(turnFiber).pipe(Effect.forkDetach);
2165 }).pipe(Effect.scoped);
2166 }
2167 }).pipe(Effect.provide(testLayer), Effect.scoped),
2168 );
2169
2170 it.live("waits for immediate URL elicitation responses before hard teardown", () =>
2171 Effect.gen(function* () {
2172 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2173 const fileSystem = yield* FileSystem.FileSystem;
2174 const idAllocator = yield* IdAllocatorV2;
2175 const path = yield* Path.Path;
2176 const serverConfig = yield* ServerConfig;
2177 const mockAgentPath = yield* path.fromFileUrl(
2178 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2179 );
2180 const responseEnqueued = yield* Deferred.make<void>();
2181 const releaseResponseAcknowledgement = yield* Deferred.make<void>();
2182 const instanceId = ProviderInstanceId.make("acp-test-url-elicitation");
2183 const adapter = makeAcpAdapterV2({
2184 instanceId,
2185 flavor: {
2186 driver: ACP_TEST_DRIVER,
2187 capabilities: AcpProviderCapabilitiesV2,
2188 restartRuntimeAfterInterrupt: true,
2189 terminateRuntimeProcessGroupOnInterrupt: true,
2190 makeRuntime: makeMockRuntime({
2191 childProcessSpawner,
2192 mockAgentPath,
2193 environment: { T3_ACP_EMIT_URL_ELICITATION: "1" },
2194 ownDetachedProcessGroup: true,
2195 processGroupPlatform: "win32",
2196 windowsProcessTreeTerminator: (pid) =>
2197 Effect.sync(() => {
2198 process.kill(pid, "SIGTERM");
2199 }),
2200 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
2201 Deferred.succeed(responseEnqueued, undefined).pipe(
2202 Effect.andThen(Deferred.await(releaseResponseAcknowledgement)),
2203 Effect.andThen(onOutgoingResponse(requestId)),
2204 ),
2205 }),
2206 },
2207 fileSystem,
2208 idAllocator,
2209 serverConfig,
2210 });
2211 const threadId = ThreadId.make("thread-acp-immediate-url-elicitation");
2212 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
2213 runtimeMode: "full-access",
2214 interactionMode: "default",
2215 cwd: process.cwd(),
2216 });
2217 const modelSelection = { instanceId, model: "default" } as const;
2218 const runtime = yield* adapter.openSession({
2219 threadId,
2220 providerSessionId: ProviderSessionId.make("provider-session-acp-immediate-url-elicitation"),
2221 modelSelection,
2222 runtimePolicy,
2223 });
2224 const providerThread = yield* runtime.ensureThread({
2225 threadId,
2226 modelSelection,
2227 runtimePolicy,
2228 });
2229 const turnFiber = yield* runtime
2230 .startTurn(
2231 makeTurnInput({
2232 threadId,
2233 providerThread,
2234 instanceId,
2235 runtimePolicy,
2236 now: yield* DateTime.now,
2237 }),
2238 )
2239 .pipe(Effect.forkDetach);
2240 yield* Deferred.await(responseEnqueued);
2241 const interruptFiber = yield* runtime
2242 .interruptTurn({
2243 providerThread,
2244 providerTurnId: idAllocator.derive.providerTurn({
2245 driver: ACP_TEST_DRIVER,
2246 nativeTurnId: "mock-session-1:turn:1",
2247 }),
2248 requestRuntimeRestart: true,
2249 })
2250 .pipe(Effect.forkScoped);
2251 yield* Effect.yieldNow;
2252 assert.isUndefined(interruptFiber.pollUnsafe());
2253
2254 yield* Deferred.succeed(releaseResponseAcknowledgement, undefined);
2255 yield* Fiber.join(interruptFiber);
2256 yield* Fiber.interrupt(turnFiber).pipe(Effect.forkDetach);
2257 }).pipe(Effect.provide(testLayer), Effect.scoped),
2258 );
2259
2260 it.live("bounds a missing immediate response acknowledgement before hard teardown", () =>
2261 Effect.gen(function* () {
2262 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2263 const fileSystem = yield* FileSystem.FileSystem;
2264 const idAllocator = yield* IdAllocatorV2;
2265 const path = yield* Path.Path;
2266 const serverConfig = yield* ServerConfig;
2267 const mockAgentPath = yield* path.fromFileUrl(
2268 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2269 );
2270 const responseEnqueued = yield* Deferred.make<void>();
2271 const releaseNativeHook = yield* Deferred.make<void>();
2272 const instanceId = ProviderInstanceId.make("acp-test-missing-response-ack");
2273 const adapter = makeAcpAdapterV2({
2274 instanceId,
2275 flavor: {
2276 driver: ACP_TEST_DRIVER,
2277 capabilities: AcpProviderCapabilitiesV2,
2278 restartRuntimeAfterInterrupt: true,
2279 terminateRuntimeProcessGroupOnInterrupt: true,
2280 makeRuntime: makeMockRuntime({
2281 childProcessSpawner,
2282 mockAgentPath,
2283 environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
2284 ownDetachedProcessGroup: true,
2285 processGroupPlatform: "win32",
2286 windowsProcessTreeTerminator: (pid) =>
2287 Deferred.succeed(releaseNativeHook, undefined).pipe(
2288 Effect.andThen(
2289 Effect.sync(() => {
2290 process.kill(pid, "SIGTERM");
2291 }),
2292 ),
2293 ),
2294 wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
2295 Deferred.succeed(responseEnqueued, undefined).pipe(
2296 Effect.andThen(Deferred.await(releaseNativeHook)),
2297 Effect.andThen(onOutgoingResponse(requestId)),
2298 ),
2299 }),
2300 },
2301 fileSystem,
2302 idAllocator,
2303 serverConfig,
2304 });
2305 const threadId = ThreadId.make("thread-acp-missing-response-ack");
2306 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
2307 runtimeMode: "full-access",
2308 interactionMode: "default",
2309 approvalPolicy: "never",
2310 cwd: process.cwd(),
2311 });
2312 const modelSelection = { instanceId, model: "default" } as const;
2313 const runtime = yield* adapter.openSession({
2314 threadId,
2315 providerSessionId: ProviderSessionId.make("provider-session-acp-missing-response-ack"),
2316 modelSelection,
2317 runtimePolicy,
2318 });
2319 const providerThread = yield* runtime.ensureThread({
2320 threadId,
2321 modelSelection,
2322 runtimePolicy,
2323 });
2324 const turnFiber = yield* runtime
2325 .startTurn(
2326 makeTurnInput({
2327 threadId,
2328 providerThread,
2329 instanceId,
2330 runtimePolicy,
2331 now: yield* DateTime.now,
2332 }),
2333 )
2334 .pipe(Effect.forkDetach);
2335 yield* Deferred.await(responseEnqueued);
2336 const startedAt = yield* Clock.currentTimeMillis;
2337 yield* runtime.interruptTurn({
2338 providerThread,
2339 providerTurnId: idAllocator.derive.providerTurn({
2340 driver: ACP_TEST_DRIVER,
2341 nativeTurnId: "mock-session-1:turn:1",
2342 }),
2343 requestRuntimeRestart: true,
2344 });
2345 assert.isAtLeast((yield* Clock.currentTimeMillis) - startedAt, 1_500);
2346 yield* Fiber.interrupt(turnFiber).pipe(Effect.forkDetach);
2347 }).pipe(Effect.provide(testLayer), Effect.scoped),
2348 );
2349
2350 it.live("rejects an elicitation response when hard teardown wins admission", () =>
2351 Effect.gen(function* () {
2352 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2353 const fileSystem = yield* FileSystem.FileSystem;
2354 const idAllocator = yield* IdAllocatorV2;
2355 const path = yield* Path.Path;
2356 const serverConfig = yield* ServerConfig;
2357 const mockAgentPath = yield* path.fromFileUrl(
2358 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2359 );
2360 const teardownStarted = yield* Deferred.make<void>();
2361 const releaseTeardown = yield* Deferred.make<void>();
2362 const instanceId = ProviderInstanceId.make("acp-test");
2363 const adapter = makeAcpAdapterV2({
2364 instanceId,
2365 flavor: {
2366 driver: ACP_TEST_DRIVER,
2367 capabilities: AcpProviderCapabilitiesV2,
2368 restartRuntimeAfterInterrupt: true,
2369 terminateRuntimeProcessGroupOnInterrupt: true,
2370 makeRuntime: makeMockRuntime({
2371 childProcessSpawner,
2372 mockAgentPath,
2373 environment: { T3_ACP_EMIT_ELICITATION: "1" },
2374 ownDetachedProcessGroup: true,
2375 processGroupPlatform: "win32",
2376 windowsProcessTreeTerminator: (pid) =>
2377 Deferred.succeed(teardownStarted, undefined).pipe(
2378 Effect.andThen(Deferred.await(releaseTeardown)),
2379 Effect.andThen(
2380 Effect.sync(() => {
2381 process.kill(pid, "SIGTERM");
2382 }),
2383 ),
2384 ),
2385 }),
2386 },
2387 fileSystem,
2388 idAllocator,
2389 serverConfig,
2390 });
2391 const threadId = ThreadId.make("thread-acp-teardown-wins-elicitation");
2392 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
2393 runtimeMode: "approval-required",
2394 interactionMode: "default",
2395 cwd: process.cwd(),
2396 });
2397 const modelSelection = { instanceId, model: "default" } as const;
2398 const runtime = yield* adapter.openSession({
2399 threadId,
2400 providerSessionId: ProviderSessionId.make("provider-session-acp-teardown-wins-elicitation"),
2401 modelSelection,
2402 runtimePolicy,
2403 });
2404 const providerThread = yield* runtime.ensureThread({
2405 threadId,
2406 modelSelection,
2407 runtimePolicy,
2408 });
2409 yield* runtime.startTurn(
2410 makeTurnInput({
2411 threadId,
2412 providerThread,
2413 instanceId,
2414 runtimePolicy,
2415 now: yield* DateTime.now,
2416 }),
2417 );
2418 const pending = Option.getOrThrow(
2419 yield* runtime.events.pipe(
2420 Stream.filter(
2421 (event) =>
2422 event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
2423 ),
2424 Stream.runHead,
2425 ),
2426 );
2427 if (
2428 pending.type !== "runtime_request.updated" ||
2429 pending.runtimeRequest.providerTurnId === null
2430 ) {
2431 return yield* Effect.die("Expected a pending elicitation request");
2432 }
2433 const interruptFiber = yield* runtime
2434 .interruptTurn({
2435 providerThread,
2436 providerTurnId: pending.runtimeRequest.providerTurnId,
2437 requestRuntimeRestart: true,
2438 })
2439 .pipe(Effect.forkScoped);
2440 yield* Deferred.await(teardownStarted);
2441 const responseFiber = yield* runtime
2442 .respondToRuntimeRequest({
2443 requestId: pending.runtimeRequest.id,
2444 answers: { approved: ["true"] },
2445 })
2446 .pipe(Effect.exit, Effect.forkScoped);
2447 yield* Effect.yieldNow;
2448 assert.isUndefined(responseFiber.pollUnsafe());
2449
2450 yield* Deferred.succeed(releaseTeardown, undefined);
2451 yield* Fiber.join(interruptFiber);
2452 const responseExit = yield* Fiber.join(responseFiber);
2453 if (Exit.isSuccess(responseExit)) {
2454 assert.fail("teardown winning admission must reject the elicitation response");
2455 }
2456 assert.include(Cause.pretty(responseExit.cause), "No pending ACP runtime request");
2457 }).pipe(Effect.provide(testLayer), Effect.scoped),
2458 );
2459
2460 it.effect("releases an ACP turn when cancellation times out", () =>
2461 Effect.gen(function* () {
2462 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2463 const fileSystem = yield* FileSystem.FileSystem;
2464 const idAllocator = yield* IdAllocatorV2;
2465 const path = yield* Path.Path;
2466 const serverConfig = yield* ServerConfig;
2467 const mockAgentPath = yield* path.fromFileUrl(
2468 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2469 );
2470 const instanceId = ProviderInstanceId.make("acp-test");
2471 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
2472 const adapter = makeAcpAdapterV2({
2473 instanceId,
2474 flavor: {
2475 driver: ACP_TEST_DRIVER,
2476 capabilities: AcpProviderCapabilitiesV2,
2477 makeRuntime: makeMockRuntime({
2478 childProcessSpawner,
2479 mockAgentPath,
2480 environment: { T3_ACP_PROMPT_DELAY_MS: "5000" },
2481 protocolEvents,
2482 }),
2483 },
2484 fileSystem,
2485 idAllocator,
2486 serverConfig,
2487 });
2488 const threadId = ThreadId.make("thread-acp-cancel-timeout");
2489 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
2490 runtimeMode: "full-access",
2491 interactionMode: "default",
2492 cwd: process.cwd(),
2493 });
2494 const modelSelection = { instanceId, model: "default" } as const;
2495 const runtime = yield* adapter.openSession({
2496 threadId,
2497 providerSessionId: ProviderSessionId.make("provider-session-acp-cancel-timeout"),
2498 modelSelection,
2499 runtimePolicy,
2500 });
2501 const providerThread = yield* runtime.ensureThread({
2502 threadId,
2503 modelSelection,
2504 runtimePolicy,
2505 });
2506 const now = yield* DateTime.now;
2507 const firstTurn = makeTurnInput({
2508 threadId,
2509 providerThread,
2510 instanceId,
2511 runtimePolicy,
2512 now,
2513 });
2514 yield* runtime.startTurn(firstTurn);
2515 yield* Stream.fromQueue(protocolEvents).pipe(
2516 Stream.filter(
2517 (event) =>
2518 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
2519 ),
2520 Stream.runHead,
2521 );
2522 const providerTurnId = idAllocator.derive.providerTurn({
2523 driver: ACP_TEST_DRIVER,
2524 nativeTurnId: "mock-session-1:turn:1",
2525 });
2526 const interruptFiber = yield* runtime
2527 .interruptTurn({ providerThread, providerTurnId })
2528 .pipe(Effect.flip, Effect.forkScoped);
2529 yield* Stream.fromQueue(protocolEvents).pipe(
2530 Stream.filter(
2531 (event) =>
2532 event.direction === "outgoing" && rawProtocolMethod(event) === "session/cancel",
2533 ),
2534 Stream.runHead,
2535 );
2536 yield* TestClock.adjust("10 seconds");
2537 const interruptError = yield* Fiber.join(interruptFiber);
2538 assert.equal(interruptError._tag, "ProviderAdapterInterruptError");
2539
2540 yield* runtime.startTurn(
2541 makeTurnInput({
2542 threadId,
2543 providerThread,
2544 instanceId,
2545 runtimePolicy,
2546 now,
2547 ordinal: 2,
2548 }),
2549 );
2550 yield* Stream.fromQueue(protocolEvents).pipe(
2551 Stream.filter(
2552 (event) =>
2553 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
2554 ),
2555 Stream.runHead,
2556 );
2557 }).pipe(Effect.provide(testLayer), Effect.scoped),
2558 );
2559
2560 it.live("treats a second hard Stop as success when the turn is already gone", () =>
2561 Effect.gen(function* () {
2562 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2563 const fileSystem = yield* FileSystem.FileSystem;
2564 const idAllocator = yield* IdAllocatorV2;
2565 const path = yield* Path.Path;
2566 const serverConfig = yield* ServerConfig;
2567 const mockAgentPath = yield* path.fromFileUrl(
2568 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2569 );
2570 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
2571 const instanceId = ProviderInstanceId.make("acp-test");
2572 const adapter = makeAcpAdapterV2({
2573 instanceId,
2574 flavor: {
2575 driver: ACP_TEST_DRIVER,
2576 capabilities: AcpProviderCapabilitiesV2,
2577 interruptPromptOnCancel: false,
2578 restartRuntimeAfterInterrupt: true,
2579 terminateRuntimeProcessGroupOnInterrupt: true,
2580 makeRuntime: makeMockRuntime({
2581 childProcessSpawner,
2582 mockAgentPath,
2583 ownDetachedProcessGroup: true,
2584 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
2585 protocolEvents,
2586 }),
2587 },
2588 fileSystem,
2589 idAllocator,
2590 serverConfig,
2591 });
2592 const threadId = ThreadId.make("thread-acp-double-stop");
2593 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
2594 runtimeMode: "full-access",
2595 interactionMode: "default",
2596 cwd: process.cwd(),
2597 });
2598 const modelSelection = { instanceId, model: "default" } as const;
2599 const runtime = yield* adapter.openSession({
2600 threadId,
2601 providerSessionId: ProviderSessionId.make("provider-session-acp-double-stop"),
2602 modelSelection,
2603 runtimePolicy,
2604 });
2605 const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
2606 yield* runtime.events.pipe(
2607 Stream.runForEach((event) => Queue.offer(events, event)),
2608 Effect.forkScoped,
2609 );
2610 const providerThread = yield* runtime.ensureThread({
2611 threadId,
2612 modelSelection,
2613 runtimePolicy,
2614 });
2615 const now = yield* DateTime.now;
2616 yield* runtime
2617 .startTurn(makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }))
2618 .pipe(Effect.forkScoped);
2619 yield* Stream.fromQueue(protocolEvents).pipe(
2620 Stream.filter(
2621 (event) =>
2622 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
2623 ),
2624 Stream.runHead,
2625 );
2626 const providerTurnId = idAllocator.derive.providerTurn({
2627 driver: ACP_TEST_DRIVER,
2628 nativeTurnId: "mock-session-1:turn:1",
2629 });
2630 yield* runtime.interruptTurn({
2631 providerThread,
2632 providerTurnId,
2633 requestRuntimeRestart: true,
2634 });
2635 // Second durable interrupt after activeTurn is cleared must not fail.
2636 const second = yield* Effect.exit(
2637 runtime.interruptTurn({
2638 providerThread,
2639 providerTurnId,
2640 requestRuntimeRestart: true,
2641 }),
2642 );
2643 assert.isTrue(Exit.isSuccess(second), "duplicate hard Stop must be idempotent");
2644 let terminal: string | null = null;
2645 while (terminal === null) {
2646 const event = yield* Queue.take(events);
2647 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
2648 terminal = event.status;
2649 }
2650 }
2651 assert.equal(terminal, "interrupted");
2652 }).pipe(Effect.provide(testLayer), Effect.scoped),
2653 );
2654
2655 it.effect("finalizes a settled turn held open for background work when interrupted", () =>
2656 Effect.gen(function* () {
2657 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2658 const fileSystem = yield* FileSystem.FileSystem;
2659 const idAllocator = yield* IdAllocatorV2;
2660 const path = yield* Path.Path;
2661 const serverConfig = yield* ServerConfig;
2662 const mockAgentPath = yield* path.fromFileUrl(
2663 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2664 );
2665 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
2666 const instanceId = ProviderInstanceId.make("acp-test");
2667 const adapter = makeAcpAdapterV2({
2668 instanceId,
2669 flavor: {
2670 driver: ACP_TEST_DRIVER,
2671 capabilities: AcpProviderCapabilitiesV2,
2672 deferFinalizeForBackgroundWork: true,
2673 extractSubagentUpdate: (toolCall) =>
2674 toolCall.toolCallId === "tool-call-generic-1"
2675 ? {
2676 nativeTaskId: "task-generic-1",
2677 prompt: "background subagent",
2678 title: "background subagent",
2679 model: null,
2680 status: "running",
2681 childSessionId: null,
2682 result: null,
2683 }
2684 : undefined,
2685 makeRuntime: makeMockRuntime({
2686 childProcessSpawner,
2687 mockAgentPath,
2688 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
2689 protocolEvents,
2690 }),
2691 },
2692 fileSystem,
2693 idAllocator,
2694 serverConfig,
2695 });
2696 const threadId = ThreadId.make("thread-acp-interrupt-background-hold");
2697 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
2698 runtimeMode: "full-access",
2699 interactionMode: "default",
2700 cwd: process.cwd(),
2701 });
2702 const modelSelection = { instanceId, model: "default" } as const;
2703 const runtime = yield* adapter.openSession({
2704 threadId,
2705 providerSessionId: ProviderSessionId.make("provider-session-acp-interrupt-background"),
2706 modelSelection,
2707 runtimePolicy,
2708 });
2709 const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
2710 yield* runtime.events.pipe(
2711 Stream.runForEach((event) => Queue.offer(events, event)),
2712 Effect.forkScoped,
2713 );
2714 const providerThread = yield* runtime.ensureThread({
2715 threadId,
2716 modelSelection,
2717 runtimePolicy,
2718 });
2719 const now = yield* DateTime.now;
2720 yield* runtime.startTurn(
2721 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
2722 );
2723 // The still-running subagent defers finalize after session/prompt returns.
2724 yield* Stream.fromQueue(protocolEvents).pipe(
2725 Stream.filter(
2726 (event) =>
2727 event.direction === "incoming" &&
2728 event.stage === "raw" &&
2729 typeof event.payload === "string" &&
2730 event.payload.includes('"stopReason"'),
2731 ),
2732 Stream.runHead,
2733 );
2734 yield* Effect.yieldNow;
2735 yield* Effect.yieldNow;
2736
2737 const providerTurnId = idAllocator.derive.providerTurn({
2738 driver: ACP_TEST_DRIVER,
2739 nativeTurnId: "mock-session-1:turn:1",
2740 });
2741 const interruptFiber = yield* runtime
2742 .interruptTurn({ providerThread, providerTurnId })
2743 .pipe(Effect.forkScoped);
2744 yield* TestClock.adjust("10 seconds");
2745 yield* Fiber.join(interruptFiber);
2746
2747 let terminalStatus: string | null = null;
2748 while (terminalStatus === null) {
2749 const event = yield* Queue.take(events);
2750 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
2751 terminalStatus = event.status;
2752 }
2753 }
2754 assert.equal(terminalStatus, "interrupted");
2755 }).pipe(Effect.provide(testLayer), Effect.scoped),
2756 );
2757
2758 it.effect(
2759 "carries a live subagent lineage across an interrupt so the next turn can complete it",
2760 () =>
2761 Effect.gen(function* () {
2762 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2763 const fileSystem = yield* FileSystem.FileSystem;
2764 const idAllocator = yield* IdAllocatorV2;
2765 const path = yield* Path.Path;
2766 const serverConfig = yield* ServerConfig;
2767 const mockAgentPath = yield* path.fromFileUrl(
2768 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2769 );
2770 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
2771 const instanceId = ProviderInstanceId.make("acp-test");
2772 let subagentPhase: "spawn" | "complete" = "spawn";
2773 const adapter = makeAcpAdapterV2({
2774 instanceId,
2775 flavor: {
2776 driver: ACP_TEST_DRIVER,
2777 capabilities: AcpProviderCapabilitiesV2,
2778 deferFinalizeForBackgroundWork: true,
2779 extractSubagentUpdate: (toolCall) =>
2780 toolCall.toolCallId !== "tool-call-generic-1"
2781 ? undefined
2782 : subagentPhase === "spawn"
2783 ? {
2784 nativeTaskId: "task-generic-1",
2785 prompt: "background subagent",
2786 title: "background subagent",
2787 model: null,
2788 status: "running",
2789 childSessionId: null,
2790 result: null,
2791 }
2792 : // Hydration-only shape (empty prompt, null title): without a
2793 // carried-over lineage this update is dropped and the item
2794 // stays running forever.
2795 {
2796 nativeTaskId: "task-generic-1",
2797 prompt: "",
2798 title: null,
2799 model: null,
2800 status: "completed",
2801 childSessionId: null,
2802 result: "SUB_DONE",
2803 },
2804 makeRuntime: makeMockRuntime({
2805 childProcessSpawner,
2806 mockAgentPath,
2807 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
2808 protocolEvents,
2809 }),
2810 },
2811 fileSystem,
2812 idAllocator,
2813 serverConfig,
2814 });
2815 const threadId = ThreadId.make("thread-acp-subagent-carryover");
2816 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
2817 runtimeMode: "full-access",
2818 interactionMode: "default",
2819 cwd: process.cwd(),
2820 });
2821 const modelSelection = { instanceId, model: "default" } as const;
2822 const runtime = yield* adapter.openSession({
2823 threadId,
2824 providerSessionId: ProviderSessionId.make("provider-session-acp-subagent-carryover"),
2825 modelSelection,
2826 runtimePolicy,
2827 });
2828 const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
2829 yield* runtime.events.pipe(
2830 Stream.runForEach((event) => Queue.offer(events, event)),
2831 Effect.forkScoped,
2832 );
2833 const providerThread = yield* runtime.ensureThread({
2834 threadId,
2835 modelSelection,
2836 runtimePolicy,
2837 });
2838 const now = yield* DateTime.now;
2839 yield* runtime.startTurn(
2840 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
2841 );
2842 yield* Stream.fromQueue(protocolEvents).pipe(
2843 Stream.filter(
2844 (event) =>
2845 event.direction === "incoming" &&
2846 event.stage === "raw" &&
2847 typeof event.payload === "string" &&
2848 event.payload.includes('"stopReason"'),
2849 ),
2850 Stream.runHead,
2851 );
2852 yield* Effect.yieldNow;
2853 yield* Effect.yieldNow;
2854
2855 const firstProviderTurnId = idAllocator.derive.providerTurn({
2856 driver: ACP_TEST_DRIVER,
2857 nativeTurnId: "mock-session-1:turn:1",
2858 });
2859 const interruptFiber = yield* runtime
2860 .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId })
2861 .pipe(Effect.forkScoped);
2862 yield* TestClock.adjust("10 seconds");
2863 yield* Fiber.join(interruptFiber);
2864
2865 let subagentTurnItemId: string | null = null;
2866 let firstTerminalStatus: string | null = null;
2867 while (firstTerminalStatus === null) {
2868 const event = yield* Queue.take(events);
2869 if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") {
2870 subagentTurnItemId = event.turnItem.id;
2871 }
2872 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
2873 firstTerminalStatus = event.status;
2874 }
2875 }
2876 assert.equal(firstTerminalStatus, "interrupted");
2877 assert.notEqual(subagentTurnItemId, null);
2878
2879 subagentPhase = "complete";
2880 const secondNow = yield* DateTime.now;
2881 yield* runtime.startTurn(
2882 makeTurnInput({
2883 threadId,
2884 providerThread,
2885 instanceId,
2886 runtimePolicy,
2887 now: secondNow,
2888 ordinal: 2,
2889 }),
2890 );
2891 const secondProviderTurnId = idAllocator.derive.providerTurn({
2892 driver: ACP_TEST_DRIVER,
2893 nativeTurnId: "mock-session-1:turn:2",
2894 });
2895 let carriedItemStatus: string | null = null;
2896 let secondTerminalStatus: string | null = null;
2897 while (secondTerminalStatus === null) {
2898 const event = yield* Queue.take(events);
2899 if (
2900 event.type === "turn_item.updated" &&
2901 event.turnItem.type === "subagent" &&
2902 event.turnItem.id === subagentTurnItemId
2903 ) {
2904 carriedItemStatus = event.turnItem.status;
2905 }
2906 if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) {
2907 secondTerminalStatus = event.status;
2908 }
2909 }
2910 assert.equal(carriedItemStatus, "completed");
2911 assert.equal(secondTerminalStatus, "completed");
2912 }).pipe(Effect.provide(testLayer), Effect.scoped),
2913 );
2914
2915 it.effect(
2916 "preserveRuntimeOnSettledInterrupt keeps the process alive and carries subagents through a settled steering interrupt",
2917 () =>
2918 Effect.gen(function* () {
2919 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2920 const fileSystem = yield* FileSystem.FileSystem;
2921 const idAllocator = yield* IdAllocatorV2;
2922 const path = yield* Path.Path;
2923 const serverConfig = yield* ServerConfig;
2924 const mockAgentPath = yield* path.fromFileUrl(
2925 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2926 );
2927 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
2928 const instanceId = ProviderInstanceId.make("acp-test");
2929 let subagentPhase: "spawn" | "complete" = "spawn";
2930 let cancelCalled = false;
2931 let runtimeOrdinalSeen = 0;
2932 const adapter = makeAcpAdapterV2({
2933 instanceId,
2934 flavor: {
2935 driver: ACP_TEST_DRIVER,
2936 capabilities: AcpProviderCapabilitiesV2,
2937 deferFinalizeForBackgroundWork: true,
2938 // Hard interrupt flags (stricter than production Grok, which no
2939 // longer sets restartRuntimeOnEveryInterrupt): every interrupt
2940 // would hard-kill the process group without the settled-soft gate
2941 // under test.
2942 restartRuntimeAfterInterrupt: true,
2943 restartRuntimeOnEveryInterrupt: true,
2944 terminateRuntimeProcessGroupOnInterrupt: true,
2945 preserveRuntimeOnSettledInterrupt: true,
2946 extractSubagentUpdate: (toolCall) =>
2947 toolCall.toolCallId !== "tool-call-generic-1"
2948 ? undefined
2949 : subagentPhase === "spawn"
2950 ? {
2951 nativeTaskId: "task-generic-1",
2952 prompt: "background subagent",
2953 title: "background subagent",
2954 model: null,
2955 status: "running",
2956 childSessionId: null,
2957 result: null,
2958 }
2959 : {
2960 nativeTaskId: "task-generic-1",
2961 prompt: "",
2962 title: null,
2963 model: null,
2964 status: "completed",
2965 childSessionId: null,
2966 result: "SUB_DONE",
2967 },
2968 // No ownDetachedProcessGroup: if the interrupt wrongly takes the
2969 // hard path, terminateProcessGroup is missing and the interrupt
2970 // fails loudly with a poisoned session.
2971 makeRuntime: makeMockRuntime({
2972 childProcessSpawner,
2973 mockAgentPath,
2974 environment: (runtimeOrdinal) => {
2975 runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal);
2976 return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" };
2977 },
2978 protocolEvents,
2979 wrapCancel: (cancel) =>
2980 Effect.sync(() => {
2981 cancelCalled = true;
2982 }).pipe(Effect.andThen(cancel)),
2983 }),
2984 },
2985 fileSystem,
2986 idAllocator,
2987 serverConfig,
2988 });
2989 const threadId = ThreadId.make("thread-acp-settled-soft-steer");
2990 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
2991 runtimeMode: "full-access",
2992 interactionMode: "default",
2993 cwd: process.cwd(),
2994 });
2995 const modelSelection = { instanceId, model: "default" } as const;
2996 const runtime = yield* adapter.openSession({
2997 threadId,
2998 providerSessionId: ProviderSessionId.make("provider-session-acp-settled-soft-steer"),
2999 modelSelection,
3000 runtimePolicy,
3001 });
3002 const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
3003 yield* runtime.events.pipe(
3004 Stream.runForEach((event) => Queue.offer(events, event)),
3005 Effect.forkScoped,
3006 );
3007 const providerThread = yield* runtime.ensureThread({
3008 threadId,
3009 modelSelection,
3010 runtimePolicy,
3011 });
3012 const now = yield* DateTime.now;
3013 yield* runtime.startTurn(
3014 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
3015 );
3016 // The still-running subagent defers finalize after session/prompt returns,
3017 // so the interrupt below hits a settled turn held open for background work.
3018 yield* Stream.fromQueue(protocolEvents).pipe(
3019 Stream.filter(
3020 (event) =>
3021 event.direction === "incoming" &&
3022 event.stage === "raw" &&
3023 typeof event.payload === "string" &&
3024 event.payload.includes('"stopReason"'),
3025 ),
3026 Stream.runHead,
3027 );
3028 yield* Effect.yieldNow;
3029 yield* Effect.yieldNow;
3030
3031 const firstProviderTurnId = idAllocator.derive.providerTurn({
3032 driver: ACP_TEST_DRIVER,
3033 nativeTurnId: "mock-session-1:turn:1",
3034 });
3035 const interruptFiber = yield* runtime
3036 .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId })
3037 .pipe(Effect.forkScoped);
3038 yield* TestClock.adjust("10 seconds");
3039 yield* Fiber.join(interruptFiber);
3040 assert.isFalse(
3041 cancelCalled,
3042 "settled soft steer must not send session/cancel (the real Grok CLI kills background subagents on cancel)",
3043 );
3044
3045 let subagentTurnItemId: string | null = null;
3046 let firstTerminalStatus: string | null = null;
3047 while (firstTerminalStatus === null) {
3048 const event = yield* Queue.take(events);
3049 if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") {
3050 subagentTurnItemId = event.turnItem.id;
3051 }
3052 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
3053 firstTerminalStatus = event.status;
3054 }
3055 }
3056 assert.equal(firstTerminalStatus, "interrupted");
3057 assert.notEqual(subagentTurnItemId, null);
3058
3059 subagentPhase = "complete";
3060 const secondNow = yield* DateTime.now;
3061 yield* runtime.startTurn(
3062 makeTurnInput({
3063 threadId,
3064 providerThread,
3065 instanceId,
3066 runtimePolicy,
3067 now: secondNow,
3068 ordinal: 2,
3069 }),
3070 );
3071 // Same runtime process (mock-session-1): a respawn would start
3072 // mock-session-2 and drop the carryover on the session mismatch.
3073 const secondProviderTurnId = idAllocator.derive.providerTurn({
3074 driver: ACP_TEST_DRIVER,
3075 nativeTurnId: "mock-session-1:turn:2",
3076 });
3077 let carriedItemStatus: string | null = null;
3078 let secondTerminalStatus: string | null = null;
3079 while (secondTerminalStatus === null) {
3080 const event = yield* Queue.take(events);
3081 if (
3082 event.type === "turn_item.updated" &&
3083 event.turnItem.type === "subagent" &&
3084 event.turnItem.id === subagentTurnItemId
3085 ) {
3086 carriedItemStatus = event.turnItem.status;
3087 }
3088 if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) {
3089 secondTerminalStatus = event.status;
3090 }
3091 }
3092 assert.equal(carriedItemStatus, "completed");
3093 assert.equal(secondTerminalStatus, "completed");
3094 assert.equal(
3095 runtimeOrdinalSeen,
3096 1,
3097 "settled soft steer must not respawn the ACP runtime process",
3098 );
3099 }).pipe(Effect.provide(testLayer), Effect.scoped),
3100 );
3101
3102 it.live("preserveRuntimeOnSettledInterrupt does not soften a mid-prompt steering interrupt", () =>
3103 Effect.gen(function* () {
3104 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3105 const fileSystem = yield* FileSystem.FileSystem;
3106 const idAllocator = yield* IdAllocatorV2;
3107 const path = yield* Path.Path;
3108 const serverConfig = yield* ServerConfig;
3109 const mockAgentPath = yield* path.fromFileUrl(
3110 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3111 );
3112 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
3113 const instanceId = ProviderInstanceId.make("acp-test");
3114 const adapter = makeAcpAdapterV2({
3115 instanceId,
3116 flavor: {
3117 driver: ACP_TEST_DRIVER,
3118 capabilities: AcpProviderCapabilitiesV2,
3119 restartRuntimeAfterInterrupt: true,
3120 // Local hard-flavor gate: production Grok no longer sets
3121 // restartRuntimeOnEveryInterrupt, but when a flavor does, the
3122 // settled-soft gate must not leak onto an unsettled prompt.
3123 restartRuntimeOnEveryInterrupt: true,
3124 terminateRuntimeProcessGroupOnInterrupt: true,
3125 preserveRuntimeOnSettledInterrupt: true,
3126 // No ownDetachedProcessGroup: the expected hard path fails loudly
3127 // on the missing terminateProcessGroup, proving the settled-soft
3128 // gate did not apply to an unsettled prompt.
3129 makeRuntime: makeMockRuntime({
3130 childProcessSpawner,
3131 mockAgentPath,
3132 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
3133 protocolEvents,
3134 }),
3135 },
3136 fileSystem,
3137 idAllocator,
3138 serverConfig,
3139 });
3140 const threadId = ThreadId.make("thread-acp-unsettled-steer-stays-hard");
3141 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
3142 runtimeMode: "full-access",
3143 interactionMode: "default",
3144 cwd: process.cwd(),
3145 });
3146 const modelSelection = { instanceId, model: "default" } as const;
3147 const runtime = yield* adapter.openSession({
3148 threadId,
3149 providerSessionId: ProviderSessionId.make(
3150 "provider-session-acp-unsettled-steer-stays-hard",
3151 ),
3152 modelSelection,
3153 runtimePolicy,
3154 });
3155 const providerThread = yield* runtime.ensureThread({
3156 threadId,
3157 modelSelection,
3158 runtimePolicy,
3159 });
3160 const now = yield* DateTime.now;
3161 yield* runtime
3162 .startTurn(
3163 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
3164 )
3165 .pipe(Effect.forkScoped);
3166 yield* Stream.fromQueue(protocolEvents).pipe(
3167 Stream.filter(
3168 (event) =>
3169 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
3170 ),
3171 Stream.runHead,
3172 );
3173 const providerTurnId = idAllocator.derive.providerTurn({
3174 driver: ACP_TEST_DRIVER,
3175 nativeTurnId: "mock-session-1:turn:1",
3176 });
3177 const interruptExit = yield* runtime
3178 .interruptTurn({ providerThread, providerTurnId })
3179 .pipe(Effect.exit);
3180 if (Exit.isSuccess(interruptExit)) {
3181 assert.fail("mid-prompt steering interrupt must still take the hard teardown path");
3182 }
3183 assert.include(Cause.pretty(interruptExit.cause), "session is poisoned");
3184 }).pipe(Effect.provide(testLayer), Effect.scoped),
3185 );
3186
3187 it.live(
3188 "soft mid-prompt interrupt cancels in place, reuses the runtime, and tracks cancel-backgrounded work",
3189 () =>
3190 Effect.gen(function* () {
3191 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3192 const fileSystem = yield* FileSystem.FileSystem;
3193 const idAllocator = yield* IdAllocatorV2;
3194 const path = yield* Path.Path;
3195 const serverConfig = yield* ServerConfig;
3196 const mockAgentPath = yield* path.fromFileUrl(
3197 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3198 );
3199 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
3200 const continuationRequests: Array<ProviderContinuationRequest> = [];
3201 const instanceId = ProviderInstanceId.make("acp-test");
3202 let cancelCalled = false;
3203 let runtimeOrdinalSeen = 0;
3204 const adapter = makeAcpAdapterV2({
3205 instanceId,
3206 flavor: {
3207 driver: ACP_TEST_DRIVER,
3208 capabilities: AcpProviderCapabilitiesV2,
3209 enablePostSettleContinuation: true,
3210 // Production Grok interrupt flags: hard teardown only with
3211 // requestRuntimeRestart (user Stop). Without
3212 // restartRuntimeOnEveryInterrupt a mid-prompt steering interrupt
3213 // stays soft: session/cancel, same process, session reuse.
3214 restartRuntimeAfterInterrupt: true,
3215 terminateRuntimeProcessGroupOnInterrupt: true,
3216 preserveRuntimeOnSettledInterrupt: true,
3217 registerExtensions: ({ runtime: extensionRuntime, applyBackgroundTaskMutation }) =>
3218 registerXAiBackgroundTaskTracking(extensionRuntime, applyBackgroundTaskMutation),
3219 // No ownDetachedProcessGroup: if the interrupt wrongly takes the
3220 // hard path, terminateProcessGroup is missing and the interrupt
3221 // fails loudly with a poisoned session.
3222 makeRuntime: makeMockRuntime({
3223 childProcessSpawner,
3224 mockAgentPath,
3225 environment: (runtimeOrdinal) => {
3226 runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal);
3227 return {
3228 T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG_FIRST_PROMPT: "1",
3229 T3_ACP_EMIT_TASK_BACKGROUNDED_AFTER_CANCEL: "1",
3230 };
3231 },
3232 protocolEvents,
3233 wrapCancel: (cancel) =>
3234 Effect.sync(() => {
3235 cancelCalled = true;
3236 }).pipe(Effect.andThen(cancel)),
3237 }),
3238 },
3239 fileSystem,
3240 idAllocator,
3241 serverConfig,
3242 continuationRequests: {
3243 offer: (request) =>
3244 Effect.sync(() => {
3245 continuationRequests.push(request);
3246 }),
3247 },
3248 });
3249 const threadId = ThreadId.make("thread-acp-soft-mid-prompt-steer");
3250 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
3251 runtimeMode: "full-access",
3252 interactionMode: "default",
3253 cwd: process.cwd(),
3254 });
3255 const modelSelection = { instanceId, model: "default" } as const;
3256 const runtime = yield* adapter.openSession({
3257 threadId,
3258 providerSessionId: ProviderSessionId.make("provider-session-acp-soft-mid-prompt-steer"),
3259 modelSelection,
3260 runtimePolicy,
3261 });
3262 if (runtime.hasPendingBackgroundWork === undefined) {
3263 return yield* Effect.die(
3264 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
3265 );
3266 }
3267 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
3268 const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
3269 yield* runtime.events.pipe(
3270 Stream.runForEach((event) => Queue.offer(events, event)),
3271 Effect.forkScoped,
3272 );
3273 const providerThread = yield* runtime.ensureThread({
3274 threadId,
3275 modelSelection,
3276 runtimePolicy,
3277 });
3278 const now = yield* DateTime.now;
3279 yield* runtime
3280 .startTurn(
3281 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
3282 )
3283 .pipe(Effect.forkScoped);
3284 // Wait for the running command tool so the interrupt lands mid-prompt.
3285 yield* Stream.fromQueue(protocolEvents).pipe(
3286 Stream.filter(
3287 (event) =>
3288 event.direction === "incoming" &&
3289 event.stage === "raw" &&
3290 typeof event.payload === "string" &&
3291 event.payload.includes("tool-call-running-1"),
3292 ),
3293 Stream.runHead,
3294 );
3295
3296 const firstProviderTurnId = idAllocator.derive.providerTurn({
3297 driver: ACP_TEST_DRIVER,
3298 nativeTurnId: "mock-session-1:turn:1",
3299 });
3300 // No requestRuntimeRestart: a steering interrupt, not a user Stop.
3301 yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId });
3302 assert.isTrue(
3303 cancelCalled,
3304 "soft mid-prompt interrupt must send session/cancel to detach the running work",
3305 );
3306
3307 let firstTerminalStatus: string | null = null;
3308 while (firstTerminalStatus === null) {
3309 const event = yield* Queue.take(events);
3310 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
3311 firstTerminalStatus = event.status;
3312 }
3313 }
3314 assert.equal(firstTerminalStatus, "interrupted");
3315 // The cancel handler emitted _x.ai/task_backgrounded for the detached
3316 // command; the tracked task must report as pending background work.
3317 let backgroundTracked = false;
3318 for (let attempt = 0; attempt < 80 && !backgroundTracked; attempt += 1) {
3319 backgroundTracked = yield* hasPendingBackgroundWork;
3320 if (!backgroundTracked) {
3321 yield* Effect.sleep("25 millis");
3322 }
3323 }
3324 assert.isTrue(
3325 backgroundTracked,
3326 "cancel-backgrounded task must be tracked as running background work",
3327 );
3328
3329 // The second prompt reuses the same process and session.
3330 const secondNow = yield* DateTime.now;
3331 yield* runtime.startTurn(
3332 makeTurnInput({
3333 threadId,
3334 providerThread,
3335 instanceId,
3336 runtimePolicy,
3337 now: secondNow,
3338 ordinal: 2,
3339 }),
3340 );
3341 const secondProviderTurnId = idAllocator.derive.providerTurn({
3342 driver: ACP_TEST_DRIVER,
3343 nativeTurnId: "mock-session-1:turn:2",
3344 });
3345 let secondTerminalStatus: string | null = null;
3346 while (secondTerminalStatus === null) {
3347 const event = yield* Queue.take(events);
3348 if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) {
3349 secondTerminalStatus = event.status;
3350 }
3351 }
3352 assert.equal(secondTerminalStatus, "completed");
3353 assert.equal(
3354 runtimeOrdinalSeen,
3355 1,
3356 "soft mid-prompt interrupt must not respawn the ACP runtime process",
3357 );
3358
3359 // _x.ai/task_completed lands ~1.2s after the cancel and clears the
3360 // tracked task without opening a synthetic continuation run.
3361 let backgroundPending = true;
3362 for (let attempt = 0; attempt < 50 && backgroundPending; attempt += 1) {
3363 backgroundPending = yield* hasPendingBackgroundWork;
3364 if (backgroundPending) {
3365 yield* Effect.sleep("100 millis");
3366 }
3367 }
3368 assert.isFalse(
3369 backgroundPending,
3370 "tracked background task must clear after _x.ai/task_completed",
3371 );
3372 assert.lengthOf(
3373 continuationRequests,
3374 0,
3375 "a cancel-backgrounded task completion must not wake a synthetic continuation run",
3376 );
3377 }).pipe(Effect.provide(testLayer), Effect.scoped),
3378 );
3379
3380 it.live("direct Stop quarantine drops late background task mutations from the stopped run", () =>
3381 Effect.gen(function* () {
3382 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3383 const fileSystem = yield* FileSystem.FileSystem;
3384 const idAllocator = yield* IdAllocatorV2;
3385 const path = yield* Path.Path;
3386 const serverConfig = yield* ServerConfig;
3387 const mockAgentPath = yield* path.fromFileUrl(
3388 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3389 );
3390 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
3391 const instanceId = ProviderInstanceId.make("acp-test");
3392 const capturedMutation: {
3393 current:
3394 | ((mutation: {
3395 readonly sessionId: string;
3396 readonly taskId: string;
3397 readonly status: "running" | "completed" | "failed";
3398 }) => Effect.Effect<void>)
3399 | null;
3400 } = { current: null };
3401 const adapter = makeAcpAdapterV2({
3402 instanceId,
3403 flavor: {
3404 driver: ACP_TEST_DRIVER,
3405 capabilities: AcpProviderCapabilitiesV2,
3406 enablePostSettleContinuation: true,
3407 restartRuntimeAfterInterrupt: true,
3408 terminateRuntimeProcessGroupOnInterrupt: true,
3409 preserveRuntimeOnSettledInterrupt: true,
3410 registerExtensions: (context) =>
3411 Effect.sync(() => {
3412 capturedMutation.current = context.applyBackgroundTaskMutation;
3413 }),
3414 makeRuntime: makeMockRuntime({
3415 childProcessSpawner,
3416 mockAgentPath,
3417 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
3418 ownDetachedProcessGroup: true,
3419 protocolEvents,
3420 }),
3421 },
3422 fileSystem,
3423 idAllocator,
3424 serverConfig,
3425 continuationRequests: { offer: () => Effect.void },
3426 });
3427 const threadId = ThreadId.make("thread-acp-stop-quarantine-late-task");
3428 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
3429 runtimeMode: "full-access",
3430 interactionMode: "default",
3431 cwd: process.cwd(),
3432 });
3433 const modelSelection = { instanceId, model: "default" } as const;
3434 const runtime = yield* adapter.openSession({
3435 threadId,
3436 providerSessionId: ProviderSessionId.make("provider-session-acp-stop-quarantine-late-task"),
3437 modelSelection,
3438 runtimePolicy,
3439 });
3440 if (runtime.hasPendingBackgroundWork === undefined) {
3441 return yield* Effect.die(
3442 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
3443 );
3444 }
3445 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
3446 const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
3447 yield* runtime.events.pipe(
3448 Stream.runForEach((event) => Queue.offer(events, event)),
3449 Effect.forkScoped,
3450 );
3451 const providerThread = yield* runtime.ensureThread({
3452 threadId,
3453 modelSelection,
3454 runtimePolicy,
3455 });
3456 const now = yield* DateTime.now;
3457 yield* runtime
3458 .startTurn(
3459 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
3460 )
3461 .pipe(Effect.forkScoped);
3462 yield* Stream.fromQueue(protocolEvents).pipe(
3463 Stream.filter(
3464 (event) =>
3465 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
3466 ),
3467 Stream.runHead,
3468 );
3469 const applyMutation = capturedMutation.current;
3470 if (applyMutation === null) {
3471 return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation");
3472 }
3473 // Plumbing sanity: pre-Stop mutations on the root session track and
3474 // clear pending background work through the extension callback.
3475 yield* applyMutation({
3476 sessionId: "mock-session-1",
3477 taskId: "task-pre-stop",
3478 status: "running",
3479 });
3480 assert.isTrue(
3481 yield* hasPendingBackgroundWork,
3482 "a running background task mutation on the root session must track before Stop",
3483 );
3484 yield* applyMutation({
3485 sessionId: "mock-session-1",
3486 taskId: "task-pre-stop",
3487 status: "completed",
3488 });
3489 assert.isFalse(
3490 yield* hasPendingBackgroundWork,
3491 "a completed background task mutation must clear pending background work",
3492 );
3493
3494 const providerTurnId = idAllocator.derive.providerTurn({
3495 driver: ACP_TEST_DRIVER,
3496 nativeTurnId: "mock-session-1:turn:1",
3497 });
3498 yield* runtime.interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true });
3499 let terminalStatus: string | null = null;
3500 while (terminalStatus === null) {
3501 const event = yield* Queue.take(events);
3502 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
3503 terminalStatus = event.status;
3504 }
3505 }
3506 assert.equal(terminalStatus, "interrupted");
3507
3508 // Residual lifecycle from the stopped run: activeSessionId still points
3509 // at the stopped session until the next turn respawns the runtime, so
3510 // only the direct Stop quarantine stands between this mutation and the
3511 // wake machinery.
3512 yield* applyMutation({
3513 sessionId: "mock-session-1",
3514 taskId: "task-late-after-stop",
3515 status: "running",
3516 });
3517 assert.isFalse(
3518 yield* hasPendingBackgroundWork,
3519 "direct Stop quarantine must drop residual background task mutations from the stopped run",
3520 );
3521 }).pipe(Effect.provide(testLayer), Effect.scoped),
3522 );
3523
3524 it.live("production Grok interrupt flags still hard-kill and respawn on user Stop", () =>
3525 Effect.gen(function* () {
3526 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3527 const fileSystem = yield* FileSystem.FileSystem;
3528 const idAllocator = yield* IdAllocatorV2;
3529 const path = yield* Path.Path;
3530 const serverConfig = yield* ServerConfig;
3531 const mockAgentPath = yield* path.fromFileUrl(
3532 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3533 );
3534 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
3535 const instanceId = ProviderInstanceId.make("acp-test");
3536 let runtimeOrdinalSeen = 0;
3537 const adapter = makeAcpAdapterV2({
3538 instanceId,
3539 flavor: {
3540 driver: ACP_TEST_DRIVER,
3541 capabilities: AcpProviderCapabilitiesV2,
3542 enablePostSettleContinuation: true,
3543 // The full production Grok interrupt flag set after the non-Stop
3544 // softening: no restartRuntimeOnEveryInterrupt. User Stop
3545 // (requestRuntimeRestart) must still take the hard teardown and
3546 // respawn path, not the soft cancel path.
3547 restartRuntimeAfterInterrupt: true,
3548 terminateRuntimeProcessGroupOnInterrupt: true,
3549 preserveRuntimeOnSettledInterrupt: true,
3550 registerExtensions: ({ runtime: extensionRuntime, applyBackgroundTaskMutation }) =>
3551 registerXAiBackgroundTaskTracking(extensionRuntime, applyBackgroundTaskMutation),
3552 makeRuntime: makeMockRuntime({
3553 childProcessSpawner,
3554 mockAgentPath,
3555 // Only hang the interrupted first turn. The replacement process must
3556 // complete normally so startTurn / session-load assertions can finish.
3557 environment: (runtimeOrdinal) => {
3558 runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal);
3559 return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {};
3560 },
3561 ownDetachedProcessGroup: true,
3562 protocolEvents,
3563 }),
3564 },
3565 fileSystem,
3566 idAllocator,
3567 serverConfig,
3568 continuationRequests: { offer: () => Effect.void },
3569 });
3570 const threadId = ThreadId.make("thread-acp-production-stop-hard-kill");
3571 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
3572 runtimeMode: "full-access",
3573 interactionMode: "default",
3574 cwd: process.cwd(),
3575 });
3576 const modelSelection = { instanceId, model: "default" } as const;
3577 const runtime = yield* adapter.openSession({
3578 threadId,
3579 providerSessionId: ProviderSessionId.make("provider-session-acp-production-stop-hard-kill"),
3580 modelSelection,
3581 runtimePolicy,
3582 });
3583 const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
3584 yield* runtime.events.pipe(
3585 Stream.runForEach((event) => Queue.offer(events, event)),
3586 Effect.forkScoped,
3587 );
3588 const providerThread = yield* runtime.ensureThread({
3589 threadId,
3590 modelSelection,
3591 runtimePolicy,
3592 });
3593 const now = yield* DateTime.now;
3594 yield* runtime
3595 .startTurn(
3596 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
3597 )
3598 .pipe(Effect.forkScoped);
3599 yield* Stream.fromQueue(protocolEvents).pipe(
3600 Stream.filter(
3601 (event) =>
3602 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
3603 ),
3604 Stream.runHead,
3605 );
3606 const providerTurnId = idAllocator.derive.providerTurn({
3607 driver: ACP_TEST_DRIVER,
3608 nativeTurnId: "mock-session-1:turn:1",
3609 });
3610 yield* runtime.interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true });
3611 let terminalStatus: string | null = null;
3612 while (terminalStatus === null) {
3613 const event = yield* Queue.take(events);
3614 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
3615 terminalStatus = event.status;
3616 }
3617 }
3618 assert.equal(terminalStatus, "interrupted");
3619
3620 // Effect 4 Queue.takeAll waits for at least one element when empty. After
3621 // the session/prompt stream drain the protocol queue is often empty, so
3622 // takeAll would hang forever. Use clear (non-blocking drain) instead so
3623 // the session/load wait cannot match residual pre-restart traffic.
3624 yield* Queue.clear(protocolEvents);
3625 yield* runtime.startTurn(
3626 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
3627 );
3628 const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe(
3629 Stream.filter(
3630 (event) => event.direction === "outgoing" && rawProtocolMethod(event) === "session/load",
3631 ),
3632 Stream.runHead,
3633 );
3634 assert.isTrue(
3635 Option.isSome(loadAfterRestart),
3636 "user Stop must respawn the runtime and reload the session on the next turn",
3637 );
3638 assert.equal(
3639 runtimeOrdinalSeen,
3640 2,
3641 "user Stop with production Grok flags must replace the ACP runtime process",
3642 );
3643 }).pipe(Effect.provide(testLayer), Effect.scoped),
3644 );
3645
3646 // it.live: ownDetachedProcessGroup teardown uses wall-clock sleeps; under
3647 // it.effect the interrupt timeout on context.completed still needs real time
3648 // after the soft steer clears the turn.
3649 it.live(
3650 "Stop after settled soft steer contains the orphan runtime and respawns without subagent carryover",
3651 () =>
3652 Effect.gen(function* () {
3653 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3654 const fileSystem = yield* FileSystem.FileSystem;
3655 const idAllocator = yield* IdAllocatorV2;
3656 const path = yield* Path.Path;
3657 const serverConfig = yield* ServerConfig;
3658 const mockAgentPath = yield* path.fromFileUrl(
3659 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3660 );
3661 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
3662 const instanceId = ProviderInstanceId.make("acp-test");
3663 let subagentPhase: "spawn" | "complete" = "spawn";
3664 let cancelCalled = false;
3665 let runtimeOrdinalSeen = 0;
3666 const childSessionId = "mock-child-session-1";
3667 const streamedSubagentText = "streamed subagent carryover text";
3668 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
3669 let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined;
3670 const adapter = makeAcpAdapterV2({
3671 instanceId,
3672 flavor: {
3673 driver: ACP_TEST_DRIVER,
3674 capabilities: AcpProviderCapabilitiesV2,
3675 deferFinalizeForBackgroundWork: true,
3676 // Production Grok interrupt flags: soft settle keeps the process;
3677 // only requestRuntimeRestart (user Stop) hard-kills.
3678 restartRuntimeAfterInterrupt: true,
3679 terminateRuntimeProcessGroupOnInterrupt: true,
3680 preserveRuntimeOnSettledInterrupt: true,
3681 extractSubagentUpdate: (toolCall) =>
3682 toolCall.toolCallId !== "tool-call-generic-1"
3683 ? undefined
3684 : subagentPhase === "spawn"
3685 ? {
3686 nativeTaskId: "task-generic-1",
3687 prompt: "background subagent",
3688 title: "background subagent",
3689 model: null,
3690 status: "running",
3691 childSessionId,
3692 result: null,
3693 }
3694 : {
3695 nativeTaskId: "task-generic-1",
3696 prompt: "",
3697 title: null,
3698 model: null,
3699 status: "completed",
3700 childSessionId: null,
3701 result: "SUB_DONE",
3702 },
3703 makeRuntime: makeMockRuntime({
3704 childProcessSpawner,
3705 mockAgentPath,
3706 environment: (runtimeOrdinal) => {
3707 runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal);
3708 return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" };
3709 },
3710 ownDetachedProcessGroup: true,
3711 protocolEvents,
3712 wrapCancel: (cancel) =>
3713 Effect.sync(() => {
3714 cancelCalled = true;
3715 }).pipe(Effect.andThen(cancel)),
3716 wrapRuntime: (runtime) => ({
3717 ...runtime,
3718 handleSessionUpdate: (handler) =>
3719 Effect.sync(() => {
3720 sessionUpdateHandler = handler;
3721 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
3722 }),
3723 }),
3724 },
3725 fileSystem,
3726 idAllocator,
3727 serverConfig,
3728 });
3729 const threadId = ThreadId.make("thread-acp-stop-after-soft-steer-orphan");
3730 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
3731 runtimeMode: "full-access",
3732 interactionMode: "default",
3733 cwd: process.cwd(),
3734 });
3735 const modelSelection = { instanceId, model: "default" } as const;
3736 const runtime = yield* adapter.openSession({
3737 threadId,
3738 providerSessionId: ProviderSessionId.make(
3739 "provider-session-acp-stop-after-soft-steer-orphan",
3740 ),
3741 modelSelection,
3742 runtimePolicy,
3743 });
3744 const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
3745 yield* runtime.events.pipe(
3746 Stream.runForEach((event) => Queue.offer(events, event)),
3747 Effect.forkScoped,
3748 );
3749 const providerThread = yield* runtime.ensureThread({
3750 threadId,
3751 modelSelection,
3752 runtimePolicy,
3753 });
3754 const now = yield* DateTime.now;
3755 yield* runtime.startTurn(
3756 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
3757 );
3758 yield* Stream.fromQueue(protocolEvents).pipe(
3759 Stream.filter(
3760 (event) =>
3761 event.direction === "incoming" &&
3762 event.stage === "raw" &&
3763 typeof event.payload === "string" &&
3764 event.payload.includes('"stopReason"'),
3765 ),
3766 Stream.runHead,
3767 );
3768 yield* Effect.yieldNow;
3769 yield* Effect.yieldNow;
3770
3771 // Stream assistant text onto the carryover subagent while the deferred
3772 // turn is still active so assistantText races ahead of task.result.
3773 assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
3774 yield* sessionUpdateHandler!({
3775 sessionId: childSessionId,
3776 update: {
3777 sessionUpdate: "agent_message_chunk",
3778 content: { type: "text", text: streamedSubagentText },
3779 },
3780 });
3781 let subagentTurnItemId: string | null = null;
3782 let streamedTextSeen = false;
3783 for (let attempt = 0; attempt < 64; attempt += 1) {
3784 const maybeEvent = yield* Queue.take(events).pipe(Effect.timeoutOption("50 millis"));
3785 if (Option.isNone(maybeEvent)) break;
3786 const event = maybeEvent.value;
3787 if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") {
3788 subagentTurnItemId = event.turnItem.id;
3789 }
3790 if (
3791 event.type === "message.updated" &&
3792 event.message.text.includes(streamedSubagentText)
3793 ) {
3794 streamedTextSeen = true;
3795 break;
3796 }
3797 }
3798 assert.isTrue(
3799 streamedTextSeen,
3800 "pre-steer child session chunk must project as subagent assistant text",
3801 );
3802
3803 const firstProviderTurnId = idAllocator.derive.providerTurn({
3804 driver: ACP_TEST_DRIVER,
3805 nativeTurnId: "mock-session-1:turn:1",
3806 });
3807 // Soft steer first: clears the turn, leaves the process alive.
3808 yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId });
3809 assert.isFalse(
3810 cancelCalled,
3811 "settled soft steer must not send session/cancel before the later Stop",
3812 );
3813
3814 let firstTerminalStatus: string | null = null;
3815 while (firstTerminalStatus === null) {
3816 const event = yield* Queue.take(events);
3817 if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") {
3818 subagentTurnItemId = event.turnItem.id;
3819 }
3820 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
3821 firstTerminalStatus = event.status;
3822 }
3823 }
3824 assert.equal(firstTerminalStatus, "interrupted");
3825 assert.notEqual(subagentTurnItemId, null);
3826 assert.equal(
3827 runtimeOrdinalSeen,
3828 1,
3829 "soft steer must leave the original ACP runtime process alive",
3830 );
3831
3832 // User Stop against the already-cleared turn: contain the orphan runtime.
3833 const stopExit = yield* runtime
3834 .interruptTurn({
3835 providerThread,
3836 providerTurnId: firstProviderTurnId,
3837 requestRuntimeRestart: true,
3838 })
3839 .pipe(Effect.exit);
3840 if (Exit.isFailure(stopExit)) {
3841 assert.fail(
3842 `cleared-turn Stop must contain the orphan runtime, not fail: ${Cause.pretty(stopExit.cause)}`,
3843 );
3844 }
3845
3846 // Orphan Stop must terminalize carried-over subagents (soft steer left them
3847 // running on purpose; quarantine alone would leave them stuck "running").
3848 let subagentStopStatus: string | null = null;
3849 let subagentStopResult: string | null | undefined;
3850 let subagentStopProviderThreadId: string | null | undefined;
3851 let subagentUpdatedResult: string | null | undefined;
3852 for (let attempt = 0; attempt < 64; attempt += 1) {
3853 const maybeEvent = yield* Queue.take(events).pipe(Effect.timeoutOption("50 millis"));
3854 if (Option.isNone(maybeEvent)) break;
3855 const event = maybeEvent.value;
3856 if (
3857 event.type === "turn_item.updated" &&
3858 event.turnItem.type === "subagent" &&
3859 event.turnItem.id === subagentTurnItemId
3860 ) {
3861 subagentStopStatus = event.turnItem.status;
3862 subagentStopResult = event.turnItem.result;
3863 subagentStopProviderThreadId = event.turnItem.providerThreadId;
3864 if (subagentStopStatus === "interrupted") break;
3865 }
3866 if (
3867 event.type === "subagent.updated" &&
3868 event.subagent.status === "interrupted" &&
3869 event.subagent.result === streamedSubagentText
3870 ) {
3871 subagentUpdatedResult = event.subagent.result;
3872 }
3873 }
3874 assert.equal(
3875 subagentStopStatus,
3876 "interrupted",
3877 "orphan Stop must emit interrupted terminal for turn-1 carryover subagent",
3878 );
3879 assert.equal(
3880 subagentStopResult,
3881 streamedSubagentText,
3882 "orphan Stop must merge streamed assistantText into the interrupted result",
3883 );
3884 assert.equal(
3885 subagentStopProviderThreadId,
3886 providerThread.id,
3887 "orphan Stop parent-level events must use the spawn-time parent provider thread id",
3888 );
3889 assert.equal(
3890 subagentUpdatedResult,
3891 streamedSubagentText,
3892 "orphan Stop subagent.updated must also carry the streamed result",
3893 );
3894
3895 yield* Queue.clear(protocolEvents);
3896 const secondNow = yield* DateTime.now;
3897 yield* runtime.startTurn(
3898 makeTurnInput({
3899 threadId,
3900 providerThread,
3901 instanceId,
3902 runtimePolicy,
3903 now: secondNow,
3904 ordinal: 2,
3905 }),
3906 );
3907 const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe(
3908 Stream.filter(
3909 (event) =>
3910 event.direction === "outgoing" &&
3911 (rawProtocolMethod(event) === "session/load" ||
3912 rawProtocolMethod(event) === "session/new"),
3913 ),
3914 Stream.runHead,
3915 );
3916 assert.isTrue(
3917 Option.isSome(loadAfterRestart),
3918 "orphan containment must force a runtime respawn before the next turn",
3919 );
3920 assert.equal(
3921 runtimeOrdinalSeen,
3922 2,
3923 "Stop after soft steer must replace the orphan ACP runtime process",
3924 );
3925
3926 // nativeTurnId is `${sessionId}:turn:${ordinal}`. The mock always uses
3927 // mock-session-1; ordinal 2 yields turn:2 on the replacement process.
3928 // Carryover was quarantined by Stop, so turn-1's subagent must not re-attach.
3929 subagentPhase = "complete";
3930 const secondProviderTurnId = idAllocator.derive.providerTurn({
3931 driver: ACP_TEST_DRIVER,
3932 nativeTurnId: "mock-session-1:turn:2",
3933 });
3934 let carriedItemStatus: string | null = null;
3935 let secondTerminalStatus: string | null = null;
3936 while (secondTerminalStatus === null) {
3937 const event = yield* Queue.take(events);
3938 if (
3939 event.type === "turn_item.updated" &&
3940 event.turnItem.type === "subagent" &&
3941 event.turnItem.id === subagentTurnItemId
3942 ) {
3943 carriedItemStatus = event.turnItem.status;
3944 }
3945 if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) {
3946 secondTerminalStatus = event.status;
3947 }
3948 }
3949 assert.isNull(
3950 carriedItemStatus,
3951 "Stop quarantine must drop turn-1 subagent carryover on the respawned runtime",
3952 );
3953 assert.equal(secondTerminalStatus, "completed");
3954 }).pipe(Effect.provide(testLayer), Effect.scoped),
3955 );
3956
3957 it.effect(
3958 "settled soft interrupt skips cancel when prompt wire is settled before completion callback",
3959 () =>
3960 Effect.gen(function* () {
3961 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3962 const fileSystem = yield* FileSystem.FileSystem;
3963 const idAllocator = yield* IdAllocatorV2;
3964 const path = yield* Path.Path;
3965 const serverConfig = yield* ServerConfig;
3966 const mockAgentPath = yield* path.fromFileUrl(
3967 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3968 );
3969 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
3970 const instanceId = ProviderInstanceId.make("acp-test");
3971 let subagentPhase: "spawn" | "complete" = "spawn";
3972 let cancelCalled = false;
3973 const promptPhases: Array<string> = [];
3974 // Gate adapter-visible prompt return so we can open the race window after
3975 // the native stopReason is on the wire. Release lets Effect.tap mark
3976 // promptWireSettled before the completion callback requests the permit;
3977 // interrupt then ORs wire-done with promptSettled under the permit.
3978 const promptWireReturned = yield* Deferred.make<void>();
3979 const releasePromptCompletion = yield* Deferred.make<void>();
3980 const adapter = makeAcpAdapterV2({
3981 instanceId,
3982 flavor: {
3983 driver: ACP_TEST_DRIVER,
3984 capabilities: AcpProviderCapabilitiesV2,
3985 deferFinalizeForBackgroundWork: true,
3986 // Production Grok flags: without settled-soft, a steer soft-cancels.
3987 restartRuntimeAfterInterrupt: true,
3988 terminateRuntimeProcessGroupOnInterrupt: true,
3989 preserveRuntimeOnSettledInterrupt: true,
3990 extractSubagentUpdate: (toolCall) =>
3991 toolCall.toolCallId !== "tool-call-generic-1"
3992 ? undefined
3993 : subagentPhase === "spawn"
3994 ? {
3995 nativeTaskId: "task-generic-1",
3996 prompt: "background subagent",
3997 title: "background subagent",
3998 model: null,
3999 status: "running",
4000 childSessionId: null,
4001 result: null,
4002 }
4003 : {
4004 nativeTaskId: "task-generic-1",
4005 prompt: "",
4006 title: null,
4007 model: null,
4008 status: "completed",
4009 childSessionId: null,
4010 result: "SUB_DONE",
4011 },
4012 makeRuntime: makeMockRuntime({
4013 childProcessSpawner,
4014 mockAgentPath,
4015 environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" },
4016 protocolEvents,
4017 wrapCancel: (cancel) =>
4018 Effect.sync(() => {
4019 cancelCalled = true;
4020 }).pipe(Effect.andThen(cancel)),
4021 wrapRuntime: (runtime) => ({
4022 ...runtime,
4023 prompt: (payload) =>
4024 Effect.gen(function* () {
4025 promptPhases.push("prompt-start");
4026 const result = yield* runtime.prompt(payload);
4027 promptPhases.push("wire-returned");
4028 yield* Deferred.succeed(promptWireReturned, undefined);
4029 yield* Deferred.await(releasePromptCompletion);
4030 promptPhases.push("completion-released");
4031 return result;
4032 }),
4033 }),
4034 }),
4035 },
4036 fileSystem,
4037 idAllocator,
4038 serverConfig,
4039 });
4040 const threadId = ThreadId.make("thread-acp-settled-soft-admission-race");
4041 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
4042 runtimeMode: "full-access",
4043 interactionMode: "default",
4044 cwd: process.cwd(),
4045 });
4046 const modelSelection = { instanceId, model: "default" } as const;
4047 const runtime = yield* adapter.openSession({
4048 threadId,
4049 providerSessionId: ProviderSessionId.make(
4050 "provider-session-acp-settled-soft-admission-race",
4051 ),
4052 modelSelection,
4053 runtimePolicy,
4054 });
4055 const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
4056 yield* runtime.events.pipe(
4057 Stream.runForEach((event) => Queue.offer(events, event)),
4058 Effect.forkScoped,
4059 );
4060 const providerThread = yield* runtime.ensureThread({
4061 threadId,
4062 modelSelection,
4063 runtimePolicy,
4064 });
4065 const now = yield* DateTime.now;
4066 yield* runtime.startTurn(
4067 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
4068 );
4069 yield* Deferred.await(promptWireReturned);
4070 yield* Stream.fromQueue(protocolEvents).pipe(
4071 Stream.filter(
4072 (event) =>
4073 event.direction === "incoming" &&
4074 event.stage === "raw" &&
4075 typeof event.payload === "string" &&
4076 event.payload.includes('"stopReason"'),
4077 ),
4078 Stream.runHead,
4079 );
4080 assert.deepEqual(promptPhases, ["prompt-start", "wire-returned"]);
4081
4082 const firstProviderTurnId = idAllocator.derive.providerTurn({
4083 driver: ACP_TEST_DRIVER,
4084 nativeTurnId: "mock-session-1:turn:1",
4085 });
4086 // Fork interrupt while the adapter-side return is still gated. Release so
4087 // promptWireSettled completes (Effect.tap) before/while the completion
4088 // callback contends for runtimeCallbackPermit. Holding the gate forever
4089 // would also block the wire signal (same Effect resolution), so release
4090 // is required; the assertion is cancel skipped after that race window.
4091 const interruptFiber = yield* runtime
4092 .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId })
4093 .pipe(Effect.forkScoped);
4094 yield* Deferred.succeed(releasePromptCompletion, undefined);
4095 for (let attempt = 0; attempt < 20; attempt += 1) {
4096 if (promptPhases.includes("completion-released")) break;
4097 yield* Effect.yieldNow;
4098 }
4099 yield* TestClock.adjust("10 seconds");
4100 yield* Fiber.join(interruptFiber);
4101
4102 assert.includeMembers(promptPhases, [
4103 "prompt-start",
4104 "wire-returned",
4105 "completion-released",
4106 ]);
4107 assert.isFalse(
4108 cancelCalled,
4109 "settled soft steer must skip session/cancel once the prompt wire has settled",
4110 );
4111
4112 let firstTerminalStatus: string | null = null;
4113 while (firstTerminalStatus === null) {
4114 const event = yield* Queue.take(events);
4115 if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) {
4116 firstTerminalStatus = event.status;
4117 }
4118 }
4119 assert.equal(firstTerminalStatus, "interrupted");
4120 assert.equal(subagentPhase, "spawn");
4121 }).pipe(Effect.provide(testLayer), Effect.scoped),
4122 );
4123
4124 it.effect(
4125 "does not pin hasPendingBackgroundWork when a late TaskOutput re-reports an in-turn-handled task",
4126 () =>
4127 Effect.gen(function* () {
4128 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4129 const fileSystem = yield* FileSystem.FileSystem;
4130 const idAllocator = yield* IdAllocatorV2;
4131 const path = yield* Path.Path;
4132 const serverConfig = yield* ServerConfig;
4133 const mockAgentPath = yield* path.fromFileUrl(
4134 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4135 );
4136 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
4137 const continuationRequests: Array<ProviderContinuationRequest> = [];
4138 const instanceId = ProviderInstanceId.make("acp-test");
4139 const adapter = makeAcpAdapterV2({
4140 instanceId,
4141 flavor: {
4142 driver: ACP_TEST_DRIVER,
4143 capabilities: AcpProviderCapabilitiesV2,
4144 enablePostSettleContinuation: true,
4145 extractBackgroundTaskId: (toolCall) =>
4146 toolCall.toolCallId === "tool-call-monitor-1" ? "task-monitor-1" : undefined,
4147 extractBackgroundTaskCompletion: (toolCall) =>
4148 toolCall.toolCallId === "tool-call-fetch-1"
4149 ? [
4150 {
4151 taskId: "task-monitor-1",
4152 status: toolCall.status === "completed" ? "completed" : "running",
4153 appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "",
4154 },
4155 ]
4156 : [],
4157 makeRuntime: makeMockRuntime({
4158 childProcessSpawner,
4159 mockAgentPath,
4160 environment: {
4161 T3_ACP_EMIT_IN_TURN_TASKOUTPUT_THEN_LATE_DUPLICATE: "1",
4162 },
4163 protocolEvents,
4164 }),
4165 },
4166 fileSystem,
4167 idAllocator,
4168 serverConfig,
4169 continuationRequests: {
4170 offer: (request) =>
4171 Effect.sync(() => {
4172 continuationRequests.push(request);
4173 }),
4174 },
4175 });
4176 const threadId = ThreadId.make("thread-acp-already-handled-wake-pin");
4177 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
4178 runtimeMode: "full-access",
4179 interactionMode: "default",
4180 cwd: process.cwd(),
4181 });
4182 const modelSelection = { instanceId, model: "default" } as const;
4183 const runtime = yield* adapter.openSession({
4184 threadId,
4185 providerSessionId: ProviderSessionId.make(
4186 "provider-session-acp-already-handled-wake-pin",
4187 ),
4188 modelSelection,
4189 runtimePolicy,
4190 });
4191 if (runtime.hasPendingBackgroundWork === undefined) {
4192 return yield* Effect.die(
4193 "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.",
4194 );
4195 }
4196 const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork;
4197 const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
4198 yield* runtime.events.pipe(
4199 Stream.runForEach((event) => Queue.offer(events, event)),
4200 Effect.forkScoped,
4201 );
4202 const providerThread = yield* runtime.ensureThread({
4203 threadId,
4204 modelSelection,
4205 runtimePolicy,
4206 });
4207 const now = yield* DateTime.now;
4208 yield* runtime.startTurn(
4209 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
4210 );
4211 const providerTurnId = idAllocator.derive.providerTurn({
4212 driver: ACP_TEST_DRIVER,
4213 nativeTurnId: "mock-session-1:turn:1",
4214 });
4215
4216 let terminalStatus: string | null = null;
4217 while (terminalStatus === null) {
4218 const event = yield* Queue.take(events);
4219 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
4220 terminalStatus = event.status;
4221 }
4222 }
4223 assert.equal(terminalStatus, "completed");
4224
4225 // Wait for the late post-finalize duplicate TaskOutput frame.
4226 yield* Stream.fromQueue(protocolEvents).pipe(
4227 Stream.filter(
4228 (event) =>
4229 event.direction === "incoming" &&
4230 event.stage === "raw" &&
4231 typeof event.payload === "string" &&
4232 event.payload.includes("MONITOR_LISTING_TOKEN_LATE"),
4233 ),
4234 Stream.runHead,
4235 );
4236 yield* Effect.yieldNow;
4237 yield* Effect.yieldNow;
4238 yield* Effect.yieldNow;
4239
4240 assert.lengthOf(
4241 continuationRequests,
4242 0,
4243 "already-handled late TaskOutput must not open a continuation run",
4244 );
4245 assert.isFalse(
4246 yield* hasPendingBackgroundWork,
4247 "wake buffer must not stay non-empty and pin idle release after an already-handled re-report",
4248 );
4249 }).pipe(Effect.provide(testLayer), Effect.scoped),
4250 );
4251
4252 it.effect(
4253 "holds a settled turn until the injected monitor report streams instead of finalizing into it",
4254 () =>
4255 Effect.gen(function* () {
4256 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4257 const fileSystem = yield* FileSystem.FileSystem;
4258 const idAllocator = yield* IdAllocatorV2;
4259 const path = yield* Path.Path;
4260 const serverConfig = yield* ServerConfig;
4261 const mockAgentPath = yield* path.fromFileUrl(
4262 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4263 );
4264 const triggerDir = yield* fileSystem.makeTempDirectoryScoped();
4265 const triggerPath = path.join(triggerDir, "report-trigger");
4266 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
4267 const instanceId = ProviderInstanceId.make("acp-test");
4268 const adapter = makeAcpAdapterV2({
4269 instanceId,
4270 flavor: {
4271 driver: ACP_TEST_DRIVER,
4272 capabilities: AcpProviderCapabilitiesV2,
4273 deferFinalizeForBackgroundWork: true,
4274 extractBackgroundTaskId: (toolCall) =>
4275 toolCall.toolCallId === "tool-call-monitor-1" ? "task-monitor-1" : undefined,
4276 extractBackgroundToolMutation: (text) =>
4277 text.includes('Monitor "task-monitor-1" ended')
4278 ? [{ taskId: "task-monitor-1", status: "completed", appendOutput: "" }]
4279 : [],
4280 extractBackgroundTaskCompletion: (toolCall) =>
4281 toolCall.toolCallId === "tool-call-fetch-1"
4282 ? [
4283 {
4284 taskId: "task-monitor-1",
4285 status: toolCall.status === "completed" ? "completed" : "running",
4286 appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "",
4287 },
4288 ]
4289 : [],
4290 makeRuntime: makeMockRuntime({
4291 childProcessSpawner,
4292 mockAgentPath,
4293 environment: {
4294 T3_ACP_EMIT_POST_SETTLE_MONITOR_FLOW: "1",
4295 T3_ACP_INJECTED_REPORT_TRIGGER_PATH: triggerPath,
4296 },
4297 protocolEvents,
4298 }),
4299 },
4300 fileSystem,
4301 idAllocator,
4302 serverConfig,
4303 });
4304 const threadId = ThreadId.make("thread-acp-injected-report-hold");
4305 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
4306 runtimeMode: "full-access",
4307 interactionMode: "default",
4308 cwd: process.cwd(),
4309 });
4310 const modelSelection = { instanceId, model: "default" } as const;
4311 const runtime = yield* adapter.openSession({
4312 threadId,
4313 providerSessionId: ProviderSessionId.make("provider-session-acp-injected-report"),
4314 modelSelection,
4315 runtimePolicy,
4316 });
4317 const events = yield* Queue.unbounded<ProviderAdapterV2Event>();
4318 yield* runtime.events.pipe(
4319 Stream.runForEach((event) => Queue.offer(events, event)),
4320 Effect.forkScoped,
4321 );
4322 const providerThread = yield* runtime.ensureThread({
4323 threadId,
4324 modelSelection,
4325 runtimePolicy,
4326 });
4327 const now = yield* DateTime.now;
4328 yield* runtime.startTurn(
4329 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }),
4330 );
4331 const providerTurnId = idAllocator.derive.providerTurn({
4332 driver: ACP_TEST_DRIVER,
4333 nativeTurnId: "mock-session-1:turn:1",
4334 });
4335
4336 // Wait (real time) until the post-settle end notice and TaskOutput
4337 // hydration are ingested: the hydrated monitor card carries the
4338 // fetched listing.
4339 let reportSeen = false;
4340 const trackReport = (event: ProviderAdapterV2Event): void => {
4341 if (
4342 event.type === "turn_item.updated" &&
4343 event.turnItem.type === "assistant_message" &&
4344 event.turnItem.text.includes("MONITOR_REPORT_TOKEN")
4345 ) {
4346 reportSeen = true;
4347 }
4348 };
4349 let hydrated = false;
4350 while (!hydrated) {
4351 const event = yield* Queue.take(events);
4352 if (
4353 event.type === "turn_item.updated" &&
4354 event.turnItem.type === "command_execution" &&
4355 event.turnItem.status === "completed" &&
4356 (event.turnItem.output ?? "").includes("MONITOR_LISTING_TOKEN")
4357 ) {
4358 hydrated = true;
4359 }
4360 }
4361 yield* Effect.yieldNow;
4362 yield* Effect.yieldNow;
4363 yield* Effect.yieldNow;
4364
4365 // Pre-fix the 2s deferred-finalize debounce fires here and the report
4366 // streamed by the injected turn afterwards is dropped on the floor.
4367 yield* TestClock.adjust("3 seconds");
4368 yield* Effect.yieldNow;
4369 yield* Effect.yieldNow;
4370 yield* Effect.yieldNow;
4371 let drained = yield* Queue.poll(events);
4372 while (Option.isSome(drained)) {
4373 trackReport(drained.value);
4374 assert.notEqual(
4375 drained.value.type,
4376 "turn.terminal",
4377 "deferred finalize must hold while the injected-turn report is owed",
4378 );
4379 drained = yield* Queue.poll(events);
4380 }
4381
4382 // Release the report, then the normal debounce finalizes the turn.
4383 yield* fileSystem.writeFileString(triggerPath, "go");
4384 yield* Stream.fromQueue(protocolEvents).pipe(
4385 Stream.filter(
4386 (event) =>
4387 event.direction === "incoming" &&
4388 event.stage === "raw" &&
4389 typeof event.payload === "string" &&
4390 event.payload.includes("MONITOR_REPORT_TOKEN"),
4391 ),
4392 Stream.runHead,
4393 );
4394 yield* Effect.yieldNow;
4395 yield* Effect.yieldNow;
4396 yield* Effect.yieldNow;
4397 yield* TestClock.adjust("3 seconds");
4398
4399 let terminalStatus: string | null = null;
4400 while (terminalStatus === null) {
4401 const event = yield* Queue.take(events);
4402 trackReport(event);
4403 if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) {
4404 terminalStatus = event.status;
4405 }
4406 }
4407 assert.equal(terminalStatus, "completed");
4408 assert.isTrue(
4409 reportSeen,
4410 "the injected-turn report must project before the turn finalizes",
4411 );
4412 }).pipe(Effect.provide(testLayer), Effect.scoped),
4413 );
4414
4415 it.effect("restarts the ACP child process before the next prompt after interrupt", () =>
4416 Effect.gen(function* () {
4417 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4418 const fileSystem = yield* FileSystem.FileSystem;
4419 const idAllocator = yield* IdAllocatorV2;
4420 const path = yield* Path.Path;
4421 const serverConfig = yield* ServerConfig;
4422 const mockAgentPath = yield* path.fromFileUrl(
4423 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4424 );
4425 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
4426 const instanceId = ProviderInstanceId.make("acp-test");
4427 const adapter = makeAcpAdapterV2({
4428 instanceId,
4429 flavor: {
4430 driver: ACP_TEST_DRIVER,
4431 capabilities: AcpProviderCapabilitiesV2,
4432 restartRuntimeAfterInterrupt: true,
4433 makeRuntime: makeMockRuntime({
4434 childProcessSpawner,
4435 mockAgentPath,
4436 protocolEvents,
4437 }),
4438 },
4439 fileSystem,
4440 idAllocator,
4441 serverConfig,
4442 });
4443 const threadId = ThreadId.make("thread-acp-restart-after-interrupt");
4444 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
4445 runtimeMode: "full-access",
4446 interactionMode: "default",
4447 cwd: process.cwd(),
4448 });
4449 const modelSelection = { instanceId, model: "default" } as const;
4450 const runtime = yield* adapter.openSession({
4451 threadId,
4452 providerSessionId: ProviderSessionId.make("provider-session-acp-restart-after-interrupt"),
4453 modelSelection,
4454 runtimePolicy,
4455 });
4456 const providerThread = yield* runtime.ensureThread({
4457 threadId,
4458 modelSelection,
4459 runtimePolicy,
4460 });
4461 const now = yield* DateTime.now;
4462 yield* runtime.startTurn(
4463 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
4464 );
4465 yield* Stream.fromQueue(protocolEvents).pipe(
4466 Stream.filter(
4467 (event) =>
4468 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
4469 ),
4470 Stream.runHead,
4471 );
4472 const providerTurnId = idAllocator.derive.providerTurn({
4473 driver: ACP_TEST_DRIVER,
4474 nativeTurnId: `${providerThread.nativeThreadRef?.nativeId}:turn:1`,
4475 });
4476 yield* runtime.interruptTurn({
4477 providerThread,
4478 providerTurnId,
4479 requestRuntimeRestart: true,
4480 });
4481 yield* Stream.fromQueue(protocolEvents).pipe(
4482 Stream.filter(
4483 (event) =>
4484 event.direction === "outgoing" && rawProtocolMethod(event) === "session/cancel",
4485 ),
4486 Stream.runHead,
4487 );
4488
4489 yield* runtime.startTurn(
4490 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
4491 );
4492 const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe(
4493 Stream.filter(
4494 (event) => event.direction === "outgoing" && rawProtocolMethod(event) === "session/load",
4495 ),
4496 Stream.runHead,
4497 );
4498 assert.isTrue(
4499 Option.isSome(loadAfterRestart),
4500 "post-interrupt startTurn should respawn the runtime and replay session/load",
4501 );
4502 }).pipe(Effect.provide(testLayer), Effect.scoped),
4503 );
4504
4505 it.effect("Windows teardown is one-shot explicitly with independent finalizer cleanup", () =>
4506 Effect.gen(function* () {
4507 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4508 const path = yield* Path.Path;
4509 const mockAgentPath = yield* path.fromFileUrl(
4510 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4511 );
4512 const runtimeScope = yield* Scope.make();
4513 const taskkillCommands: Array<{
4514 readonly command: string;
4515 readonly args: ReadonlyArray<string>;
4516 }> = [];
4517 const taskkillSpawner = makeTaskkillSpawner({ exitCode: 0, commands: taskkillCommands });
4518 const spawner = ChildProcessSpawner.make((command) => {
4519 const value = command as unknown as { readonly command: string };
4520 return value.command === "taskkill"
4521 ? taskkillSpawner.spawn(command)
4522 : childProcessSpawner.spawn(command);
4523 });
4524 const context = yield* Layer.build(
4525 AcpSessionRuntime.layer({
4526 spawn: {
4527 command: process.execPath,
4528 args: [mockAgentPath],
4529 cwd: process.cwd(),
4530 env: { T3_ACP_SESSION_LIFECYCLE: "1" },
4531 },
4532 cwd: process.cwd(),
4533 clientInfo: { name: "t3-acp-test", version: "0.0.0" },
4534 ownDetachedProcessGroup: true,
4535 processGroupPlatform: "win32",
4536 }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))),
4537 ).pipe(Effect.provideService(Scope.Scope, runtimeScope));
4538 const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
4539 Effect.provide(context),
4540 );
4541 assert.isDefined(runtime.terminateProcessGroup);
4542 yield* Effect.all([runtime.terminateProcessGroup!, runtime.terminateProcessGroup!], {
4543 concurrency: "unbounded",
4544 });
4545 assert.equal(taskkillCommands.length, 1);
4546 yield* Scope.close(runtimeScope, Exit.void);
4547 assert.equal(taskkillCommands.length, 1);
4548 }).pipe(Effect.provide(testLayer)),
4549 );
4550
4551 it("accepts only taskkill exit code zero as successful tree termination", () => {
4552 assert.isTrue(AcpSessionRuntime.windowsTaskkillResultIsSuccess(0, ""));
4553 assert.isFalse(
4554 AcpSessionRuntime.windowsTaskkillResultIsSuccess(
4555 128,
4556 "FEHLER: Der Prozess wurde nicht gefunden.",
4557 ),
4558 );
4559 assert.isFalse(AcpSessionRuntime.windowsTaskkillResultIsSuccess(128, ""));
4560 assert.isFalse(AcpSessionRuntime.windowsTaskkillResultIsSuccess(1, "localized failure"));
4561 assert.isFalse(AcpSessionRuntime.windowsTaskkillResultIsSuccess(255, ""));
4562 });
4563
4564 it.effect("runs the default taskkill path and preserves every failure mode", () =>
4565 Effect.gen(function* () {
4566 const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> =
4567 [];
4568 yield* AcpSessionRuntime.terminateWindowsProcessTreeWithTaskkill(
4569 makeTaskkillSpawner({ exitCode: 0, commands }),
4570 4321,
4571 );
4572 assert.deepEqual(commands, [{ command: "taskkill", args: ["/PID", "4321", "/T", "/F"] }]);
4573
4574 for (const fixture of [
4575 { exitCode: 128, output: 'ERROR: The process "4321" not found.' },
4576 { exitCode: 128, output: "FEHLER: Prozess nicht gefunden." },
4577 { exitCode: 128, output: "" },
4578 { exitCode: 128, output: "ERROR: Access is denied." },
4579 { exitCode: 1, output: "generic failure" },
4580 ]) {
4581 const failed = yield* AcpSessionRuntime.terminateWindowsProcessTreeWithTaskkill(
4582 makeTaskkillSpawner(fixture),
4583 4321,
4584 ).pipe(Effect.exit);
4585 if (Exit.isSuccess(failed)) assert.fail(`taskkill ${fixture.exitCode} must fail`);
4586 const error = Cause.squash(failed.cause);
4587 assert.instanceOf(error, AcpSessionRuntime.AcpProcessGroupTerminationError);
4588 const termination = error as AcpSessionRuntime.AcpProcessGroupTerminationError;
4589 assert.equal(
4590 termination.detail,
4591 `taskkill exited ${fixture.exitCode} for ACP process tree 4321`,
4592 );
4593 assert.equal(termination.pid, 4321);
4594 assert.equal(termination.exitCode, fixture.exitCode);
4595 if (fixture.output.length > 0) {
4596 assert.equal(termination.cause, fixture.output);
4597 }
4598 }
4599
4600 for (const fixture of [
4601 { spawnFailure: true },
4602 { outputFailure: true },
4603 { exitFailure: true },
4604 ]) {
4605 const failed = yield* AcpSessionRuntime.terminateWindowsProcessTreeWithTaskkill(
4606 makeTaskkillSpawner(fixture),
4607 4321,
4608 ).pipe(Effect.exit);
4609 if (Exit.isSuccess(failed)) assert.fail("taskkill infrastructure failure must fail");
4610 const error = Cause.squash(failed.cause);
4611 assert.instanceOf(error, AcpSessionRuntime.AcpProcessGroupTerminationError);
4612 assert.equal(
4613 (error as AcpSessionRuntime.AcpProcessGroupTerminationError).detail,
4614 "Failed to run taskkill for ACP process tree 4321",
4615 );
4616 }
4617 }),
4618 );
4619
4620 it.effect("Windows process-tree teardown surfaces taskkill failure", () =>
4621 Effect.gen(function* () {
4622 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4623 const path = yield* Path.Path;
4624 const mockAgentPath = yield* path.fromFileUrl(
4625 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4626 );
4627 const context = yield* Layer.build(
4628 AcpSessionRuntime.layer({
4629 spawn: { command: process.execPath, args: [mockAgentPath], cwd: process.cwd() },
4630 cwd: process.cwd(),
4631 clientInfo: { name: "t3-acp-test", version: "0.0.0" },
4632 ownDetachedProcessGroup: true,
4633 processGroupPlatform: "win32",
4634 windowsProcessTreeTerminator: () =>
4635 Effect.fail(
4636 new AcpSessionRuntime.AcpProcessGroupTerminationError({
4637 detail: "mock taskkill failure",
4638 }),
4639 ),
4640 }).pipe(
4641 Layer.provide(
4642 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
4643 ),
4644 ),
4645 );
4646 const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
4647 Effect.provide(context),
4648 );
4649 const error = yield* Effect.flip(runtime.terminateProcessGroup!);
4650 assert.equal(error.detail, "mock taskkill failure");
4651 }).pipe(Effect.provide(testLayer), Effect.scoped),
4652 );
4653
4654 it.live("poisons the session when hard teardown defects and blocks replacement work", () =>
4655 Effect.gen(function* () {
4656 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4657 const fileSystem = yield* FileSystem.FileSystem;
4658 const idAllocator = yield* IdAllocatorV2;
4659 const path = yield* Path.Path;
4660 const serverConfig = yield* ServerConfig;
4661 const mockAgentPath = yield* path.fromFileUrl(
4662 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4663 );
4664 const commandPidPath = yield* fileSystem.makeTempFileScoped({
4665 prefix: "t3-acp-failed-teardown-command-",
4666 });
4667 const residualCallbackDir = yield* fileSystem.makeTempDirectoryScoped();
4668 const residualCallbackResponseLogPath = path.join(residualCallbackDir, "responses.log");
4669 const residualCallbackTriggerPath = path.join(residualCallbackDir, "trigger");
4670 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
4671 const continuationRequests: Array<ProviderContinuationRequest> = [];
4672 const teardownStarted = yield* Deferred.make<void>();
4673 const releaseTeardown = yield* Deferred.make<void>();
4674 let terminatorCallCount = 0;
4675 const instanceId = ProviderInstanceId.make("acp-test");
4676 const adapter = makeAcpAdapterV2({
4677 instanceId,
4678 flavor: {
4679 driver: ACP_TEST_DRIVER,
4680 capabilities: AcpProviderCapabilitiesV2,
4681 enablePostSettleContinuation: true,
4682 restartRuntimeAfterInterrupt: true,
4683 terminateRuntimeProcessGroupOnInterrupt: true,
4684 makeRuntime: makeMockRuntime({
4685 childProcessSpawner,
4686 mockAgentPath,
4687 ownDetachedProcessGroup: true,
4688 processGroupPlatform: "win32",
4689 windowsProcessTreeTerminator: () =>
4690 Effect.gen(function* () {
4691 terminatorCallCount += 1;
4692 yield* Deferred.succeed(teardownStarted, undefined);
4693 yield* Deferred.await(releaseTeardown);
4694 return yield* Effect.die("mock taskkill defect");
4695 }),
4696 environment: {
4697 T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG: "1",
4698 T3_ACP_RESIDUAL_CALLBACK_RESPONSE_LOG_PATH: residualCallbackResponseLogPath,
4699 T3_ACP_RESIDUAL_CALLBACK_TRIGGER_PATH: residualCallbackTriggerPath,
4700 T3_ACP_RUNNING_COMMAND_PID_PATH: commandPidPath,
4701 },
4702 protocolEvents,
4703 }),
4704 },
4705 fileSystem,
4706 idAllocator,
4707 serverConfig,
4708 continuationRequests: {
4709 offer: (request) =>
4710 Effect.sync(() => {
4711 continuationRequests.push(request);
4712 }),
4713 },
4714 });
4715 const threadId = ThreadId.make("thread-acp-failed-hard-teardown");
4716 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
4717 runtimeMode: "full-access",
4718 interactionMode: "default",
4719 cwd: process.cwd(),
4720 });
4721 const modelSelection = { instanceId, model: "default" } as const;
4722 const sessionScope = yield* Scope.make();
4723 const runtime = yield* adapter
4724 .openSession({
4725 threadId,
4726 providerSessionId: ProviderSessionId.make("provider-session-acp-failed-hard-teardown"),
4727 modelSelection,
4728 runtimePolicy,
4729 })
4730 .pipe(Effect.provideService(Scope.Scope, sessionScope));
4731 const adapterEvents = yield* Queue.unbounded<ProviderAdapterV2Event>();
4732 yield* runtime.events.pipe(
4733 Stream.runForEach((event) => Queue.offer(adapterEvents, event)),
4734 Effect.forkIn(sessionScope),
4735 );
4736 const providerThread = yield* runtime.ensureThread({
4737 threadId,
4738 modelSelection,
4739 runtimePolicy,
4740 });
4741 const now = yield* DateTime.now;
4742 yield* runtime
4743 .startTurn(
4744 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
4745 )
4746 .pipe(Effect.forkScoped);
4747 while ((yield* fileSystem.readFileString(commandPidPath)).trim().length === 0) {
4748 yield* Effect.yieldNow;
4749 }
4750 const [commandRootPid, commandSleepPid] = (yield* fileSystem.readFileString(commandPidPath))
4751 .trim()
4752 .split(/\s+/)
4753 .map(Number);
4754 assert.isTrue(
4755 Option.isSome(yield* waitForProcesses([commandRootPid!, commandSleepPid!])),
4756 "declared failed-teardown Bash and sleep PIDs must both become live",
4757 );
4758 yield* pollProtocolMethods(protocolEvents);
4759
4760 const providerTurnId = idAllocator.derive.providerTurn({
4761 driver: ACP_TEST_DRIVER,
4762 nativeTurnId: "mock-session-1:turn:1",
4763 });
4764 const interruptFiber = yield* runtime
4765 .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true })
4766 .pipe(Effect.exit, Effect.forkScoped);
4767 yield* Deferred.await(teardownStarted);
4768 const startFiber = yield* runtime
4769 .startTurn(
4770 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
4771 )
4772 .pipe(Effect.exit, Effect.forkScoped);
4773 yield* Effect.yieldNow;
4774 yield* Effect.yieldNow;
4775 assert.isUndefined(startFiber.pollUnsafe());
4776 const methodsDuringTeardown = yield* pollProtocolMethods(protocolEvents);
4777 yield* Deferred.succeed(releaseTeardown, undefined);
4778 assert.notInclude(methodsDuringTeardown, "session/load");
4779 assert.notInclude(methodsDuringTeardown, "session/prompt");
4780
4781 const interruptExit = yield* Fiber.join(interruptFiber);
4782 if (Exit.isSuccess(interruptExit)) assert.fail("hard teardown failure must fail interrupt");
4783 assert.include(Cause.pretty(interruptExit.cause), "session is poisoned");
4784 assert.isTrue(
4785 Option.isSome(yield* waitForProcesses([commandRootPid!, commandSleepPid!])),
4786 "failed teardown must leave both declared Bash and sleep PIDs live",
4787 );
4788
4789 while (Option.isSome(yield* Queue.poll(adapterEvents))) {
4790 // Discard the interrupted turn's expected projection before residual traffic.
4791 }
4792 yield* fileSystem.writeFileString(residualCallbackTriggerPath, "go");
4793 yield* Stream.fromQueue(protocolEvents).pipe(
4794 Stream.filter(
4795 (event) =>
4796 event.direction === "incoming" &&
4797 event.stage === "raw" &&
4798 typeof event.payload === "string" &&
4799 event.payload.includes('"method":"session/elicitation"'),
4800 ),
4801 Stream.runHead,
4802 );
4803 yield* Effect.sleep("100 millis");
4804 assert.isTrue(
4805 Option.isNone(yield* Queue.poll(adapterEvents)),
4806 "residual callbacks must not mutate or emit adapter projection after poison",
4807 );
4808 assert.isFalse(
4809 yield* fileSystem.exists(residualCallbackResponseLogPath),
4810 "permission and elicitation callbacks must remain unresolved after poison",
4811 );
4812 assert.lengthOf(
4813 continuationRequests,
4814 0,
4815 "residual callbacks must not offer a continuation after poison",
4816 );
4817
4818 const startExit = yield* Fiber.join(startFiber);
4819 if (Exit.isSuccess(startExit)) assert.fail("poisoned session must reject startTurn");
4820 assert.include(Cause.pretty(startExit.cause), "session is poisoned");
4821 const resumeExit = yield* runtime
4822 .resumeThread({ providerThread, modelSelection, runtimePolicy })
4823 .pipe(Effect.exit);
4824 if (Exit.isSuccess(resumeExit)) assert.fail("poisoned session must reject resumeThread");
4825 assert.include(Cause.pretty(resumeExit.cause), "session is poisoned");
4826 const snapshotExit = yield* runtime.readThreadSnapshot({ providerThread }).pipe(Effect.exit);
4827 if (Exit.isSuccess(snapshotExit)) {
4828 assert.fail("poisoned session must reject readThreadSnapshot");
4829 }
4830 assert.include(Cause.pretty(snapshotExit.cause), "session is poisoned");
4831 const forkExit = yield* runtime
4832 .forkThread({
4833 sourceProviderThread: providerThread,
4834 targetThreadId: ThreadId.make("thread-acp-poisoned-fork"),
4835 })
4836 .pipe(Effect.exit);
4837 if (Exit.isSuccess(forkExit)) assert.fail("poisoned session must reject forkThread");
4838 assert.include(Cause.pretty(forkExit.cause), "session is poisoned");
4839 yield* pollProtocolMethods(protocolEvents);
4840 const retryInterruptExit = yield* runtime
4841 .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true })
4842 .pipe(Effect.exit);
4843 if (Exit.isSuccess(retryInterruptExit)) {
4844 assert.fail("poisoned session must reject a repeated hard interrupt");
4845 }
4846 const firstInterruptError = Cause.squash(interruptExit.cause) as Error;
4847 const retryInterruptError = Cause.squash(retryInterruptExit.cause) as Error;
4848 assert.strictEqual(retryInterruptError.cause, firstInterruptError.cause);
4849 assert.equal(terminatorCallCount, 1);
4850 const methodsAfterPoison = yield* pollProtocolMethods(protocolEvents);
4851 assert.notInclude(methodsAfterPoison, "initialize");
4852 assert.notInclude(methodsAfterPoison, "session/cancel");
4853 assert.notInclude(methodsAfterPoison, "session/fork");
4854 assert.notInclude(methodsAfterPoison, "session/load");
4855 assert.notInclude(methodsAfterPoison, "session/prompt");
4856 assert.isTrue(processExists(commandRootPid!));
4857 assert.isTrue(processExists(commandSleepPid!));
4858 yield* Scope.close(sessionScope, Exit.void);
4859 assert.isTrue(
4860 Option.isSome(yield* waitForProcessesToExit([commandRootPid!, commandSleepPid!])),
4861 "finalizer cleanup must reap both declared Bash and sleep PIDs",
4862 );
4863 assert.isFalse(processExists(commandRootPid!));
4864 assert.isFalse(processExists(commandSleepPid!));
4865 const finalizerMethods = yield* pollProtocolMethods(protocolEvents);
4866 assert.notInclude(finalizerMethods, "session/close");
4867 }).pipe(Effect.provide(testLayer), Effect.scoped),
4868 );
4869
4870 it.live("durably poisons start and resume when required hard teardown is unavailable", () =>
4871 Effect.gen(function* () {
4872 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4873 const fileSystem = yield* FileSystem.FileSystem;
4874 const idAllocator = yield* IdAllocatorV2;
4875 const path = yield* Path.Path;
4876 const serverConfig = yield* ServerConfig;
4877 const mockAgentPath = yield* path.fromFileUrl(
4878 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4879 );
4880 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
4881 const instanceId = ProviderInstanceId.make("acp-test");
4882 const adapter = makeAcpAdapterV2({
4883 instanceId,
4884 flavor: {
4885 driver: ACP_TEST_DRIVER,
4886 capabilities: AcpProviderCapabilitiesV2,
4887 restartRuntimeAfterInterrupt: true,
4888 terminateRuntimeProcessGroupOnInterrupt: true,
4889 makeRuntime: makeMockRuntime({
4890 childProcessSpawner,
4891 mockAgentPath,
4892 environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" },
4893 protocolEvents,
4894 }),
4895 },
4896 fileSystem,
4897 idAllocator,
4898 serverConfig,
4899 });
4900 const threadId = ThreadId.make("thread-acp-missing-hard-teardown");
4901 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
4902 runtimeMode: "full-access",
4903 interactionMode: "default",
4904 cwd: process.cwd(),
4905 });
4906 const modelSelection = { instanceId, model: "default" } as const;
4907 const runtime = yield* adapter.openSession({
4908 threadId,
4909 providerSessionId: ProviderSessionId.make("provider-session-acp-missing-hard-teardown"),
4910 modelSelection,
4911 runtimePolicy,
4912 });
4913 const providerThread = yield* runtime.ensureThread({
4914 threadId,
4915 modelSelection,
4916 runtimePolicy,
4917 });
4918 const now = yield* DateTime.now;
4919 yield* runtime
4920 .startTurn(
4921 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
4922 )
4923 .pipe(Effect.forkScoped);
4924 yield* Stream.fromQueue(protocolEvents).pipe(
4925 Stream.filter(
4926 (event) =>
4927 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
4928 ),
4929 Stream.runHead,
4930 );
4931 const providerTurnId = idAllocator.derive.providerTurn({
4932 driver: ACP_TEST_DRIVER,
4933 nativeTurnId: "mock-session-1:turn:1",
4934 });
4935 const interruptExit = yield* runtime
4936 .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true })
4937 .pipe(Effect.exit);
4938 if (Exit.isSuccess(interruptExit)) assert.fail("missing hard teardown must fail interrupt");
4939 assert.include(Cause.pretty(interruptExit.cause), "session is poisoned");
4940
4941 const startExit = yield* runtime
4942 .startTurn(
4943 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
4944 )
4945 .pipe(Effect.exit);
4946 if (Exit.isSuccess(startExit)) assert.fail("poisoned session must reject startTurn");
4947 assert.include(Cause.pretty(startExit.cause), "session is poisoned");
4948 const resumeExit = yield* runtime
4949 .resumeThread({ providerThread, modelSelection, runtimePolicy })
4950 .pipe(Effect.exit);
4951 if (Exit.isSuccess(resumeExit)) assert.fail("poisoned session must reject resumeThread");
4952 assert.include(Cause.pretty(resumeExit.cause), "session is poisoned");
4953 }).pipe(Effect.provide(testLayer), Effect.scoped),
4954 );
4955
4956 it.live("holds concurrent startTurn behind successful hard teardown and reloads once", () =>
4957 Effect.gen(function* () {
4958 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
4959 const fileSystem = yield* FileSystem.FileSystem;
4960 const idAllocator = yield* IdAllocatorV2;
4961 const path = yield* Path.Path;
4962 const serverConfig = yield* ServerConfig;
4963 const mockAgentPath = yield* path.fromFileUrl(
4964 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4965 );
4966 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
4967 const teardownStarted = yield* Deferred.make<void>();
4968 const releaseTeardown = yield* Deferred.make<void>();
4969 let runtimeOrdinalSeen = 0;
4970 const instanceId = ProviderInstanceId.make("acp-test");
4971 const adapter = makeAcpAdapterV2({
4972 instanceId,
4973 flavor: {
4974 driver: ACP_TEST_DRIVER,
4975 capabilities: AcpProviderCapabilitiesV2,
4976 interruptPromptOnCancel: true,
4977 restartRuntimeAfterInterrupt: true,
4978 terminateRuntimeProcessGroupOnInterrupt: true,
4979 makeRuntime: makeMockRuntime({
4980 childProcessSpawner,
4981 mockAgentPath,
4982 ownDetachedProcessGroup: true,
4983 processGroupPlatform: "win32",
4984 windowsProcessTreeTerminator: () =>
4985 Deferred.succeed(teardownStarted, undefined).pipe(
4986 Effect.andThen(Deferred.await(releaseTeardown)),
4987 ),
4988 environment: (runtimeOrdinal) => {
4989 runtimeOrdinalSeen = runtimeOrdinal;
4990 return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {};
4991 },
4992 protocolEvents,
4993 }),
4994 },
4995 fileSystem,
4996 idAllocator,
4997 serverConfig,
4998 });
4999 const threadId = ThreadId.make("thread-acp-concurrent-hard-teardown");
5000 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
5001 runtimeMode: "full-access",
5002 interactionMode: "default",
5003 cwd: process.cwd(),
5004 });
5005 const modelSelection = { instanceId, model: "default" } as const;
5006 const runtime = yield* adapter.openSession({
5007 threadId,
5008 providerSessionId: ProviderSessionId.make("provider-session-acp-concurrent-hard-teardown"),
5009 modelSelection,
5010 runtimePolicy,
5011 });
5012 const providerThread = yield* runtime.ensureThread({
5013 threadId,
5014 modelSelection,
5015 runtimePolicy,
5016 });
5017 const now = yield* DateTime.now;
5018 yield* runtime
5019 .startTurn(
5020 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
5021 )
5022 .pipe(Effect.forkScoped);
5023 yield* Effect.yieldNow;
5024 yield* Effect.yieldNow;
5025 yield* Effect.yieldNow;
5026 const providerTurnId = idAllocator.derive.providerTurn({
5027 driver: ACP_TEST_DRIVER,
5028 nativeTurnId: "mock-session-1:turn:1",
5029 });
5030 yield* pollProtocolMethods(protocolEvents);
5031 const interruptFiber = yield* runtime
5032 .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true })
5033 .pipe(Effect.forkScoped);
5034 yield* Deferred.await(teardownStarted);
5035 const startFiber = yield* runtime
5036 .startTurn(
5037 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }),
5038 )
5039 .pipe(Effect.forkScoped);
5040 yield* Effect.yieldNow;
5041 yield* Effect.yieldNow;
5042 assert.isUndefined(startFiber.pollUnsafe());
5043 assert.equal(runtimeOrdinalSeen, 1);
5044 const methodsDuringTeardown = yield* pollProtocolMethods(protocolEvents);
5045 assert.notInclude(methodsDuringTeardown, "session/load");
5046 assert.notInclude(methodsDuringTeardown, "session/prompt");
5047
5048 yield* Deferred.succeed(releaseTeardown, undefined);
5049 yield* Fiber.join(interruptFiber);
5050 yield* Fiber.join(startFiber);
5051 assert.equal(runtimeOrdinalSeen, 2);
5052 const replacementMethods = yield* pollProtocolMethods(protocolEvents);
5053 assert.equal(replacementMethods.filter((method) => method === "session/load").length, 1);
5054 if (!replacementMethods.includes("session/prompt")) {
5055 yield* Stream.fromQueue(protocolEvents).pipe(
5056 Stream.filter(
5057 (event) =>
5058 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
5059 ),
5060 Stream.runHead,
5061 );
5062 }
5063 }).pipe(Effect.provide(testLayer), Effect.scoped),
5064 );
5065
5066 it.live("quarantines old-runtime callbacks after successful hard teardown", () =>
5067 Effect.gen(function* () {
5068 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
5069 const fileSystem = yield* FileSystem.FileSystem;
5070 const idAllocator = yield* IdAllocatorV2;
5071 const path = yield* Path.Path;
5072 const serverConfig = yield* ServerConfig;
5073 const mockAgentPath = yield* path.fromFileUrl(
5074 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
5075 );
5076 const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
5077 const continuationRequests: Array<ProviderContinuationRequest> = [];
5078 const responseLifecycle: Array<string> = [];
5079 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
5080 type HandlerRecord = {
5081 sessionUpdate?: Parameters<RuntimeService["handleSessionUpdate"]>[0];
5082 permission?: Parameters<RuntimeService["handleRequestPermission"]>[0];
5083 elicitation?: Parameters<RuntimeService["handleElicitation"]>[0];
5084 requestUserInput?: AcpAdapterV2ExtensionContext["requestUserInput"];
5085 };
5086 const handlerRecords: HandlerRecord[] = [];
5087 const runtimeInputs: AcpAdapterV2RuntimeInput[] = [];
5088 const oldPromptCompletion = yield* Deferred.make<EffectAcpSchema.PromptResponse>();
5089 const transportDrained = yield* Deferred.make<void>();
5090 const releaseTransportDrain = yield* Deferred.make<void>();
5091 let extensionOrdinal = 0;
5092 let runtimeOrdinalSeen = 0;
5093 const instanceId = ProviderInstanceId.make("acp-test");
5094 const makeRuntime = makeMockRuntime({
5095 childProcessSpawner,
5096 mockAgentPath,
5097 ownDetachedProcessGroup: true,
5098 processGroupPlatform: "win32",
5099 windowsProcessTreeTerminator: () => Effect.void,
5100 environment: (runtimeOrdinal) => {
5101 runtimeOrdinalSeen = runtimeOrdinal;
5102 return { T3_ACP_HANG_PROMPT_FOREVER: "1" };
5103 },
5104 protocolEvents,
5105 wrapRuntime: (runtime, runtimeOrdinal) => {
5106 const record: HandlerRecord = {};
5107 handlerRecords[runtimeOrdinal - 1] = record;
5108 return {
5109 ...runtime,
5110 handleSessionUpdate: (handler) =>
5111 Effect.sync(() => {
5112 record.sessionUpdate = handler;
5113 }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))),
5114 handleRequestPermission: (handler) =>
5115 Effect.sync(() => {
5116 record.permission = handler;
5117 }).pipe(Effect.andThen(runtime.handleRequestPermission(handler))),
5118 handleElicitation: (handler) =>
5119 Effect.sync(() => {
5120 record.elicitation = handler;
5121 }).pipe(Effect.andThen(runtime.handleElicitation(handler))),
5122 ...(runtimeOrdinal === 1
5123 ? {
5124 prompt: (payload) =>
5125 Effect.gen(function* () {
5126 yield* runtime.prompt(payload).pipe(Effect.ignore, Effect.forkDetach);
5127 return yield* Deferred.await(oldPromptCompletion);
5128 }),
5129 }
5130 : {}),
5131 };
5132 },
5133 });
5134 const adapter = makeAcpAdapterV2({
5135 instanceId,
5136 flavor: {
5137 driver: ACP_TEST_DRIVER,
5138 capabilities: AcpProviderCapabilitiesV2,
5139 enablePostSettleContinuation: true,
5140 registerExtensions: ({ requestUserInput }) =>
5141 Effect.sync(() => {
5142 handlerRecords[extensionOrdinal++]!.requestUserInput = requestUserInput;
5143 }),
5144 restartRuntimeAfterInterrupt: true,
5145 terminateRuntimeProcessGroupOnInterrupt: true,
5146 makeRuntime: (runtimeInput) =>
5147 Effect.sync(() => {
5148 runtimeInputs.push(runtimeInput);
5149 }).pipe(Effect.andThen(makeRuntime(runtimeInput))),
5150 },
5151 fileSystem,
5152 idAllocator,
5153 serverConfig,
5154 testHooks: {
5155 afterHardTeardownTransportDrained: () =>
5156 Deferred.succeed(transportDrained, undefined).pipe(
5157 Effect.andThen(Deferred.await(releaseTransportDrain)),
5158 ),
5159 onNativeResponseLifecycle: (event) =>
5160 Effect.sync(() => {
5161 responseLifecycle.push(event.type);
5162 }),
5163 },
5164 continuationRequests: {
5165 offer: (request) =>
5166 Effect.sync(() => {
5167 continuationRequests.push(request);
5168 }),
5169 },
5170 });
5171 const threadId = ThreadId.make("thread-acp-successful-teardown-callback-quarantine");
5172 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
5173 runtimeMode: "full-access",
5174 interactionMode: "default",
5175 cwd: process.cwd(),
5176 });
5177 const modelSelection = { instanceId, model: "default" } as const;
5178 const runtime = yield* adapter.openSession({
5179 threadId,
5180 providerSessionId: ProviderSessionId.make(
5181 "provider-session-acp-successful-teardown-callback-quarantine",
5182 ),
5183 modelSelection,
5184 runtimePolicy,
5185 });
5186 const adapterEvents = yield* Queue.unbounded<ProviderAdapterV2Event>();
5187 yield* runtime.events.pipe(
5188 Stream.runForEach((event) => Queue.offer(adapterEvents, event)),
5189 Effect.forkScoped,
5190 );
5191 const providerThread = yield* runtime.ensureThread({
5192 threadId,
5193 modelSelection,
5194 runtimePolicy,
5195 });
5196 const now = yield* DateTime.now;
5197 yield* runtime
5198 .startTurn(
5199 makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }),
5200 )
5201 .pipe(Effect.forkScoped);
5202 yield* Stream.fromQueue(protocolEvents).pipe(
5203 Stream.filter(
5204 (event) =>
5205 event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
5206 ),
5207 Stream.runHead,
5208 );
5209 const providerTurnId = idAllocator.derive.providerTurn({
5210 driver: ACP_TEST_DRIVER,
5211 nativeTurnId: "mock-session-1:turn:1",
5212 });
5213 const permissionRequest = {
5214 sessionId: "mock-session-1",
5215 toolCall: {
5216 toolCallId: "stale-generation-1-permission",
5217 title: "Stale generation 1 permission",
5218 },
5219 options: [{ optionId: "allow", name: "Allow", kind: "allow_once" as const }],
5220 };
5221 const interruptFiber = yield* runtime
5222 .interruptTurn({
5223 providerThread,

1396 more lines aren’t shown here.

apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts+3000−363
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
18 type ProviderInstanceId,18 type ProviderInstanceId,
19 type ProviderDriverKind,19 type ProviderDriverKind,
20 type ProviderRequestKind,20 type ProviderRequestKind,
21 type ProviderThreadId,
21 type ProviderUserInputAnswers,22 type ProviderUserInputAnswers,
22 type RuntimeRequestId,23 type RuntimeRequestId,
23 type ThreadId,24 type ThreadId,
@@ -27,11 +28,14 @@ import * as Cause from "effect/Cause"; · 3 unmodified lines
27import * as DateTime from "effect/DateTime";28import * as DateTime from "effect/DateTime";
28import * as Deferred from "effect/Deferred";29import * as Deferred from "effect/Deferred";
29import * as Effect from "effect/Effect";30import * as Effect from "effect/Effect";
31import * as Exit from "effect/Exit";
30import * as FileSystem from "effect/FileSystem";32import * as FileSystem from "effect/FileSystem";
33import * as Fiber from "effect/Fiber";
31import * as Option from "effect/Option";34import * as Option from "effect/Option";
32import * as Queue from "effect/Queue";35import * as Queue from "effect/Queue";
33import * as Ref from "effect/Ref";36import * as Ref from "effect/Ref";
34import type * as Scope from "effect/Scope";37import * as Scope from "effect/Scope";
38import * as Semaphore from "effect/Semaphore";
35import * as Stream from "effect/Stream";39import * as Stream from "effect/Stream";
36import * as EffectAcpErrors from "effect-acp/errors";40import * as EffectAcpErrors from "effect-acp/errors";
37import type * as EffectAcpSchema from "effect-acp/schema";41import type * as EffectAcpSchema from "effect-acp/schema";
@@ -52,6 +56,7 @@ import type { · 14 unmodified lines
52} from "../../provider/acp/AcpSessionRuntime.ts";56} from "../../provider/acp/AcpSessionRuntime.ts";
53import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";57import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
54import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";58import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";
59import { type ProviderContinuationRequest } from "../ProviderContinuationRequests.ts";
55import { makeProviderFailure } from "../ProviderFailure.ts";60import { makeProviderFailure } from "../ProviderFailure.ts";
56import { acpSelectionTransition } from "../ProviderSelectionTransition.ts";61import { acpSelectionTransition } from "../ProviderSelectionTransition.ts";
57import {62import {
@@ -87,10 +92,15 @@ export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; · 29 unmodified lines
87export interface AcpAdapterV2RuntimeInput {92export interface AcpAdapterV2RuntimeInput {
88 readonly cwd: string;93 readonly cwd: string;
89 readonly mcpServers: ReadonlyArray<EffectAcpSchema.McpServer>;94 readonly mcpServers: ReadonlyArray<EffectAcpSchema.McpServer>;
95 readonly interruptPromptOnCancel?: boolean;
90 readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"];96 readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"];
91 readonly clientInfo: AcpSessionRuntimeOptions["clientInfo"];97 readonly clientInfo: AcpSessionRuntimeOptions["clientInfo"];
92 readonly requestLogger?: NonNullable<AcpSessionRuntimeOptions["requestLogger"]>;98 readonly requestLogger?: NonNullable<AcpSessionRuntimeOptions["requestLogger"]>;
93 readonly protocolLogging: NonNullable<AcpSessionRuntimeOptions["protocolLogging"]>;99 readonly protocolLogging: NonNullable<AcpSessionRuntimeOptions["protocolLogging"]>;
100 readonly onIncomingRequest?: AcpSessionRuntimeOptions["onIncomingRequest"];
101 readonly onTermination: NonNullable<AcpSessionRuntimeOptions["onTermination"]>;
102 readonly onOutgoingResponseFailure?: AcpSessionRuntimeOptions["onOutgoingResponseFailure"];
103 readonly onOutgoingResponse?: AcpSessionRuntimeOptions["onOutgoingResponse"];
94}104}
95 105
96export type AcpAdapterV2NativeLogging = Pick<106export type AcpAdapterV2NativeLogging = Pick<
@@ -100,15 +110,79 @@ export type AcpAdapterV2NativeLogging = Pick< · 3 unmodified lines
100 110
101export interface AcpAdapterV2UserInputRequest {111export interface AcpAdapterV2UserInputRequest {
102 readonly nativeItemId: string;112 readonly nativeItemId: string;
113 readonly nativeMethod?: string;
103 readonly nativeRequestId: string;114 readonly nativeRequestId: string;
115 readonly nativeSessionId?: string;
104 readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>;116 readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>;
105}117}
106 118
107export interface AcpAdapterV2ExtensionContext {119export interface AcpAdapterV2ExtensionContext {
108 readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"];120 readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"];
109 readonly requestUserInput: (121 /**
110 input: AcpAdapterV2UserInputRequest,122 * Session-scoped background-task lifecycle reported via extension
111 ) => Effect.Effect<ProviderUserInputAnswers | null, EffectAcpErrors.AcpError>;123 * notifications (e.g. Grok `_x.ai/task_backgrounded` after a soft cancel
124 * detaches foreground work). Mutations for non-root sessions are ignored.
125 */
126 readonly applyBackgroundTaskMutation: (mutation: {
127 readonly sessionId: string;
128 readonly taskId: string;
129 readonly status: "running" | "completed" | "failed";
130 }) => Effect.Effect<void>;
131 readonly requestUserInput: (input: AcpAdapterV2UserInputRequest) => Effect.Effect<
132 {
133 readonly acknowledgeNativeResponse: Effect.Effect<void, EffectAcpErrors.AcpError>;
134 readonly answers: ProviderUserInputAnswers | null;
135 },
136 EffectAcpErrors.AcpError
137 >;
138}
139
140export interface AcpRootTurnIdleSnapshot {
141 readonly finalized: boolean;
142 readonly interrupted: boolean;
143 readonly assistantStreamOpen: boolean;
144 readonly reasoningStreamOpen: boolean;
145 readonly hasRunningTool: boolean;
146 readonly hasPendingRuntimeRequest: boolean;
147 readonly hasToolHistory: boolean;
148 readonly hasRunningSubagent: boolean;
149 readonly hasOutput: boolean;
150}
151
152/**
153 * Debounce used if a flavor re-enables speculative idle settlement.
154 * Kept for tests and future root-matched recovery; Grok no longer idle-settles.
155 */
156export const acpRootTurnSettleDebounceMs = 2_000;
157
158/** Let trailing root session chunks land before terminalizing a settled turn. */
159export const acpRootTurnCompletionDrainMs = 100;
160
161/**
162 * True when root-session streaming is quiescent enough for speculative settle.
163 *
164 * Always false today: settling on "assistant text then quiet" over-settles Grok
165 * preamble-before-tools turns, and settling after tools drops later tool waves
166 * while `session/prompt` is still open. Terminalize from the prompt RPC (or a
167 * future root-matched completion signal), not from local silence.
168 */
169export function acpRootTurnIsIdle(snapshot: AcpRootTurnIdleSnapshot): boolean {
170 if (snapshot.finalized || snapshot.interrupted) return false;
171 if (snapshot.assistantStreamOpen || snapshot.reasoningStreamOpen) return false;
172 if (snapshot.hasRunningTool || snapshot.hasPendingRuntimeRequest) return false;
173 if (snapshot.hasRunningSubagent) return false;
174 if (!snapshot.hasOutput) return false;
175 // Structural gates above stay for unit tests / future re-enable. Speculative
176 // idle completion is intentionally disabled.
177 return false;
178}
179
180/** True when idle settle should be (re-)scheduled after pending runtime work clears. */
181export function acpRootTurnShouldRearmRecoveryTimers(context: {
182 readonly finalized: boolean;
183 readonly interrupted: boolean;
184}): boolean {
185 return !context.finalized && !context.interrupted;
112}186}
113 187
114export interface AcpAdapterV2Flavor {188export interface AcpAdapterV2Flavor {
@@ -128,7 +202,117 @@ export interface AcpAdapterV2Flavor { · 13 unmodified lines
128 readonly extractSubagentUpdate?: (202 readonly extractSubagentUpdate?: (
129 toolCall: AcpToolCallState,203 toolCall: AcpToolCallState,
130 ) => AcpAdapterV2SubagentUpdate | undefined;204 ) => AcpAdapterV2SubagentUpdate | undefined;
205 /**
206 * Optional Grok-style rewrite before tool projection (e.g. keep monitor start
207 * ACKs in the running state until stream end).
208 */
209 readonly normalizeToolCall?: (toolCall: AcpToolCallState) => AcpToolCallState;
210 /**
211 * Optional mapping from a long-lived background tool start ACK to a task id
212 * (e.g. monitor task uuid) so later synthetic text events can update it.
213 */
214 readonly extractBackgroundTaskId?: (toolCall: AcpToolCallState) => string | undefined;
215 /**
216 * Optional parse of root-session synthetic text (monitor-event lines, monitor
217 * ended reminders). Returns every task mutation in the chunk so coalesced
218 * progress / end notices are not dropped.
219 */
220 readonly extractBackgroundToolMutation?: (text: string) => ReadonlyArray<{
221 readonly taskId: string;
222 readonly status: "running" | "completed" | "failed";
223 readonly appendOutput: string;
224 }>;
225 /**
226 * Optional parse of root-session synthetic text announcing a background
227 * subagent's end ("Background subagent "<uuid>" ... completed successfully").
228 * The agent may never hydrate via get_command_or_subagent_output, so this
229 * notice can be the only terminal signal for the subagent row.
230 */
231 readonly extractSubagentEndNotice?: (text: string) =>
232 | {
233 readonly childSessionId: string;
234 readonly status: "completed" | "failed";
235 }
236 | undefined;
237 /**
238 * Optional hydration when a later tool (e.g. get_command TaskOutput) completes
239 * previously registered background task id(s).
240 */
241 readonly extractBackgroundTaskCompletion?: (toolCall: AcpToolCallState) => ReadonlyArray<{
242 readonly taskId: string;
243 readonly status: "running" | "completed" | "failed";
244 readonly appendOutput: string;
245 }>;
246 /**
247 * Persistent monitors (e.g. Grok `persistent: true`) should not hold root-turn
248 * deferred finalize open forever. Still tracked for post-settle wake.
249 */
250 readonly isPersistentBackgroundTool?: (toolCall: AcpToolCallState) => boolean;
251 /**
252 * When true, keep the active turn open after session/prompt returns while
253 * background tools/subagents are still running so later monitor/wake traffic
254 * can project (Grok monitors finish after the root prompt settles).
255 */
256 readonly deferFinalizeForBackgroundWork?: boolean;
131 readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>;257 readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>;
258 /**
259 * When true, schedule speculative local settlement after root session
260 * quiet. Disabled for Grok: short idle windows over-settle preamble-before-
261 * tools turns and `session/cancel` from that path freezes projection while
262 * the agent keeps working. Prefer `session/prompt` return (or a future
263 * root-matched terminal signal).
264 */
265 readonly settleRootTurnWhenIdle?: boolean;
266 /** Interrupt the local prompt fiber before `session/cancel` (Grok wedged prompts). */
267 readonly interruptPromptOnCancel?: boolean;
268 /**
269 * Kill and respawn the ACP child process before the next `session/prompt` after a
270 * user interrupt. Grok can keep `task_already_running` state until the process exits.
271 */
272 readonly restartRuntimeAfterInterrupt?: boolean;
273 /**
274 * When true, every interrupt restarts the runtime, not just those carrying
275 * `requestRuntimeRestart` (user Stop). Leave unset to keep non-Stop
276 * interrupts (steering, restart_active) soft: `session/cancel` plus session
277 * reuse in the same process.
278 */
279 readonly restartRuntimeOnEveryInterrupt?: boolean;
280 readonly terminateRuntimeProcessGroupOnInterrupt?: boolean;
281 /**
282 * When true, an interrupt without `requestRuntimeRestart` (steering restart)
283 * on a turn whose native prompt already settled skips the hard process-group
284 * kill, the ACP cancel, and the runtime respawn entirely: the turn
285 * terminalizes locally while background subagents keep running in the same
286 * process and carry over into the replacement turn. Verified against the
287 * real Grok CLI (tmp/grok-acp-experiments E1): a new session/prompt is
288 * accepted concurrently while a fire-and-forget subagent is still running,
289 * with no task_already_running. User Stop (`requestRuntimeRestart: true`)
290 * keeps the hard teardown; mid-prompt non-Stop interrupts go soft
291 * (`session/cancel` plus same-session re-prompt) unless
292 * `restartRuntimeOnEveryInterrupt` is set.
293 */
294 readonly preserveRuntimeOnSettledInterrupt?: boolean;
295 /**
296 * When true (with continuationRequests), post-settle root session/update traffic
297 * buffers and requests a provider continuation run instead of being dropped or
298 * only appended to loaded history.
299 */
300 readonly enablePostSettleContinuation?: boolean;
301 /**
302 * When true, send image attachment content blocks even if the ACP agent
303 * advertises `promptCapabilities.image: false`. Grok CLI currently accepts
304 * and vision-processes image blocks while still reporting the capability as
305 * false; without this override, screenshot turns fail before `session/prompt`.
306 */
307 readonly supportsImagePrompts?: boolean;
308}
309
310/** Whether image attachment blocks may be included in session/prompt. */
311export function acpSupportsImagePrompts(input: {
312 readonly flavorSupportsImagePrompts?: boolean | undefined;
313 readonly negotiatedImage?: boolean | undefined;
314}): boolean {
315 return input.flavorSupportsImagePrompts === true || input.negotiatedImage === true;
132}316}
133 317
134export interface AcpAdapterV2SubagentUpdate {318export interface AcpAdapterV2SubagentUpdate {
@@ -136,9 +320,14 @@ export interface AcpAdapterV2SubagentUpdate { · 1 unmodified line
136 readonly prompt: string;320 readonly prompt: string;
137 readonly title: string | null;321 readonly title: string | null;
138 readonly model: string | null;322 readonly model: string | null;
139 readonly status: "running" | "completed" | "failed";323 readonly status: "running" | "completed" | "failed" | "interrupted" | "cancelled";
140 readonly childSessionId: string | null;324 readonly childSessionId: string | null;
141 readonly result: string | null;325 readonly result: string | null;
326 /**
327 * When false, still project a normal tool turn item after the subagent update
328 * (hydration tools like get_command_or_subagent_output). Defaults to true.
329 */
330 readonly suppressNormalTool?: boolean;
142}331}
143 332
144export interface AcpAdapterV2Options {333export interface AcpAdapterV2Options {
@@ -148,6 +337,36 @@ export interface AcpAdapterV2Options { · 3 unmodified lines
148 readonly idAllocator: IdAllocatorV2Shape;337 readonly idAllocator: IdAllocatorV2Shape;
149 readonly serverConfig: ServerConfig["Service"];338 readonly serverConfig: ServerConfig["Service"];
150 readonly nativeLogging?: (threadId: ThreadId) => AcpAdapterV2NativeLogging;339 readonly nativeLogging?: (threadId: ThreadId) => AcpAdapterV2NativeLogging;
340 /**
341 * Shared with ProviderContinuationService so post-settle wake traffic can start
342 * a continuation run. Optional: adapters that omit it keep pre-continuation drop
343 * / history-only behavior for null-activeTurn updates.
344 */
345 readonly continuationRequests?: {
346 readonly offer: (request: ProviderContinuationRequest) => Effect.Effect<void>;
347 };
348 readonly testHooks?: {
349 readonly afterNativeResponseTransportClosed?: () => Effect.Effect<void>;
350 readonly afterHardTeardownTransportDrained?: () => Effect.Effect<void>;
351 readonly beforeNativeResponseAdmissionCheck?: (
352 generation: number,
353 requestId: string,
354 ) => Effect.Effect<void>;
355 readonly onNativeResponseLifecycle?: (event: {
356 readonly generation?: number;
357 readonly requestId?: string;
358 readonly type:
359 | "admission_rejected"
360 | "failed"
361 | "late_noop"
362 | "registered"
363 | "removed"
364 | "timer_exited"
365 | "timer_started"
366 | "watcher_exited"
367 | "watcher_started";
368 }) => Effect.Effect<void>;
369 };
151}370}
152 371
153export const AcpProviderCapabilitiesV2 = {372export const AcpProviderCapabilitiesV2 = {
@@ -278,7 +497,8 @@ function negotiatedCapabilities( · 124 unmodified lines
278 };497 };
279}498}
280 499
281function acpMcpServers(threadId: ThreadId): ReadonlyArray<EffectAcpSchema.McpServer> {500function acpMcpServers(threadId: ThreadId | null): ReadonlyArray<EffectAcpSchema.McpServer> {
501 if (threadId === null) return [];
282 const session = McpProviderSession.readMcpProviderSession(threadId);502 const session = McpProviderSession.readMcpProviderSession(threadId);
283 if (session === undefined) {503 if (session === undefined) {
284 return [];504 return [];
@@ -352,14 +572,114 @@ function unknownRecord(value: unknown): Record<string, unknown> | undefined { · 67 unmodified lines
352 : undefined;572 : undefined;
353}573}
354 574
575export function acpCanonicalJson(value: unknown): string {
576 if (Array.isArray(value)) {
577 return `[${value.map(acpCanonicalJson).join(",")}]`;
578 }
579 const record = unknownRecord(value);
580 if (record !== undefined) {
581 return `{${Object.keys(record)
582 .toSorted()
583 .map((key) => `${JSON.stringify(key)}:${acpCanonicalJson(record[key])}`)
584 .join(",")}}`;
585 }
586 return JSON.stringify(value) ?? "undefined";
587}
588
589export function acpNativeUserInputRequestMatches(
590 request: Pick<
591 AcpAdapterV2UserInputRequest,
592 "nativeMethod" | "nativeRequestId" | "nativeSessionId"
593 >,
594 transport: { readonly method: string; readonly payload: unknown },
595): boolean {
596 if (
597 request.nativeMethod === undefined ||
598 request.nativeMethod.trim().length === 0 ||
599 request.nativeRequestId.trim().length === 0 ||
600 request.nativeSessionId === undefined ||
601 request.nativeSessionId.trim().length === 0
602 ) {
603 return false;
604 }
605 if (
606 transport.method !== "x.ai/ask_user_question" &&
607 transport.method !== "_x.ai/ask_user_question"
608 ) {
609 return false;
610 }
611 if (transport.method !== request.nativeMethod) {
612 return false;
613 }
614 const payloadRecord = unknownRecord(transport.payload);
615 const paramsRecord = unknownRecord(payloadRecord?.params) ?? payloadRecord;
616 return (
617 paramsRecord?.toolCallId !== undefined &&
618 String(paramsRecord.toolCallId).trim().length > 0 &&
619 String(paramsRecord.toolCallId) === request.nativeRequestId &&
620 paramsRecord.sessionId !== undefined &&
621 String(paramsRecord.sessionId).trim().length > 0 &&
622 String(paramsRecord.sessionId) === request.nativeSessionId
623 );
624}
625
626export function acpClaimNativeTransportRequest<
627 T extends {
628 readonly generation: number;
629 readonly requestId: string;
630 readonly sequence: number;
631 },
632>(
633 requests: ReadonlyArray<T>,
634 generation: number,
635 predicate: (request: T) => boolean,
636): readonly [string | undefined, Array<T>] {
637 let claimedIndex = -1;
638 let claimedSequence = Number.POSITIVE_INFINITY;
639 for (let index = 0; index < requests.length; index += 1) {
640 const request = requests[index]!;
641 if (
642 request.generation === generation &&
643 request.sequence < claimedSequence &&
644 predicate(request)
645 ) {
646 claimedIndex = index;
647 claimedSequence = request.sequence;
648 }
649 }
650 if (claimedIndex < 0) return [undefined, [...requests]];
651 return [
652 requests[claimedIndex]!.requestId,
653 [...requests.slice(0, claimedIndex), ...requests.slice(claimedIndex + 1)],
654 ];
655}
656
355function nonEmptyText(value: unknown, fallback: string): string {657function nonEmptyText(value: unknown, fallback: string): string {
356 return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;658 return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
357}659}
358 660
661function decodeByteText(value: unknown): string | undefined {
662 if (!Array.isArray(value) || value.length === 0) return undefined;
663 if (!value.every((entry) => typeof entry === "number" && Number.isInteger(entry))) {
664 return undefined;
665 }
666 try {
667 // Preserve leading/trailing whitespace like the string path in textFromUnknown.
668 const text = new TextDecoder().decode(Uint8Array.from(value as number[]));
669 return text.length > 0 ? text : undefined;
670 } catch {
671 return undefined;
672 }
673}
674
359function textFromUnknown(value: unknown): string | undefined {675function textFromUnknown(value: unknown): string | undefined {
360 if (typeof value === "string") {676 if (typeof value === "string") {
361 return value;677 return value;
362 }678 }
679 const fromBytes = decodeByteText(value);
680 if (fromBytes !== undefined) {
681 return fromBytes;
682 }
363 if (Array.isArray(value)) {683 if (Array.isArray(value)) {
364 const parts = value.flatMap((entry) => {684 const parts = value.flatMap((entry) => {
365 const text = textFromUnknown(entry);685 const text = textFromUnknown(entry);
@@ -371,12 +691,33 @@ function textFromUnknown(value: unknown): string | undefined { · 5 unmodified lines
371 if (record === undefined) {691 if (record === undefined) {
372 return undefined;692 return undefined;
373 }693 }
374 for (const key of ["stdout", "stderr", "output", "content", "text", "message"]) {694 // Prefer prompt-facing Grok fields before nested envelopes.
375 const text = textFromUnknown(record[key]);695 for (const key of [
696 "output_for_prompt",
697 "stdout",
698 "stderr",
699 "output",
700 "content",
701 "text",
702 "message",
703 ]) {
704 const direct = record[key];
705 if (typeof direct === "string" && direct.length > 0) {
706 return direct;
707 }
708 const decoded = decodeByteText(direct);
709 if (decoded !== undefined) {
710 return decoded;
711 }
712 const text = textFromUnknown(direct);
376 if (text !== undefined && text.length > 0) {713 if (text !== undefined && text.length > 0) {
377 return text;714 return text;
378 }715 }
379 }716 }
717 const result = unknownRecord(record.Result) ?? unknownRecord(record.result);
718 if (result !== undefined) {
719 return textFromUnknown(result);
720 }
380 return undefined;721 return undefined;
381}722}
382 723
@@ -391,6 +732,21 @@ function commandExitCode(value: unknown): number | undefined { · 8 unmodified lines
391 return undefined;732 return undefined;
392}733}
393 734
735/**
736 * Project an exit code only when the tool has a terminal native status.
737 * Mid-stream Grok Bash re-reports carry exit_code 0 while still in progress;
738 * interrupted tools must not retain that stale success code.
739 */
740export function acpProjectedCommandExitCode(
741 status: "pending" | "running" | "completed" | "failed" | "interrupted",
742 rawOutput: unknown,
743): number | undefined {
744 if (status !== "completed" && status !== "failed") {
745 return undefined;
746 }
747 return commandExitCode(rawOutput);
748}
749
394function pathFromToolCall(toolCall: AcpToolCallState): string | undefined {750function pathFromToolCall(toolCall: AcpToolCallState): string | undefined {
395 const locations = toolCall.data.locations;751 const locations = toolCall.data.locations;
396 if (Array.isArray(locations)) {752 if (Array.isArray(locations)) {
@@ -443,15 +799,14 @@ function toolStatus( · 46 unmodified lines
443 }799 }
444}800}
445 801
446function nodeStatus(status: ReturnType<typeof toolStatus>): OrchestrationV2ExecutionNode["status"] {802type ProjectedToolStatus = ReturnType<typeof toolStatus> | "interrupted";
803
804function nodeStatus(status: ProjectedToolStatus): OrchestrationV2ExecutionNode["status"] {
447 return status === "pending" ? "running" : status;805 return status === "pending" ? "running" : status;
448}806}
449 807
450function completedAtForStatus(808function completedAtForStatus(status: ProjectedToolStatus, now: DateTime.Utc): DateTime.Utc | null {
451 status: ReturnType<typeof toolStatus>,809 return status === "completed" || status === "failed" || status === "interrupted" ? now : null;
452 now: DateTime.Utc,
453): DateTime.Utc | null {
454 return status === "completed" || status === "failed" ? now : null;
455}810}
456 811
457function selectPermissionOptionId(812function selectPermissionOptionId(
@@ -558,12 +913,175 @@ interface ActiveAcpTurn { · 100 unmodified lines
558 readonly subagents: Map<string, ActiveAcpSubagent>;913 readonly subagents: Map<string, ActiveAcpSubagent>;
559 readonly subagentsBySessionId: Map<string, ActiveAcpSubagent>;914 readonly subagentsBySessionId: Map<string, ActiveAcpSubagent>;
560 readonly pendingSubagentNotifications: Map<string, Array<EffectAcpSchema.SessionNotification>>;915 readonly pendingSubagentNotifications: Map<string, Array<EffectAcpSchema.SessionNotification>>;
916 /** Background monitor/task id → toolCallId for synthetic root text updates. */
917 readonly toolCallIdsByBackgroundTaskId: Map<string, string>;
918 /**
919 * Persistent monitors registered this turn. Excluded from deferred-finalize
920 * holds so the root turn can settle while they keep streaming post-settle.
921 */
922 readonly persistentBackgroundTaskIds: Set<string>;
923 /**
924 * Monitor end events often only say "use get_command…"; keep the turn open
925 * until TaskOutput hydration arrives (or the safety timeout elapses).
926 */
927 readonly awaitingBackgroundHydration: Set<string>;
928 /**
929 * A monitor end notice landed after the prompt settled: the CLI runs an
930 * injected turn whose report never gets a turn_completed marker, so the
931 * report chunk races the deferred-finalize debounce (thread a8e8b0a9 run 5
932 * dropped the listing this way). Hold finalize until the report streams or
933 * the safety timeout elapses.
934 */
935 readonly pendingInjectedReport: Set<string>;
561 plan: {936 plan: {
562 readonly id: OrchestrationV2PlanArtifact["id"];937 readonly id: OrchestrationV2PlanArtifact["id"];
563 readonly startedAt: DateTime.Utc;938 readonly startedAt: DateTime.Utc;
564 } | null;939 } | null;
565 interrupted: boolean;940 interrupted: boolean;
566 finalized: boolean;941 finalized: boolean;
942 settleScheduleGeneration: number;
943 /** session/prompt already returned; finalize deferred for background work. */
944 promptSettled: boolean;
945 promptSettledStatus: "completed" | "interrupted" | "failed" | "cancelled" | null;
946 /**
947 * Completed the moment `runtime.prompt` resolves on the wire, before the
948 * completion callback requests `runtimeCallbackPermit`. Failure does not
949 * complete this; settled-soft classification ORs it with `promptSettled`.
950 */
951 readonly promptWireSettled: Deferred.Deferred<void, never>;
952 backgroundFinalizeGeneration: number;
953}
954
955type AcpRuntimeTeardownState =
956 | { readonly _tag: "Idle" }
957 | {
958 readonly _tag: "InProgress";
959 readonly completed: Deferred.Deferred<void, ProviderAdapterProtocolError>;
960 }
961 | { readonly _tag: "Failed"; readonly error: ProviderAdapterProtocolError };
962
963export function acpRootTurnHasIngestedOutput(context: {
964 readonly assistant: ActiveTextStream;
965 readonly reasoning: ActiveTextStream;
966 readonly tools: ReadonlyMap<string, AcpToolCallState>;
967 readonly plan: unknown;
968}): boolean {
969 return (
970 context.assistant.nextSegment > 0 ||
971 context.reasoning.nextSegment > 0 ||
972 context.tools.size > 0 ||
973 context.plan !== null
974 );
975}
976
977/** True when a root session/update carries ingestible turn output, not keepalive noise. */
978export function acpRootSessionUpdateIngestsOutput(
979 notification: EffectAcpSchema.SessionNotification,
980): boolean {
981 const update = notification.update;
982 switch (update.sessionUpdate) {
983 case "agent_message_chunk":
984 case "agent_thought_chunk":
985 return update.content.type === "text" && update.content.text.length > 0;
986 case "tool_call":
987 case "tool_call_update":
988 case "plan":
989 return parseSessionUpdateEvent(notification).events.some(
990 (event) => event._tag === "ToolCallUpdated" || event._tag === "PlanUpdated",
991 );
992 default:
993 return false;
994 }
995}
996
997/**
998 * Post-settle traffic that should be *buffered* for a continuation attach.
999 * Excludes monitor end/event chatter that must not become ghost history.
1000 * Broader than {@link acpPostSettleContinuationOfferEvidence}: incremental
1001 * tool progress may still need replay once a real completion offers a run.
1002 */
1003export function acpPostSettleWakeEvidence(
1004 notification: EffectAcpSchema.SessionNotification,
1005 flavor: Pick<AcpAdapterV2Flavor, "extractBackgroundToolMutation"> = {},
1006): boolean {
1007 if (!acpRootSessionUpdateIngestsOutput(notification)) return false;
1008 const update = notification.update;
1009 if (
1010 (update.sessionUpdate === "user_message_chunk" ||
1011 update.sessionUpdate === "agent_message_chunk") &&
1012 update.content.type === "text"
1013 ) {
1014 const text = update.content.text;
1015 if ((flavor.extractBackgroundToolMutation?.(text) ?? []).length > 0) return false;
1016 if (/<monitor-event\b/i.test(text) || /Monitor\s+["']?[0-9a-f-]{8,}["']?\s+ended/i.test(text)) {
1017 return false;
1018 }
1019 }
1020 return true;
1021}
1022
1023/**
1024 * Whether buffered post-settle traffic should *offer* a continuation run now.
1025 * Still-running tool streams often land farther apart than the deferred-finalize
1026 * quiet window; treating every tool_call_update as offer evidence re-opens a
1027 * synthetic "Background task completed." run on each chunk. Only completion-like
1028 * frames (real agent text, or a terminal tool status) should open a new run.
1029 */
1030export function acpPostSettleContinuationOfferEvidence(
1031 notification: EffectAcpSchema.SessionNotification,
1032 flavor: Pick<AcpAdapterV2Flavor, "extractBackgroundToolMutation" | "normalizeToolCall"> = {},
1033): boolean {
1034 if (!acpPostSettleWakeEvidence(notification, flavor)) {
1035 return false;
1036 }
1037 const update = notification.update;
1038 // Assistant text only. Thought/reasoning bursts alone must not open synthetic
1039 // "Background task completed." runs (duplicate-run spam after monitors).
1040 if (update.sessionUpdate === "agent_message_chunk") {
1041 return update.content.type === "text" && update.content.text.length > 0;
1042 }
1043 if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
1044 return parseSessionUpdateEvent(notification).events.some((event) => {
1045 if (event._tag !== "ToolCallUpdated") return false;
1046 // Normalize first: a Grok monitor start ACK arrives with raw status
1047 // "completed" but is a still-running background task, not completion.
1048 const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall;
1049 return toolCall.status === "completed" || toolCall.status === "failed";
1050 });
1051 }
1052 return false;
1053}
1054
1055export function acpPostSettleWakeShouldBuffer(
1056 notification: EffectAcpSchema.SessionNotification,
1057 backgroundWorkRunning: boolean,
1058): boolean {
1059 if (!backgroundWorkRunning) return true;
1060 const update = notification.update;
1061 return (
1062 update.sessionUpdate !== "agent_message_chunk" && update.sessionUpdate !== "agent_thought_chunk"
1063 );
1064}
1065
1066export function acpPostSettleMonitorPromptShouldSuppress(
1067 mutation:
1068 | {
1069 readonly taskId: string;
1070 readonly status: "running" | "completed" | "failed";
1071 }
1072 | undefined,
1073): boolean {
1074 return mutation?.status === "running";
1075}
1076
1077export function acpCompletedTurnShouldTerminalizeTool(
1078 tool: AcpToolCallState,
1079 flavor: Pick<AcpAdapterV2Flavor, "extractBackgroundTaskId" | "extractSubagentUpdate">,
1080): boolean {
1081 const status = toolStatus(tool.status);
1082 if (status !== "pending" && status !== "running") return false;
1083 if (flavor.extractBackgroundTaskId?.(tool) !== undefined) return false;
1084 return flavor.extractSubagentUpdate?.(tool) === undefined;
567}1085}
568 1086
569interface ActiveAcpSubagent {1087interface ActiveAcpSubagent {
@@ -572,12 +1090,29 @@ interface ActiveAcpSubagent { · 2 unmodified lines
572 readonly childRootNodeId: OrchestrationV2ExecutionNode["id"];1090 readonly childRootNodeId: OrchestrationV2ExecutionNode["id"];
573 readonly turnItemId: OrchestrationV2TurnItem["id"];1091 readonly turnItemId: OrchestrationV2TurnItem["id"];
574 readonly turnItemOrdinal: number;1092 readonly turnItemOrdinal: number;
1093 /** Turn that spawned the subagent; carryover updates keep this lineage. */
1094 readonly providerTurnId: OrchestrationV2ProviderTurn["id"];
1095 readonly parentProviderThreadId: ProviderThreadId;
575 childSessionId: string | null;1096 childSessionId: string | null;
576 assistantText: string;1097 assistantText: string;
577 nextChildOrdinal: number;1098 nextChildOrdinal: number;
578}1099}
579 1100
1101function acpTurnHasPendingRuntimeRequest(
1102 providerTurnId: OrchestrationV2ProviderTurn["id"],
1103 pending: ReadonlyMap<string, PendingRuntimeRequest>,
1104): boolean {
1105 return [...pending.values()].some(
1106 (request) =>
1107 request.runtimeRequest.providerTurnId === providerTurnId &&
1108 request.runtimeRequest.status === "pending",
1109 );
1110}
1111
580type PendingRuntimeRequest = {1112type PendingRuntimeRequest = {
1113 readonly generation: number;
1114 readonly nativeResponseAcknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>;
1115 readonly transportRequestId: string;
581 readonly requestId: RuntimeRequestId;1116 readonly requestId: RuntimeRequestId;
582 readonly runtimeRequest: OrchestrationV2RuntimeRequest;1117 readonly runtimeRequest: OrchestrationV2RuntimeRequest;
583 readonly node: OrchestrationV2ExecutionNode;1118 readonly node: OrchestrationV2ExecutionNode;
@@ -603,6 +1138,9 @@ interface SnapshotMessageState { · 19 unmodified lines
603export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV2Shape {1138export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV2Shape {
604 const { flavor, fileSystem, idAllocator, serverConfig } = options;1139 const { flavor, fileSystem, idAllocator, serverConfig } = options;
605 const driver = flavor.driver;1140 const driver = flavor.driver;
1141 const continuationRequests = options.continuationRequests;
1142 const postSettleContinuationEnabled =
1143 flavor.enablePostSettleContinuation === true && continuationRequests !== undefined;
606 1144
607 return ProviderAdapterV2.of({1145 return ProviderAdapterV2.of({
608 instanceId: options.instanceId,1146 instanceId: options.instanceId,
@@ -617,7 +1155,33 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 8 unmodified lines
617 const activeSessionId = yield* Ref.make<string | null>(null);1155 const activeSessionId = yield* Ref.make<string | null>(null);
618 const activeSessionSetup = yield* Ref.make<AcpSessionRuntimeStartResult | null>(null);1156 const activeSessionSetup = yield* Ref.make<AcpSessionRuntimeStartResult | null>(null);
619 const activeSelection = yield* Ref.make<ModelSelection | null>(null);1157 const activeSelection = yield* Ref.make<ModelSelection | null>(null);
1158 const runtimeRestartRequired = yield* Ref.make(false);
1159 const runtimeTeardownState = yield* Ref.make<AcpRuntimeTeardownState>({ _tag: "Idle" });
1160 const runtimeCallbackGeneration = yield* Ref.make(0);
1161 const runtimeCallbackPermit = yield* Semaphore.make(1);
1162 const runtimeTransitionPermit = yield* Semaphore.make(1);
1163 const nativeTransportRequests = yield* Ref.make<
1164 Array<{
1165 readonly generation: number;
1166 readonly method: string;
1167 readonly payload: unknown;
1168 readonly requestId: string;
1169 readonly sequence: number;
1170 }>
1171 >([]);
1172 const nextNativeTransportSequence = yield* Ref.make(0);
1173 const nativeResponseAcknowledgements = yield* Ref.make(
1174 new Map<
1175 string,
1176 {
1177 readonly acknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>;
1178 readonly generation: number;
1179 }
1180 >(),
1181 );
620 const pendingRuntimeRequests = yield* Ref.make(new Map<string, PendingRuntimeRequest>());1182 const pendingRuntimeRequests = yield* Ref.make(new Map<string, PendingRuntimeRequest>());
1183 const emitNativeResponseLifecycle =
1184 options.testHooks?.onNativeResponseLifecycle ?? (() => Effect.void);
621 const nextElicitationOrdinal = yield* Ref.make(0);1185 const nextElicitationOrdinal = yield* Ref.make(0);
622 const itemOrdinals = yield* Ref.make(new Map<string, number>());1186 const itemOrdinals = yield* Ref.make(new Map<string, number>());
623 const nextItemOrdinalsByTurn = yield* Ref.make(new Map<string, number>());1187 const nextItemOrdinalsByTurn = yield* Ref.make(new Map<string, number>());
@@ -629,20 +1193,434 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines
629 loadingIndex: 0,1193 loadingIndex: 0,
630 });1194 });
631 1195
1196 const awaitRuntimeTeardown = Effect.fnUntraced(function* () {
1197 const state = yield* Ref.get(runtimeTeardownState);
1198 if (state._tag === "InProgress") {
1199 yield* Deferred.await(state.completed);
1200 } else if (state._tag === "Failed") {
1201 return yield* state.error;
1202 }
1203 });
1204 const runRuntimeCallbackAtGeneration = <A, E, R>(
1205 generation: number,
1206 effect: Effect.Effect<A, E, R>,
1207 ) =>
1208 runtimeCallbackPermit.withPermit(
1209 Effect.gen(function* () {
1210 if ((yield* Ref.get(runtimeTeardownState))._tag !== "Idle") {
1211 return Option.none<A>();
1212 }
1213 if ((yield* Ref.get(runtimeCallbackGeneration)) !== generation) {
1214 return Option.none<A>();
1215 }
1216 return Option.some(yield* effect);
1217 }),
1218 );
1219 const claimNativeTransportRequest = (
1220 generation: number,
1221 predicate: (request: { readonly method: string; readonly payload: unknown }) => boolean,
1222 ) =>
1223 Ref.modify(
1224 nativeTransportRequests,
1225 (
1226 requests,
1227 ): readonly [
1228 string | undefined,
1229 Array<{
1230 readonly generation: number;
1231 readonly method: string;
1232 readonly payload: unknown;
1233 readonly requestId: string;
1234 readonly sequence: number;
1235 }>,
1236 ] => acpClaimNativeTransportRequest(requests, generation, predicate),
1237 );
1238 const registerNativeResponseAcknowledgement = (
1239 generation: number,
1240 transportRequestId: string,
1241 acknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>,
1242 ) =>
1243 Effect.gen(function* () {
1244 yield* Ref.update(nativeResponseAcknowledgements, (current) => {
1245 const updated = new Map(current);
1246 updated.set(transportRequestId, { acknowledgement, generation });
1247 return updated;
1248 });
1249 yield* emitNativeResponseLifecycle({
1250 type: "registered",
1251 generation,
1252 requestId: transportRequestId,
1253 });
1254 if (!(yield* Deferred.isDone(acknowledgement))) return;
1255 yield* Ref.update(nativeResponseAcknowledgements, (current) => {
1256 if (current.get(transportRequestId)?.acknowledgement !== acknowledgement) {
1257 return current;
1258 }
1259 const updated = new Map(current);
1260 updated.delete(transportRequestId);
1261 return updated;
1262 });
1263 });
1264 const acknowledgeNativeResponse = (generation: number, transportRequestId: string) =>
1265 (
1266 options.testHooks?.beforeNativeResponseAdmissionCheck?.(
1267 generation,
1268 transportRequestId,
1269 ) ?? Effect.void
1270 ).pipe(
1271 Effect.andThen(runRuntimeCallbackAtGeneration(generation, Effect.void)),
1272 Effect.flatMap((registered) =>
1273 Option.isSome(registered)
1274 ? Effect.void
1275 : emitNativeResponseLifecycle({
1276 type: "admission_rejected",
1277 generation,
1278 requestId: transportRequestId,
1279 }).pipe(
1280 Effect.andThen(
1281 new EffectAcpErrors.AcpTransportError({
1282 detail: "The ACP runtime closed before its response could be admitted",
1283 cause: "Native response registration rejected during teardown",
1284 }),
1285 ),
1286 ),
1287 ),
1288 );
1289 const awaitNativeResponseAcknowledgements = Effect.fnUntraced(function* (
1290 acknowledgements: ReadonlyArray<
1291 readonly [string | undefined, Deferred.Deferred<void, EffectAcpErrors.AcpError>]
1292 >,
1293 ) {
1294 if (acknowledgements.length === 0) return true;
1295 const completed = yield* Deferred.make<"settled" | "timeout">();
1296 yield* emitNativeResponseLifecycle({ type: "watcher_started" });
1297 const acknowledgementFiber = yield* Effect.forEach(
1298 acknowledgements,
1299 ([, acknowledgement]) => Deferred.await(acknowledgement).pipe(Effect.exit),
1300 { concurrency: "unbounded", discard: true },
1301 ).pipe(
1302 Effect.andThen(Deferred.succeed(completed, "settled")),
1303 Effect.interruptible,
1304 Effect.ensuring(emitNativeResponseLifecycle({ type: "watcher_exited" })),
1305 Effect.forkDetach,
1306 );
1307 yield* emitNativeResponseLifecycle({ type: "timer_started" });
1308 const timerFiber = yield* Effect.sleep("2 seconds").pipe(
1309 Effect.andThen(Deferred.succeed(completed, "timeout")),
1310 Effect.interruptible,
1311 Effect.ensuring(emitNativeResponseLifecycle({ type: "timer_exited" })),
1312 Effect.forkDetach,
1313 );
1314 const outcome = yield* Deferred.await(completed);
1315 yield* Fiber.interrupt(acknowledgementFiber);
1316 yield* Fiber.interrupt(timerFiber);
1317 if (outcome === "settled") return true;
1318
1319 yield* Ref.update(nativeResponseAcknowledgements, (current) => {
1320 const updated = new Map(current);
1321 for (const [requestId, acknowledgement] of acknowledgements) {
1322 if (
1323 requestId !== undefined &&
1324 updated.get(requestId)?.acknowledgement === acknowledgement
1325 ) {
1326 updated.delete(requestId);
1327 }
1328 }
1329 return updated;
1330 });
1331 yield* Effect.forEach(
1332 acknowledgements,
1333 ([requestId]) =>
1334 emitNativeResponseLifecycle({
1335 type: "removed",
1336 ...(requestId === undefined ? {} : { requestId }),
1337 }),
1338 { concurrency: "unbounded", discard: true },
1339 );
1340 const timeoutError = new EffectAcpErrors.AcpTransportError({
1341 detail: "Timed out waiting for an admitted ACP response to reach the transport queue",
1342 cause: "Native response acknowledgement timed out",
1343 });
1344 yield* Effect.forEach(
1345 acknowledgements,
1346 ([requestId, acknowledgement]) =>
1347 Deferred.fail(acknowledgement, timeoutError).pipe(
1348 Effect.andThen(
1349 emitNativeResponseLifecycle({
1350 type: "failed",
1351 ...(requestId === undefined ? {} : { requestId }),
1352 }),
1353 ),
1354 ),
1355 { concurrency: "unbounded", discard: true },
1356 );
1357 return false;
1358 });
1359 const awaitAdmittedNativeResponses = Effect.gen(function* () {
1360 yield* awaitNativeResponseAcknowledgements(
1361 [...(yield* Ref.get(nativeResponseAcknowledgements)).entries()].map(
1362 ([requestId, entry]) => [requestId, entry.acknowledgement] as const,
1363 ),
1364 );
1365 });
1366 const quarantineNativeTransportAtGeneration = Effect.fnUntraced(function* (
1367 generation: number,
1368 ) {
1369 yield* Ref.update(nativeTransportRequests, (requests) =>
1370 requests.filter((request) => request.generation !== generation),
1371 );
1372 const quarantined = yield* Ref.modify(nativeResponseAcknowledgements, (current) => {
1373 const updated = new Map(current);
1374 const acknowledgements: Array<Deferred.Deferred<void, EffectAcpErrors.AcpError>> = [];
1375 for (const [requestId, entry] of updated) {
1376 if (entry.generation !== generation) continue;
1377 updated.delete(requestId);
1378 acknowledgements.push(entry.acknowledgement);
1379 }
1380 return [acknowledgements, updated] as const;
1381 });
1382 const error = new EffectAcpErrors.AcpTransportError({
1383 detail: "The ACP runtime was replaced before its response reached the transport queue",
1384 cause: "ACP runtime transport was quarantined during teardown",
1385 });
1386 yield* Effect.forEach(
1387 quarantined,
1388 (acknowledgement) => Deferred.fail(acknowledgement, error),
1389 { concurrency: "unbounded", discard: true },
1390 );
1391 });
1392 const closeNativeTransport = runtimeCallbackPermit.withPermit(
1393 Effect.gen(function* () {
1394 yield* Ref.update(runtimeCallbackGeneration, (generation) => generation + 1);
1395 yield* Ref.set(nativeTransportRequests, []);
1396 const acknowledgements = yield* Ref.getAndSet(
1397 nativeResponseAcknowledgements,
1398 new Map(),
1399 );
1400 const error = new EffectAcpErrors.AcpTransportError({
1401 detail: "The ACP session closed before its admitted response reached the transport",
1402 cause: "ACP session transport closed",
1403 });
1404 yield* Effect.forEach(
1405 acknowledgements,
1406 ([requestId, entry]) =>
1407 Deferred.fail(entry.acknowledgement, error).pipe(
1408 Effect.andThen(
1409 emitNativeResponseLifecycle({
1410 type: "removed",
1411 generation: entry.generation,
1412 requestId,
1413 }),
1414 ),
1415 Effect.andThen(
1416 emitNativeResponseLifecycle({
1417 type: "failed",
1418 generation: entry.generation,
1419 requestId,
1420 }),
1421 ),
1422 ),
1423 { concurrency: "unbounded", discard: true },
1424 );
1425 return acknowledgements.size > 0;
1426 }),
1427 );
1428 // Post-settle wake support (Grok async subagent/monitor follow-up). After
1429 // the root turn finalizes, later root session/update traffic buffers here
1430 // until a provider continuation run attaches and drains it.
1431 const lastTurnRoute = yield* Ref.make<{
1432 readonly threadId: ThreadId;
1433 readonly providerThreadId: ProviderThreadId;
1434 } | null>(null);
1435 const wakeBuffer = yield* Ref.make<Array<EffectAcpSchema.SessionNotification>>([]);
1436 const continuationRequested = yield* Ref.make(false);
1437 const continuationGeneration = yield* Ref.make(0);
1438 const continuationPermit = yield* Semaphore.make(1);
1439 const continuationClosed = yield* Ref.make(false);
1440 // Direct Stop (requestRuntimeRestart) quarantines residual events from the
1441 // stopped run so they cannot wake or attach to a later prompt/run.
1442 const stoppedRunQuarantine = yield* Ref.make(false);
1443 // A steering restart (or any interrupt) can finalize a turn while its
1444 // spawned subagents are still running natively. Carry the live
1445 // lineages into the next turn on the same session so their terminal
1446 // signals can still flip the original turn items instead of leaving
1447 // them running forever.
1448 const carryoverSubagents = yield* Ref.make<{
1449 readonly sessionId: string;
1450 readonly subagents: ReadonlyArray<ActiveAcpSubagent>;
1451 } | null>(null);
1452 const handledBackgroundTaskIdsInActiveTurn = yield* Ref.make<ReadonlySet<string>>(
1453 new Set(),
1454 );
1455 // A monitor-event can arrive after its task and the user-facing provider
1456 // turn already completed. Grok starts another internal prompt for that
1457 // stale notification; suppress its agent output until a genuine terminal
1458 // mutation or the next app turn so it cannot create a redundant app
1459 // continuation. Tool frames continue through normal hydration.
1460 const suppressPostSettleMonitorPrompt = yield* Ref.make(false);
1461 // Background tasks (Grok monitors) known to still run at session level.
1462 // Turn contexts are too short-lived to carry this: a continuation run
1463 // finalizes between monitor events, and the next commentary burst must
1464 // not reopen a run while the monitor is still streaming.
1465 const runningBackgroundTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set());
1466 // Task ids with a GENUINE end signal (monitor-ended reminder or
1467 // TaskOutput completion). Normalized tool statuses are not genuine:
1468 // Grok Bash re-reports carry exit_code 0 mid-stream. A straggler
1469 // monitor-event can land after the real end (the CLI keeps streaming
1470 // while the agent already consumed the output via
1471 // get_command_or_subagent_output); without the tombstone it would
1472 // resurrect the running set and pin offers/idle-release forever.
1473 // A tool-level failed get_command tombstones too: failing open to a
1474 // single continuation beats failing closed to a dead thread.
1475 const endedBackgroundTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set());
1476 const endedBackgroundTaskIdLimit = 128;
1477
1478 const setBackgroundTaskRunning = (taskId: string, running: boolean) =>
1479 Effect.gen(function* () {
1480 if (running && (yield* Ref.get(endedBackgroundTaskIds)).has(taskId)) {
1481 return;
1482 }
1483 yield* Ref.update(runningBackgroundTaskIds, (current) => {
1484 if (current.has(taskId) === running) return current;
1485 const next = new Set(current);
1486 if (running) {
1487 next.add(taskId);
1488 } else {
1489 next.delete(taskId);
1490 }
1491 return next;
1492 });
1493 });
1494
1495 const markBackgroundTaskEnded = (taskId: string) =>
1496 Ref.update(endedBackgroundTaskIds, (current) => {
1497 if (current.has(taskId)) return current;
1498 const next = new Set(current).add(taskId);
1499 for (const oldest of next) {
1500 if (next.size <= endedBackgroundTaskIdLimit) break;
1501 next.delete(oldest);
1502 }
1503 return next;
1504 }).pipe(Effect.andThen(setBackgroundTaskRunning(taskId, false)));
1505
1506 const applyBackgroundTaskMutationRunning = (mutation: {
1507 readonly taskId: string;
1508 readonly status: "running" | "completed" | "failed";
1509 }) =>
1510 mutation.status === "running"
1511 ? setBackgroundTaskRunning(mutation.taskId, true)
1512 : markBackgroundTaskEnded(mutation.taskId);
1513
1514 const trackRunningBackgroundTools = (
1515 notification: EffectAcpSchema.SessionNotification,
1516 ): Effect.Effect<void> =>
1517 Effect.gen(function* () {
1518 if (flavor.extractBackgroundTaskId === undefined) return;
1519 for (const event of parseSessionUpdateEvent(notification).events) {
1520 if (event._tag !== "ToolCallUpdated") continue;
1521 const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall;
1522 const taskId = flavor.extractBackgroundTaskId(toolCall);
1523 if (taskId === undefined) continue;
1524 const status = toolStatus(toolCall.status);
1525 yield* setBackgroundTaskRunning(taskId, status === "pending" || status === "running");
1526 }
1527 });
1528
632 const emitProviderEvent = (event: ProviderAdapterV2Event) =>1529 const emitProviderEvent = (event: ProviderAdapterV2Event) =>
633 Queue.offer(events, event).pipe(Effect.asVoid);1530 Queue.offer(events, event).pipe(Effect.asVoid);
1531 let scheduleSettleRootTurnWhenIdle = (_context: ActiveAcpTurn) => Effect.void;
1532 let rearmRootTurnRecoveryTimers = (_context: ActiveAcpTurn) => Effect.void;
1533 let scheduleDeferredFinalize: (context: ActiveAcpTurn) => Effect.Effect<void> = () =>
1534 Effect.void;
634 1535
635 const nativeLogging = options.nativeLogging?.(input.threadId);1536 const nativeLogging = options.nativeLogging?.(input.threadId);
636 1537 const makeRuntimeInput = (runtimeGeneration: number): AcpAdapterV2RuntimeInput => ({
637 const runtime = yield* flavor.makeRuntime({
638 cwd: input.runtimePolicy.cwd ?? process.cwd(),1538 cwd: input.runtimePolicy.cwd ?? process.cwd(),
639 mcpServers: acpMcpServers(input.threadId),1539 mcpServers: acpMcpServers(input.threadId),
1540 interruptPromptOnCancel: flavor.interruptPromptOnCancel ?? false,
640 clientCapabilities: {1541 clientCapabilities: {
641 fs: { readTextFile: false, writeTextFile: false },1542 fs: { readTextFile: false, writeTextFile: false },
642 terminal: false,1543 terminal: false,
643 elicitation: { form: {} },1544 elicitation: { form: {} },
644 },1545 },
645 clientInfo: { name: "t3-code", version: "0.0.0" },1546 clientInfo: { name: "t3-code", version: "0.0.0" },
1547 onIncomingRequest: (requestId, method, payload) =>
1548 runRuntimeCallbackAtGeneration(
1549 runtimeGeneration,
1550 Effect.gen(function* () {
1551 const sequence = yield* Ref.getAndUpdate(
1552 nextNativeTransportSequence,
1553 (current) => current + 1,
1554 );
1555 yield* Ref.update(nativeTransportRequests, (current) => [
1556 ...current,
1557 { generation: runtimeGeneration, method, payload, requestId, sequence },
1558 ]);
1559 }),
1560 ).pipe(Effect.asVoid),
1561 onTermination: () =>
1562 runRuntimeCallbackAtGeneration(
1563 runtimeGeneration,
1564 Ref.set(runtimeRestartRequired, true),
1565 ).pipe(Effect.asVoid),
1566 onOutgoingResponseFailure: (requestId, error) =>
1567 Ref.modify(nativeResponseAcknowledgements, (current) => {
1568 const entry = current.get(requestId);
1569 if (entry === undefined || entry.generation !== runtimeGeneration) {
1570 return [
1571 emitNativeResponseLifecycle({
1572 type: "late_noop",
1573 generation: runtimeGeneration,
1574 requestId,
1575 }),
1576 current,
1577 ] as const;
1578 }
1579 const updated = new Map(current);
1580 updated.delete(requestId);
1581 return [
1582 Deferred.fail(entry.acknowledgement, error).pipe(
1583 Effect.andThen(
1584 emitNativeResponseLifecycle({
1585 type: "removed",
1586 generation: runtimeGeneration,
1587 requestId,
1588 }),
1589 ),
1590 Effect.asVoid,
1591 ),
1592 updated,
1593 ] as const;
1594 }).pipe(Effect.flatten),
1595 onOutgoingResponse: (requestId) =>
1596 Ref.modify(nativeResponseAcknowledgements, (current) => {
1597 const entry = current.get(requestId);
1598 if (entry === undefined || entry.generation !== runtimeGeneration) {
1599 return [
1600 emitNativeResponseLifecycle({
1601 type: "late_noop",
1602 generation: runtimeGeneration,
1603 requestId,
1604 }),
1605 current,
1606 ] as const;
1607 }
1608 const updated = new Map(current);
1609 updated.delete(requestId);
1610 return [
1611 Deferred.succeed(entry.acknowledgement, undefined).pipe(
1612 Effect.andThen(
1613 emitNativeResponseLifecycle({
1614 type: "removed",
1615 generation: runtimeGeneration,
1616 requestId,
1617 }),
1618 ),
1619 Effect.asVoid,
1620 ),
1621 updated,
1622 ] as const;
1623 }).pipe(Effect.flatten),
646 ...(nativeLogging?.requestLogger === undefined1624 ...(nativeLogging?.requestLogger === undefined
647 ? {}1625 ? {}
648 : { requestLogger: nativeLogging.requestLogger }),1626 : { requestLogger: nativeLogging.requestLogger }),
@@ -652,6 +1630,13 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines
652 logger: () => Effect.void,1630 logger: () => Effect.void,
653 },1631 },
654 });1632 });
1633 let runtimeScope: Scope.Closeable | undefined;
1634 let runtime!: AcpSessionRuntime.AcpSessionRuntime["Service"];
1635 yield* Effect.addFinalizer(() =>
1636 runtimeScope === undefined
1637 ? Effect.void
1638 : Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore),
1639 );
655 1640
656 const resolveItemOrdinal = Effect.fnUntraced(function* (1641 const resolveItemOrdinal = Effect.fnUntraced(function* (
657 context: ActiveAcpTurn,1642 context: ActiveAcpTurn,
@@ -811,6 +1796,9 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 153 unmodified lines
811 if (stream.current === null) return;1796 if (stream.current === null) return;
812 yield* emitTextSegment(context, kind, true);1797 yield* emitTextSegment(context, kind, true);
813 stream.current = null;1798 stream.current = null;
1799 if (kind === "assistant") {
1800 yield* scheduleSettleRootTurnWhenIdle(context);
1801 }
814 });1802 });
815 1803
816 const closeTextStreams = Effect.fnUntraced(function* (context: ActiveAcpTurn) {1804 const closeTextStreams = Effect.fnUntraced(function* (context: ActiveAcpTurn) {
@@ -883,39 +1871,56 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 66 unmodified lines
883 context: ActiveAcpTurn,1871 context: ActiveAcpTurn,
884 update: AcpAdapterV2SubagentUpdate,1872 update: AcpAdapterV2SubagentUpdate,
885 ) {1873 ) {
886 const existing = context.subagents.get(update.nativeTaskId);1874 // Hydration tools (get_command_or_subagent_output) use a new toolCallId
1875 // but reference the child via subagent_id / task id.
1876 const existing =
1877 context.subagents.get(update.nativeTaskId) ??
1878 (update.childSessionId !== null
1879 ? context.subagentsBySessionId.get(update.childSessionId)
1880 : undefined);
1881 // get_command_or_subagent_output may target monitors/bash tasks. Only
1882 // hydrate when we already have a matching subagent lineage. Spawn ACKs
1883 // (non-empty prompt) may create a new lineage; empty-prompt hydration
1884 // with suppressNormalTool must not invent a phantom subagent.
1885 if (existing === undefined) {
1886 const isHydrationOnly = update.prompt === "" && update.title === null;
1887 if (isHydrationOnly || update.suppressNormalTool === false) {
1888 return;
1889 }
1890 }
887 const now = yield* DateTime.now;1891 const now = yield* DateTime.now;
1892 const nativeTaskId = existing?.task.nativeTaskRef?.nativeId ?? update.nativeTaskId;
888 const nativeItemRef = {1893 const nativeItemRef = {
889 driver,1894 driver,
890 nativeId: update.nativeTaskId,1895 nativeId: nativeTaskId,
891 strength: "strong" as const,1896 strength: "strong" as const,
892 };1897 };
893 const nodeId =1898 const nodeId =
894 existing?.task.id ??1899 existing?.task.id ??
895 idAllocator.derive.nodeFromProviderItem({1900 idAllocator.derive.nodeFromProviderItem({
896 driver,1901 driver,
897 nativeItemId: update.nativeTaskId,1902 nativeItemId: nativeTaskId,
898 });1903 });
899 const childThreadId =1904 const childThreadId =
900 existing?.childThreadId ??1905 existing?.childThreadId ??
901 idAllocator.derive.threadFromProviderThread({1906 idAllocator.derive.threadFromProviderThread({
902 driver,1907 driver,
903 nativeThreadId: `${nativeThreadId(driver, context.input.providerThread)}:task:${update.nativeTaskId}`,1908 nativeThreadId: `${nativeThreadId(driver, context.input.providerThread)}:task:${nativeTaskId}`,
904 });1909 });
905 const childRootNodeId =1910 const childRootNodeId =
906 existing?.childRootNodeId ??1911 existing?.childRootNodeId ??
907 idAllocator.derive.nodeFromProviderItem({1912 idAllocator.derive.nodeFromProviderItem({
908 driver,1913 driver,
909 nativeItemId: `${update.nativeTaskId}:child-root`,1914 nativeItemId: `${nativeTaskId}:child-root`,
910 });1915 });
911 const turnItemId =1916 const turnItemId =
912 existing?.turnItemId ??1917 existing?.turnItemId ??
913 idAllocator.derive.turnItemFromProviderItem({1918 idAllocator.derive.turnItemFromProviderItem({
914 driver,1919 driver,
915 nativeItemId: update.nativeTaskId,1920 nativeItemId: nativeTaskId,
916 });1921 });
917 const turnItemOrdinal =1922 const turnItemOrdinal =
918 existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(context, update.nativeTaskId));1923 existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(context, nativeTaskId));
919 const taskStatus = update.status;1924 const taskStatus = update.status;
920 const task: OrchestrationV2Subagent = {1925 const task: OrchestrationV2Subagent = {
921 ...(existing?.task ?? {1926 ...(existing?.task ?? {
@@ -947,12 +1952,14 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 25 unmodified lines
947 childRootNodeId,1952 childRootNodeId,
948 turnItemId,1953 turnItemId,
949 turnItemOrdinal,1954 turnItemOrdinal,
1955 providerTurnId: context.providerTurnId,
1956 parentProviderThreadId: context.input.providerThread.id,
950 childSessionId: null,1957 childSessionId: null,
951 assistantText: "",1958 assistantText: "",
952 nextChildOrdinal: 101,1959 nextChildOrdinal: 101,
953 };1960 };
954 subagent.task = task;1961 subagent.task = task;
955 context.subagents.set(update.nativeTaskId, subagent);1962 context.subagents.set(nativeTaskId, subagent);
956 1963
957 if (existing === undefined) {1964 if (existing === undefined) {
958 yield* emitProviderEvent({1965 yield* emitProviderEvent({
@@ -979,7 +1986,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 20 unmodified lines
979 creationSource: "provider",1986 creationSource: "provider",
980 }),1987 }),
981 });1988 });
982 const promptNativeItemId = `${update.nativeTaskId}:prompt`;1989 const promptNativeItemId = `${nativeTaskId}:prompt`;
983 const promptArtifacts = makeSubagentConversationArtifacts({1990 const promptArtifacts = makeSubagentConversationArtifacts({
984 messageId: idAllocator.derive.messageFromProviderItem({1991 messageId: idAllocator.derive.messageFromProviderItem({
985 driver,1992 driver,
@@ -1064,14 +2071,14 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 78 unmodified lines
1064 node: {2071 node: {
1065 id: nodeId,2072 id: nodeId,
1066 threadId: context.input.threadId,2073 threadId: context.input.threadId,
1067 runId: context.input.runId,2074 runId: subagent.task.runId,
1068 parentNodeId: context.input.rootNodeId,2075 parentNodeId: subagent.task.parentNodeId,
1069 rootNodeId: context.input.rootNodeId,2076 rootNodeId: subagent.task.parentNodeId,
1070 kind: "subagent",2077 kind: "subagent",
1071 status: taskStatus,2078 status: taskStatus,
1072 countsForRun: false,2079 countsForRun: false,
1073 providerThreadId: context.input.providerThread.id,2080 providerThreadId: context.input.providerThread.id,
1074 providerTurnId: context.providerTurnId,2081 providerTurnId: subagent.providerTurnId,
1075 nativeItemRef,2082 nativeItemRef,
1076 runtimeRequestId: null,2083 runtimeRequestId: null,
1077 checkpointScopeId: null,2084 checkpointScopeId: null,
@@ -1107,10 +2114,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 29 unmodified lines
1107 turnItem: {2114 turnItem: {
1108 id: turnItemId,2115 id: turnItemId,
1109 threadId: context.input.threadId,2116 threadId: context.input.threadId,
1110 runId: context.input.runId,2117 runId: subagent.task.runId,
1111 nodeId,2118 nodeId,
1112 providerThreadId: context.input.providerThread.id,2119 providerThreadId: context.input.providerThread.id,
1113 providerTurnId: context.providerTurnId,2120 providerTurnId: subagent.providerTurnId,
1114 nativeItemRef,2121 nativeItemRef,
1115 parentItemId: null,2122 parentItemId: null,
1116 ordinal: turnItemOrdinal,2123 ordinal: turnItemOrdinal,
@@ -1131,23 +2138,231 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 14 unmodified lines
1131 });2138 });
1132 });2139 });
1133 2140
1134 const emitTool = Effect.fnUntraced(function* (2141 const toolOutputText = (toolCall: AcpToolCallState): string => {
2142 if (typeof toolCall.data.rawOutput === "string") {
2143 return toolCall.data.rawOutput;
2144 }
2145 if (
2146 typeof (toolCall.data.rawOutput as { text?: unknown } | undefined)?.text === "string"
2147 ) {
2148 return String((toolCall.data.rawOutput as { text: string }).text);
2149 }
2150 return textFromUnknown(toolCall.data.rawOutput) ?? "";
2151 };
2152
2153 const setToolOutputText = (toolCall: AcpToolCallState, text: string): AcpToolCallState => ({
2154 ...toolCall,
2155 data: {
2156 ...toolCall.data,
2157 rawOutput: {
2158 type: "Text",
2159 text,
2160 },
2161 },
2162 });
2163
2164 const appendToolOutputText = (
2165 toolCall: AcpToolCallState,
2166 appendOutput: string,
2167 ): AcpToolCallState => {
2168 if (appendOutput.length === 0) return toolCall;
2169 return setToolOutputText(toolCall, `${toolOutputText(toolCall)}${appendOutput}`);
2170 };
2171
2172 const isMonitorEndNoticeText = (text: string): boolean =>
2173 /Monitor\s+["']?[0-9a-f-]{8,}/i.test(text) && /ended/i.test(text);
2174
2175 const hasDeferredBackgroundWork = (context: ActiveAcpTurn): boolean => {
2176 if (context.awaitingBackgroundHydration.size > 0) return true;
2177 if (context.pendingInjectedReport.size > 0) return true;
2178 for (const [taskId, toolCallId] of context.toolCallIdsByBackgroundTaskId) {
2179 // Persistent monitors stream after root settle; do not pin finalize.
2180 if (context.persistentBackgroundTaskIds.has(taskId)) continue;
2181 const tool = context.tools.get(toolCallId);
2182 if (tool === undefined) continue;
2183 const status = toolStatus(tool.status);
2184 if (status === "pending" || status === "running") return true;
2185 }
2186 for (const subagent of context.subagents.values()) {
2187 if (subagent.task.status === "running" || subagent.task.status === "pending") {
2188 return true;
2189 }
2190 }
2191 return false;
2192 };
2193
2194 // scheduleDeferredFinalize is declared above openSession body start and
2195 // assigned after finalizeTurn so forked finalize Effect typing stays clean.
2196 const rearmDeferredFinalize = (context: ActiveAcpTurn) =>
2197 Effect.gen(function* () {
2198 if (!flavor.deferFinalizeForBackgroundWork) return;
2199 if (!context.promptSettled || context.finalized) return;
2200 if (hasDeferredBackgroundWork(context)) return;
2201 yield* scheduleDeferredFinalize(context);
2202 });
2203
2204 // `let` breaks circular inference from monitor hydration re-entry.
2205 let emitTool: (
2206 context: ActiveAcpTurn,
2207 incoming: AcpToolCallState,
2208 projectedStatus?: ProjectedToolStatus,
2209 ) => Effect.Effect<void> = () => Effect.void;
2210
2211 const markAwaitingBackgroundHydration = (context: ActiveAcpTurn, taskId: string) =>
2212 Effect.gen(function* () {
2213 if (context.awaitingBackgroundHydration.has(taskId)) return;
2214 context.awaitingBackgroundHydration.add(taskId);
2215 // Safety: do not hold the root turn forever if the agent never hydrates.
2216 yield* Effect.gen(function* () {
2217 yield* Effect.sleep("60000 millis");
2218 if (context.finalized || !context.awaitingBackgroundHydration.has(taskId)) return;
2219 context.awaitingBackgroundHydration.delete(taskId);
2220 // End notices keep the tool running until hydration; force-complete so
2221 // deferred finalize can proceed when get_command never arrives.
2222 const toolCallId = context.toolCallIdsByBackgroundTaskId.get(taskId);
2223 const tool = toolCallId !== undefined ? context.tools.get(toolCallId) : undefined;
2224 if (tool !== undefined) {
2225 const status = toolStatus(tool.status);
2226 if (status === "pending" || status === "running") {
2227 yield* emitTool(context, { ...tool, status: "completed" });
2228 }
2229 }
2230 yield* rearmDeferredFinalize(context);
2231 }).pipe(Effect.forkIn(sessionScope), Effect.asVoid);
2232 });
2233
2234 const markPendingInjectedReport = (context: ActiveAcpTurn, taskId: string) =>
2235 Effect.gen(function* () {
2236 // Only the settled-and-held window has the race; mid-turn reports
2237 // are protected by the prompt RPC still being open.
2238 if (!context.promptSettled) return;
2239 if (context.pendingInjectedReport.has(taskId)) return;
2240 context.pendingInjectedReport.add(taskId);
2241 // Safety: the injected turn may end without a report chunk.
2242 yield* Effect.gen(function* () {
2243 yield* Effect.sleep("25000 millis");
2244 if (context.finalized || !context.pendingInjectedReport.has(taskId)) return;
2245 context.pendingInjectedReport.delete(taskId);
2246 yield* rearmDeferredFinalize(context);
2247 }).pipe(Effect.forkIn(sessionScope), Effect.asVoid);
2248 });
2249
2250 emitTool = Effect.fnUntraced(function* (
1135 context: ActiveAcpTurn,2251 context: ActiveAcpTurn,
1136 incoming: AcpToolCallState,2252 incoming: AcpToolCallState,
2253 projectedStatus?: ProjectedToolStatus,
1137 ) {2254 ) {
1138 yield* closeTextStreams(context);2255 yield* closeTextStreams(context);
1139 const previous = context.tools.get(incoming.toolCallId);2256 const previous = context.tools.get(incoming.toolCallId);
1140 const toolCall = mergeToolCallState(previous, incoming);2257 const merged = mergeToolCallState(previous, incoming);
2258 const toolCall = flavor.normalizeToolCall?.(merged) ?? merged;
1141 context.tools.set(toolCall.toolCallId, toolCall);2259 context.tools.set(toolCall.toolCallId, toolCall);
1142 const subagentUpdate = flavor.extractSubagentUpdate?.(toolCall);2260 const backgroundTaskId = flavor.extractBackgroundTaskId?.(toolCall);
1143 if (subagentUpdate !== undefined) {2261 if (backgroundTaskId !== undefined) {
1144 yield* emitSubagent(context, subagentUpdate);2262 context.toolCallIdsByBackgroundTaskId.set(backgroundTaskId, toolCall.toolCallId);
1145 return;2263 if (flavor.isPersistentBackgroundTool?.(toolCall) === true) {
2264 context.persistentBackgroundTaskIds.add(backgroundTaskId);
2265 }
2266 const backgroundStatus = projectedStatus ?? toolStatus(toolCall.status);
2267 yield* setBackgroundTaskRunning(
2268 backgroundTaskId,
2269 backgroundStatus === "pending" || backgroundStatus === "running",
2270 );
2271 // Background tool that reaches a terminal status while the root
2272 // prompt is still open (completed, failed, or interrupted) was
2273 // consumed in-turn. Mark handled so late CLI re-reports and residual
2274 // agent chatter do not open synthetic "Background task completed."
2275 // runs. get_command TaskOutput still marks handled below.
2276 // Only before promptSettled: after STARTED the deferred-finalize
2277 // hold can let a monitor finish while the turn is still active;
2278 // marking then would suppress the legitimate post-settle TaskOutput
2279 // continuation (live: grok-post-settle-continuation-poll).
2280 // Terminal statuses only after normalizeToolCall (start ACKs stay
2281 // inProgress/running).
2282 if (
2283 !context.promptSettled &&
2284 backgroundStatus !== "pending" &&
2285 backgroundStatus !== "running"
2286 ) {
2287 yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) =>
2288 new Set(current).add(backgroundTaskId),
2289 );
2290 }
1146 }2291 }
1147 const status = toolStatus(toolCall.status);2292
1148 const now = yield* DateTime.now;2293 // get_command TaskOutput for registered monitor(s): hydrate those tools.
1149 const nativeItemId = `${nativeThreadId(driver, context.input.providerThread)}:tool:${toolCall.toolCallId}`;2294 const backgroundCompletions =
1150 const ordinal = yield* resolveItemOrdinal(context, nativeItemId);2295 projectedStatus === undefined
2296 ? (flavor.extractBackgroundTaskCompletion?.(toolCall) ?? [])
2297 : [];
2298 let hydratedRegisteredMonitor = false;
2299 for (const backgroundCompletion of backgroundCompletions) {
2300 // Genuine end signal when terminal: tombstone so straggler
2301 // monitor-event chatter cannot resurrect the running set after the
2302 // task truly ended. A still-running poll keeps the id running.
2303 yield* applyBackgroundTaskMutationRunning(backgroundCompletion);
2304 if (backgroundCompletion.status !== "running") {
2305 yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) =>
2306 new Set(current).add(backgroundCompletion.taskId),
2307 );
2308 }
2309 // A still-running fetch must keep the hydration hold (and its
2310 // safety timer) alive until output actually lands.
2311 if (backgroundCompletion.status !== "running") {
2312 context.awaitingBackgroundHydration.delete(backgroundCompletion.taskId);
2313 }
2314 const targetToolCallId = context.toolCallIdsByBackgroundTaskId.get(
2315 backgroundCompletion.taskId,
2316 );
2317 // Known background-task id (monitor registration) — never open a
2318 // phantom subagent for the same get_output poll.
2319 if (targetToolCallId !== undefined) {
2320 hydratedRegisteredMonitor = true;
2321 }
2322 const target =
2323 targetToolCallId !== undefined ? context.tools.get(targetToolCallId) : undefined;
2324 if (target !== undefined && target.toolCallId !== toolCall.toolCallId) {
2325 const nextStatus =
2326 backgroundCompletion.status === "running"
2327 ? ("inProgress" as const)
2328 : backgroundCompletion.status === "failed"
2329 ? ("failed" as const)
2330 : ("completed" as const);
2331 const hydrated =
2332 backgroundCompletion.appendOutput.length > 0
2333 ? // TaskOutput is the real stdout; replace end-notice boilerplate
2334 // so the timeline shows the listing, not only "Monitor ended…".
2335 isMonitorEndNoticeText(toolOutputText(target)) ||
2336 toolOutputText(target).trim().length === 0
2337 ? setToolOutputText(
2338 { ...target, status: nextStatus },
2339 backgroundCompletion.appendOutput,
2340 )
2341 : appendToolOutputText(
2342 { ...target, status: nextStatus },
2343 backgroundCompletion.appendOutput,
2344 )
2345 : { ...target, status: nextStatus };
2346 yield* emitTool(context, hydrated);
2347 }
2348 }
2349
2350 // Monitor TaskOutput shares the get_command tool shape with subagent
2351 // hydration; do not spawn a phantom subagent for a registered monitor.
2352 const subagentUpdate = hydratedRegisteredMonitor
2353 ? undefined
2354 : flavor.extractSubagentUpdate?.(toolCall);
2355 if (subagentUpdate !== undefined) {
2356 yield* emitSubagent(context, subagentUpdate);
2357 if (subagentUpdate.suppressNormalTool !== false) {
2358 yield* rearmDeferredFinalize(context);
2359 return;
2360 }
2361 }
2362 const status = projectedStatus ?? toolStatus(toolCall.status);
2363 const now = yield* DateTime.now;
2364 const nativeItemId = `${nativeThreadId(driver, context.input.providerThread)}:tool:${toolCall.toolCallId}`;
2365 const ordinal = yield* resolveItemOrdinal(context, nativeItemId);
1151 const nodeId = idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId });2366 const nodeId = idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId });
1152 const turnItemId = idAllocator.derive.turnItemFromProviderItem({2367 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
1153 driver,2368 driver,
@@ -1203,6 +2418,30 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 49 unmodified lines
1203 const rawInput = toolCall.data.rawInput;2418 const rawInput = toolCall.data.rawInput;
1204 const rawOutput = toolCall.data.rawOutput ?? toolCall.data.content;2419 const rawOutput = toolCall.data.rawOutput ?? toolCall.data.content;
1205 const path = pathFromToolCall(toolCall);2420 const path = pathFromToolCall(toolCall);
2421 const rawInputRecord = unknownRecord(rawInput);
2422 const inputVariant =
2423 typeof rawInputRecord?.variant === "string"
2424 ? rawInputRecord.variant.trim().toLowerCase()
2425 : "";
2426 const rawOutputRecord = unknownRecord(rawOutput);
2427 const outputCommand =
2428 typeof rawOutputRecord?.command === "string" &&
2429 rawOutputRecord.command.trim().length > 0
2430 ? rawOutputRecord.command.trim()
2431 : undefined;
2432 const monitorCommand =
2433 (typeof rawInputRecord?.command === "string" && rawInputRecord.command.trim().length > 0
2434 ? rawInputRecord.command.trim()
2435 : undefined) ?? outputCommand;
2436 const outputIsBashResult =
2437 typeof rawOutputRecord?.type === "string" &&
2438 rawOutputRecord.type.trim().toLowerCase() === "bash" &&
2439 commandExitCode(rawOutput) !== undefined;
2440 // Grok Monitor tools arrive as generic kind + variant; project like shell
2441 // so stdout is plain text in the timeline (not JSON {type:Text,text:...}).
2442 // Post-settle wake re-reports of a finished monitor carry no rawInput at
2443 // all, only a structured Bash result; project those as commands too.
2444 const projectAsCommandExecution = inputVariant === "monitor" || outputIsBashResult;
1206 let turnItem: OrchestrationV2TurnItem;2445 let turnItem: OrchestrationV2TurnItem;
1207 switch (toolCall.kind) {2446 switch (toolCall.kind) {
1208 case "read":2447 case "read":
@@ -1225,19 +2464,19 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 16 unmodified lines
1225 }),2464 }),
1226 };2465 };
1227 break;2466 break;
1228 case "execute":2467 case "execute": {
2468 const exitCode = acpProjectedCommandExitCode(status, rawOutput);
1229 turnItem = {2469 turnItem = {
1230 ...base,2470 ...base,
1231 type: "command_execution",2471 type: "command_execution",
1232 input: toolCall.command ?? toolCall.title ?? "Command",2472 input: toolCall.command ?? monitorCommand ?? toolCall.title ?? "Command",
1233 ...(textFromUnknown(rawOutput) === undefined2473 ...(textFromUnknown(rawOutput) === undefined
1234 ? {}2474 ? {}
1235 : { output: textFromUnknown(rawOutput) }),2475 : { output: textFromUnknown(rawOutput) }),
1236 ...(commandExitCode(rawOutput) === undefined2476 ...(exitCode === undefined ? {} : { exitCode }),
1237 ? {}
1238 : { exitCode: commandExitCode(rawOutput) }),
1239 };2477 };
1240 break;2478 break;
2479 }
1241 case "edit":2480 case "edit":
1242 case "delete":2481 case "delete":
1243 case "move":2482 case "move":
@@ -1270,15 +2509,33 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 26 unmodified lines
1270 };2509 };
1271 break;2510 break;
1272 default:2511 default:
1273 turnItem = {2512 if (projectAsCommandExecution) {
1274 ...base,2513 const exitCode = acpProjectedCommandExitCode(status, rawOutput);
1275 type: "dynamic_tool",2514 turnItem = {
1276 toolName: toolCall.title ?? toolCall.kind ?? null,2515 ...base,
1277 input: rawInput ?? {},2516 type: "command_execution",
1278 ...(rawOutput === undefined ? {} : { output: rawOutput }),2517 input:
1279 };2518 toolCall.command ??
2519 monitorCommand ??
2520 toolCall.title ??
2521 (inputVariant === "monitor" ? "Monitor" : "Command"),
2522 ...(textFromUnknown(rawOutput) === undefined
2523 ? {}
2524 : { output: textFromUnknown(rawOutput) }),
2525 ...(exitCode === undefined ? {} : { exitCode }),
2526 };
2527 } else {
2528 turnItem = {
2529 ...base,
2530 type: "dynamic_tool",
2531 toolName: toolCall.title ?? toolCall.kind ?? null,
2532 input: rawInput ?? {},
2533 ...(rawOutput === undefined ? {} : { output: rawOutput }),
2534 };
2535 }
1280 }2536 }
1281 yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem });2537 yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem });
2538 yield* rearmDeferredFinalize(context);
1282 });2539 });
1283 2540
1284 const emitPlan = Effect.fnUntraced(function* (2541 const emitPlan = Effect.fnUntraced(function* (
@@ -1417,22 +2674,287 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 132 unmodified lines
1417 });2674 });
1418 });2675 });
1419 2676
2677 const offerContinuationRun = Effect.fnUntraced(function* (sessionId: string) {
2678 if (continuationRequests === undefined) {
2679 return;
2680 }
2681 const pending = yield* continuationPermit.withPermit(
2682 Effect.gen(function* () {
2683 if (yield* Ref.get(continuationClosed)) return Option.none();
2684 if (yield* Ref.get(stoppedRunQuarantine)) return Option.none();
2685 if (yield* Ref.get(continuationRequested)) return Option.none();
2686 const route = yield* Ref.get(lastTurnRoute);
2687 if (route === null) return Option.none();
2688 yield* Ref.set(continuationRequested, true);
2689 const generation = yield* Ref.updateAndGet(
2690 continuationGeneration,
2691 (value) => value + 1,
2692 );
2693 return Option.some({ route, generation });
2694 }),
2695 );
2696 if (Option.isNone(pending)) return;
2697 const { route, generation } = pending.value;
2698 yield* Effect.logInfo("orchestration-v2.acp-wake-turn-detected", {
2699 driver,
2700 providerSessionId: input.providerSessionId,
2701 threadId: route.threadId,
2702 providerThreadId: route.providerThreadId,
2703 });
2704 yield* continuationRequests.offer({
2705 threadId: route.threadId,
2706 providerThreadId: route.providerThreadId,
2707 driver,
2708 detail: null,
2709 dispatchIfCurrent: (effect) =>
2710 continuationPermit.withPermit(
2711 Effect.gen(function* () {
2712 const clearIfOwner = Effect.gen(function* () {
2713 if ((yield* Ref.get(continuationGeneration)) === generation) {
2714 yield* Ref.set(continuationRequested, false);
2715 }
2716 });
2717 if (yield* Ref.get(stoppedRunQuarantine)) {
2718 yield* clearIfOwner;
2719 return Option.none();
2720 }
2721 if ((yield* Ref.get(continuationGeneration)) !== generation) {
2722 // Superseded by a newer offer; do not clear its flag.
2723 return Option.none();
2724 }
2725 if (!(yield* Ref.get(continuationRequested))) return Option.none();
2726 // Clear after success or failure so a dropped dispatch does not
2727 // stick the flag and pin hasPendingBackgroundWork forever.
2728 const exit = yield* Effect.exit(effect);
2729 yield* clearIfOwner;
2730 if (Exit.isFailure(exit)) {
2731 return yield* Effect.failCause(exit.cause);
2732 }
2733 return Option.some(exit.value);
2734 }),
2735 ),
2736 });
2737 });
2738
2739 const applyLateBackgroundMutation = Effect.fnUntraced(function* (
2740 sessionId: string,
2741 mutation: {
2742 readonly taskId: string;
2743 readonly status: "running" | "completed" | "failed";
2744 },
2745 ) {
2746 const taskAlreadyEnded = (yield* Ref.get(endedBackgroundTaskIds)).has(mutation.taskId);
2747 yield* applyBackgroundTaskMutationRunning(mutation);
2748 if (taskAlreadyEnded && acpPostSettleMonitorPromptShouldSuppress(mutation)) {
2749 yield* Ref.set(suppressPostSettleMonitorPrompt, true);
2750 }
2751 if (mutation.status !== "running") {
2752 yield* Ref.set(suppressPostSettleMonitorPrompt, false);
2753 yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => {
2754 if (!current.has(mutation.taskId)) return current;
2755 const next = new Set(current);
2756 next.delete(mutation.taskId);
2757 return next;
2758 });
2759 if (
2760 postSettleContinuationEnabled &&
2761 (yield* Ref.get(activeSessionId)) === sessionId &&
2762 (yield* Ref.get(runningBackgroundTaskIds)).size === 0 &&
2763 (yield* Ref.get(wakeBuffer)).length > 0
2764 ) {
2765 yield* offerContinuationRun(sessionId);
2766 }
2767 }
2768 });
2769
2770 const bufferPostSettleWake = Effect.fnUntraced(function* (
2771 notification: EffectAcpSchema.SessionNotification,
2772 ) {
2773 if (!postSettleContinuationEnabled || continuationRequests === undefined) {
2774 return false;
2775 }
2776 // Direct Stop quarantine: drop residual wake evidence instead of
2777 // buffering it for a later continuation or follow-up run.
2778 if (yield* Ref.get(stoppedRunQuarantine)) {
2779 return true;
2780 }
2781 const rootSessionId = yield* Ref.get(activeSessionId);
2782 if (rootSessionId === null || notification.sessionId !== rootSessionId) {
2783 return false;
2784 }
2785 if (!acpPostSettleWakeEvidence(notification, flavor)) {
2786 return false;
2787 }
2788 const update = notification.update;
2789 let alreadyHandledToolUpdate = false;
2790 if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
2791 yield* trackRunningBackgroundTools(notification);
2792 // When the agent consumes the monitor output itself via
2793 // get_command_or_subagent_output (long timeout), the TaskOutput
2794 // completion is the ONLY end signal: no "Monitor ended" reminder
2795 // follows. Without applying it here the running set never clears,
2796 // every offer stays suppressed, and the wake buffer never drains
2797 // (observed live 2026-07-12, thread 8dbe607f). The completion
2798 // frame itself is offer evidence, so clearing the id before the
2799 // gate below lets it open the single continuation run.
2800 for (const event of parseSessionUpdateEvent(notification).events) {
2801 if (event._tag !== "ToolCallUpdated") continue;
2802 const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall;
2803 const toolTaskId = flavor.extractBackgroundTaskId?.(toolCall);
2804 if (
2805 toolTaskId !== undefined &&
2806 (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(toolTaskId)
2807 ) {
2808 alreadyHandledToolUpdate = true;
2809 }
2810 if (flavor.extractBackgroundTaskCompletion !== undefined) {
2811 for (const completion of flavor.extractBackgroundTaskCompletion(toolCall)) {
2812 alreadyHandledToolUpdate =
2813 alreadyHandledToolUpdate ||
2814 (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(completion.taskId);
2815 yield* applyBackgroundTaskMutationRunning(completion);
2816 }
2817 }
2818 }
2819 }
2820 const backgroundWorkRunning = (yield* Ref.get(runningBackgroundTaskIds)).size > 0;
2821 // Grok prompts itself for every monitor event after the root turn
2822 // settles. Its assistant/reasoning replies are progress chatter, not
2823 // separate wake results. Retaining them would replay the entire burst
2824 // into the single continuation once the monitor finishes. Keep tool
2825 // state so the final command card still hydrates, then begin retaining
2826 // agent output again after the genuine end signal clears the running
2827 // set. With multiple tasks, text remains best-effort until every task
2828 // ends; their retained tool cards are the authoritative results.
2829 //
2830 // Skip retaining frames for tasks already hydrated in the root turn
2831 // (`handledBackgroundTaskIdsInActiveTurn`). Those re-reports must not
2832 // pin `hasPendingBackgroundWork` via a wake buffer that never drains
2833 // (the already-handled gate below intentionally skips
2834 // `offerContinuationRun` to avoid synthetic "Background task
2835 // completed." spam).
2836 if (
2837 !alreadyHandledToolUpdate &&
2838 acpPostSettleWakeShouldBuffer(notification, backgroundWorkRunning)
2839 ) {
2840 yield* Ref.update(wakeBuffer, (current) => [...current, notification]);
2841 }
2842 // Buffer progress without offering; only completion-like frames open a run.
2843 if (!acpPostSettleContinuationOfferEvidence(notification, flavor)) {
2844 return true;
2845 }
2846 // While a monitor is still streaming, tool re-reports buffer without
2847 // offering and per-event agent commentary is consumed without being
2848 // retained. Grok re-reports a running monitor as Bash frames that
2849 // already carry exit_code 0 mid-stream, so a "terminal" normalized
2850 // status is not evidence the task ended; each burst would otherwise
2851 // reopen a synthetic "Background task completed." run. Retained tool
2852 // frames drain into the single continuation offered once the monitor
2853 // actually ends (end-notice mutation below, or the first frame after
2854 // it).
2855 if (backgroundWorkRunning) {
2856 return true;
2857 }
2858 if (alreadyHandledToolUpdate) {
2859 // Drop leftover wake noise for in-turn-handled work so idle release
2860 // is not pinned forever. Leave the buffer alone when a continuation
2861 // is already outstanding: its startTurn will drain legitimate frames
2862 // from other tasks.
2863 if (!(yield* Ref.get(continuationRequested))) {
2864 yield* Ref.set(wakeBuffer, []);
2865 }
2866 return true;
2867 }
2868 // Residual Grok agent/thought chatter after in-turn-handled background
2869 // work must not open synthetic continuation runs. Tool-path
2870 // alreadyHandled covers re-reports with a task id; this covers
2871 // agent_message_chunk frames that carry no task id (live:
2872 // grok-in-turn-monitor-no-wake). Running work already returned above.
2873 // Do not clear wakeBuffer here: frames for other still-tracked tasks
2874 // must remain drainable when a real (tool) completion later offers.
2875 const handledInTurnCount = (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size;
2876 if (
2877 handledInTurnCount > 0 &&
2878 (update.sessionUpdate === "agent_message_chunk" ||
2879 update.sessionUpdate === "agent_thought_chunk")
2880 ) {
2881 return true;
2882 }
2883 yield* offerContinuationRun(notification.sessionId);
2884 return true;
2885 });
2886
1420 const handleSessionUpdate = Effect.fnUntraced(function* (2887 const handleSessionUpdate = Effect.fnUntraced(function* (
1421 notification: EffectAcpSchema.SessionNotification,2888 notification: EffectAcpSchema.SessionNotification,
1422 ) {2889 ) {
1423 const context = yield* Ref.get(activeTurn);2890 const context = yield* Ref.get(activeTurn);
1424 const update = notification.update;2891 const update = notification.update;
2892 // Only while a finalized turn is still the active context. When
2893 // activeTurn is null, post-settle agent frames must reach
2894 // bufferPostSettleWake so continuation can attach (context?.finalized
2895 // !== false incorrectly treated null as finalized and dropped them).
2896 if (
2897 context !== null &&
2898 context.finalized &&
2899 (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size > 0 &&
2900 (update.sessionUpdate === "agent_message_chunk" ||
2901 update.sessionUpdate === "agent_thought_chunk")
2902 ) {
2903 yield* Ref.set(suppressPostSettleMonitorPrompt, true);
2904 return;
2905 }
2906 if (
2907 context !== null &&
2908 (yield* Ref.get(suppressPostSettleMonitorPrompt)) &&
2909 (update.sessionUpdate === "agent_message_chunk" ||
2910 update.sessionUpdate === "agent_thought_chunk")
2911 ) {
2912 return;
2913 }
2914 if (
2915 context?.finalized === true &&
2916 update.sessionUpdate === "user_message_chunk" &&
2917 update.content.type === "text"
2918 ) {
2919 const mutations = flavor.extractBackgroundToolMutation?.(update.content.text) ?? [];
2920 for (const mutation of mutations) {
2921 yield* applyLateBackgroundMutation(notification.sessionId, mutation);
2922 }
2923 return;
2924 }
1425 if (context === null) {2925 if (context === null) {
2926 // Direct Stop: quarantine residual events from the stopped run so
2927 // they cannot become history, wake buffers, or a later run attach.
2928 if (yield* Ref.get(stoppedRunQuarantine)) {
2929 return;
2930 }
2931 // Prefer continuation buffering over history append so the same
2932 // frames are not double-counted once a continuation run attaches.
2933 if (yield* bufferPostSettleWake(notification)) {
2934 return;
2935 }
1426 if (2936 if (
1427 (update.sessionUpdate === "user_message_chunk" ||2937 (update.sessionUpdate === "user_message_chunk" ||
1428 update.sessionUpdate === "agent_message_chunk") &&2938 update.sessionUpdate === "agent_message_chunk") &&
1429 update.content.type === "text"2939 update.content.type === "text"
1430 ) {2940 ) {
1431 yield* appendLoadedHistory(2941 // Late monitor end/event reminders must not become ghost user/assistant
1432 notification,2942 // history (or OS-facing chatter) after the root turn already finalized.
1433 update.sessionUpdate === "user_message_chunk" ? "user" : "assistant",2943 const text = update.content.text;
1434 update.content.text,2944 const lateBackgroundMutations = flavor.extractBackgroundToolMutation?.(text) ?? [];
1435 );2945 for (const lateBackgroundMutation of lateBackgroundMutations) {
2946 yield* applyLateBackgroundMutation(notification.sessionId, lateBackgroundMutation);
2947 }
2948 const lateMonitorChatter =
2949 /<monitor-event\b/i.test(text) ||
2950 /Monitor\s+["']?[0-9a-f-]{8,}["']?\s+ended/i.test(text);
2951 if (lateBackgroundMutations.length === 0 && !lateMonitorChatter) {
2952 yield* appendLoadedHistory(
2953 notification,
2954 update.sessionUpdate === "user_message_chunk" ? "user" : "assistant",
2955 text,
2956 );
2957 }
1436 } else if (2958 } else if (
1437 update.sessionUpdate === "tool_call" ||2959 update.sessionUpdate === "tool_call" ||
1438 update.sessionUpdate === "tool_call_update" ||2960 update.sessionUpdate === "tool_call_update" ||
@@ -1444,6 +2966,8 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines
1444 }2966 }
1445 if (context.finalized) return;2967 if (context.finalized) return;
1446 if (notification.sessionId !== (yield* Ref.get(activeSessionId))) {2968 if (notification.sessionId !== (yield* Ref.get(activeSessionId))) {
2969 // Finalize may have completed during the activeSessionId yield.
2970 if (context.finalized) return;
1447 if (flavor.extractSubagentUpdate === undefined) return;2971 if (flavor.extractSubagentUpdate === undefined) return;
1448 if (update.sessionUpdate !== "agent_message_chunk" || update.content.type !== "text") {2972 if (update.sessionUpdate !== "agent_message_chunk" || update.content.type !== "text") {
1449 return;2973 return;
@@ -1458,17 +2982,90 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 8 unmodified lines
1458 context.pendingSubagentNotifications.set(notification.sessionId, buffered);2982 context.pendingSubagentNotifications.set(notification.sessionId, buffered);
1459 return;2983 return;
1460 }2984 }
2985 // Re-check after the activeSessionId yield: idle/prompt settle can
2986 // finalize the same context object while we waited.
2987 if (context.finalized) return;
1461 switch (update.sessionUpdate) {2988 switch (update.sessionUpdate) {
1462 case "agent_message_chunk":2989 case "agent_message_chunk":
1463 if (update.content.type === "text") {2990 if (update.content.type === "text") {
2991 // The injected-turn report is streaming; the normal debounce
2992 // after the last chunk takes over from here.
2993 if (context.pendingInjectedReport.size > 0) {
2994 context.pendingInjectedReport.clear();
2995 }
1464 yield* appendText(context, "assistant", update.content.text);2996 yield* appendText(context, "assistant", update.content.text);
1465 }2997 }
1466 return;2998 break;
1467 case "agent_thought_chunk":2999 case "agent_thought_chunk":
1468 if (update.content.type === "text") {3000 if (update.content.type === "text") {
1469 yield* appendText(context, "reasoning", update.content.text);3001 yield* appendText(context, "reasoning", update.content.text);
1470 }3002 }
1471 return;3003 break;
3004 case "user_message_chunk":
3005 if (update.content.type === "text" && flavor.extractBackgroundToolMutation) {
3006 for (const mutation of flavor.extractBackgroundToolMutation(update.content.text)) {
3007 const toolCallId = context.toolCallIdsByBackgroundTaskId.get(mutation.taskId);
3008 const previous =
3009 toolCallId !== undefined ? context.tools.get(toolCallId) : undefined;
3010 if (previous !== undefined) {
3011 let nextStatus =
3012 mutation.status === "running"
3013 ? ("inProgress" as const)
3014 : mutation.status === "failed"
3015 ? ("failed" as const)
3016 : ("completed" as const);
3017 // End notices typically omit full stdout (no get_command mention
3018 // either). Keep the tool running and hold finalize until
3019 // TaskOutput hydrates, or the safety timer force-completes.
3020 if (nextStatus !== "inProgress") {
3021 yield* markAwaitingBackgroundHydration(context, mutation.taskId);
3022 nextStatus = "inProgress";
3023 }
3024 yield* emitTool(
3025 context,
3026 appendToolOutputText(
3027 { ...previous, status: nextStatus },
3028 mutation.appendOutput,
3029 ),
3030 );
3031 }
3032 // After emitTool: the hydration hold keeps the tool row at
3033 // inProgress past an end notice, but the offer gate must see
3034 // the mutation's semantic status. Also tracks monitors that
3035 // never surfaced a tool_call row at all.
3036 yield* applyBackgroundTaskMutationRunning(mutation);
3037 if (mutation.status !== "running") {
3038 yield* markPendingInjectedReport(context, mutation.taskId);
3039 }
3040 }
3041 }
3042 if (update.content.type === "text" && flavor.extractSubagentEndNotice) {
3043 const notice = flavor.extractSubagentEndNotice(update.content.text);
3044 const subagent =
3045 notice !== undefined
3046 ? context.subagentsBySessionId.get(notice.childSessionId)
3047 : undefined;
3048 if (
3049 notice !== undefined &&
3050 subagent !== undefined &&
3051 (subagent.task.status === "running" || subagent.task.status === "pending")
3052 ) {
3053 // The agent is free to answer with text only and never call
3054 // get_command_or_subagent_output; without this, the subagent
3055 // row stays running and holds deferred finalize open forever.
3056 yield* emitSubagent(context, {
3057 nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? notice.childSessionId,
3058 prompt: subagent.task.prompt,
3059 title: subagent.task.title,
3060 model: subagent.task.model,
3061 status: notice.status,
3062 childSessionId: notice.childSessionId,
3063 result: null,
3064 suppressNormalTool: true,
3065 });
3066 }
3067 }
3068 break;
1472 default: {3069 default: {
1473 const parsed = parseSessionUpdateEvent(notification);3070 const parsed = parseSessionUpdateEvent(notification);
1474 for (const event of parsed.events) {3071 for (const event of parsed.events) {
@@ -1480,20 +3077,13 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines
1480 }3077 }
1481 }3078 }
1482 }3079 }
3080 if (acpRootSessionUpdateIngestsOutput(notification)) {
3081 yield* scheduleSettleRootTurnWhenIdle(context);
3082 }
3083 // Keep deferred finalize quiet-window fresh while wake traffic lands.
3084 yield* rearmDeferredFinalize(context);
1483 });3085 });
1484 3086
1485 yield* runtime.handleSessionUpdate((notification) =>
1486 handleSessionUpdate(notification).pipe(
1487 Effect.mapError(
1488 (cause) =>
1489 new EffectAcpErrors.AcpTransportError({
1490 detail: "Failed to project an ACP session update",
1491 cause,
1492 }),
1493 ),
1494 ),
1495 );
1496
1497 const activeContext = Effect.gen(function* () {3087 const activeContext = Effect.gen(function* () {
1498 const context = yield* Ref.get(activeTurn);3088 const context = yield* Ref.get(activeTurn);
1499 if (context === null) {3089 if (context === null) {
@@ -1505,9 +3095,11 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines
1505 return context;3095 return context;
1506 });3096 });
1507 3097
1508 const emitApprovalRequest = Effect.fnUntraced(function* (3098 const beginApprovalRequest = Effect.fnUntraced(function* (
1509 context: ActiveAcpTurn,3099 context: ActiveAcpTurn,
1510 params: EffectAcpSchema.RequestPermissionRequest,3100 params: EffectAcpSchema.RequestPermissionRequest,
3101 generation: number,
3102 transportRequestId: string,
1511 ) {3103 ) {
1512 yield* closeTextStreams(context);3104 yield* closeTextStreams(context);
1513 const parsed = parsePermissionRequest(params);3105 const parsed = parsePermissionRequest(params);
@@ -1518,6 +3110,15 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 4 unmodified lines
1518 nativeRequestId,3110 nativeRequestId,
1519 });3111 });
1520 const decision = yield* Deferred.make<ProviderApprovalDecision>();3112 const decision = yield* Deferred.make<ProviderApprovalDecision>();
3113 const nativeResponseAcknowledgement = yield* Deferred.make<
3114 void,
3115 EffectAcpErrors.AcpError
3116 >();
3117 yield* registerNativeResponseAcknowledgement(
3118 generation,
3119 transportRequestId,
3120 nativeResponseAcknowledgement,
3121 );
1521 const now = yield* DateTime.now;3122 const now = yield* DateTime.now;
1522 const nodeId = idAllocator.derive.approvalNode({ requestId });3123 const nodeId = idAllocator.derive.approvalNode({ requestId });
1523 const requestKind = providerRequestKind(parsed.kind);3124 const requestKind = providerRequestKind(parsed.kind);
@@ -1581,7 +3182,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 57 unmodified lines
1581 const updated = new Map(current);3182 const updated = new Map(current);
1582 updated.set(String(requestId), {3183 updated.set(String(requestId), {
1583 type: "approval",3184 type: "approval",
3185 generation,
3186 nativeResponseAcknowledgement,
1584 requestId,3187 requestId,
3188 transportRequestId,
1585 decision,3189 decision,
1586 runtimeRequest,3190 runtimeRequest,
1587 node,3191 node,
@@ -1605,55 +3209,19 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 17 unmodified lines
1605 driver,3209 driver,
1606 turnItem,3210 turnItem,
1607 });3211 });
1608 const resolved = yield* Deferred.await(decision).pipe(3212 return {
1609 Effect.ensuring(3213 context,
1610 Ref.update(pendingRuntimeRequests, (current) => {3214 decision,
1611 const updated = new Map(current);3215 nativeResponseAcknowledgement,
1612 updated.delete(String(requestId));3216 requestId,
1613 return updated;3217 transportRequestId,
1614 }),3218 } as const;
1615 ),
1616 );
1617 return resolved;
1618 });3219 });
1619 3220
1620 yield* runtime.handleRequestPermission((params) =>3221 const beginUserInputRequest = Effect.fnUntraced(function* (
1621 Effect.gen(function* () {
1622 const context = yield* activeContext;
1623 const disposition = acpPermissionDisposition(context.input.runtimePolicy, params);
1624 if (disposition === "allow") {
1625 const optionId = selectAutoApprovedPermissionOption(params);
1626 return optionId === undefined
1627 ? ({ outcome: { outcome: "cancelled" } } as const)
1628 : ({ outcome: { outcome: "selected", optionId } } as const);
1629 }
1630 if (disposition === "deny") {
1631 const optionId = selectPermissionOptionId(params, "decline");
1632 return optionId === undefined
1633 ? ({ outcome: { outcome: "cancelled" } } as const)
1634 : ({ outcome: { outcome: "selected", optionId } } as const);
1635 }
1636 const decision = yield* emitApprovalRequest(context, params);
1637 if (decision === "cancel") {
1638 return { outcome: { outcome: "cancelled" } } as const;
1639 }
1640 const optionId = selectPermissionOptionId(params, decision);
1641 return optionId === undefined
1642 ? ({ outcome: { outcome: "cancelled" } } as const)
1643 : ({ outcome: { outcome: "selected", optionId } } as const);
1644 }).pipe(
1645 Effect.mapError(
1646 (cause) =>
1647 new EffectAcpErrors.AcpTransportError({
1648 detail: "Failed to handle an ACP permission request",
1649 cause,
1650 }),
1651 ),
1652 ),
1653 );
1654
1655 const requestUserInputInternal = Effect.fnUntraced(function* (
1656 request: AcpAdapterV2UserInputRequest,3222 request: AcpAdapterV2UserInputRequest,
3223 generation: number,
3224 transportRequestId: string,
1657 ) {3225 ) {
1658 const context = yield* activeContext;3226 const context = yield* activeContext;
1659 yield* closeTextStreams(context);3227 yield* closeTextStreams(context);
@@ -1663,6 +3231,15 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines
1663 nativeRequestId: request.nativeRequestId,3231 nativeRequestId: request.nativeRequestId,
1664 });3232 });
1665 const answers = yield* Deferred.make<ProviderUserInputAnswers | null>();3233 const answers = yield* Deferred.make<ProviderUserInputAnswers | null>();
3234 const nativeResponseAcknowledgement = yield* Deferred.make<
3235 void,
3236 EffectAcpErrors.AcpError
3237 >();
3238 yield* registerNativeResponseAcknowledgement(
3239 generation,
3240 transportRequestId,
3241 nativeResponseAcknowledgement,
3242 );
1666 const now = yield* DateTime.now;3243 const now = yield* DateTime.now;
1667 const nodeId = idAllocator.derive.nodeFromProviderItem({3244 const nodeId = idAllocator.derive.nodeFromProviderItem({
1668 driver,3245 driver,
@@ -1736,7 +3313,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 67 unmodified lines
1736 const updated = new Map(current);3313 const updated = new Map(current);
1737 updated.set(String(requestId), {3314 updated.set(String(requestId), {
1738 type: "user_input",3315 type: "user_input",
3316 generation,
3317 nativeResponseAcknowledgement,
1739 requestId,3318 requestId,
3319 transportRequestId,
1740 answers,3320 answers,
1741 runtimeRequest,3321 runtimeRequest,
1742 node,3322 node,
@@ -1760,19 +3340,62 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 17 unmodified lines
1760 driver,3340 driver,
1761 turnItem,3341 turnItem,
1762 });3342 });
1763 return yield* Deferred.await(answers).pipe(3343 return {
1764 Effect.ensuring(3344 answers,
1765 Ref.update(pendingRuntimeRequests, (current) => {3345 context,
1766 const updated = new Map(current);3346 nativeResponseAcknowledgement,
1767 updated.delete(String(requestId));3347 requestId,
1768 return updated;3348 transportRequestId,
1769 }),3349 } as const;
1770 ),
1771 );
1772 });3350 });
1773 3351
1774 const requestUserInput = (request: AcpAdapterV2UserInputRequest) =>3352 const requestUserInputWithAdmission = (
1775 requestUserInputInternal(request).pipe(3353 generation: number,
3354 request: Effect.Effect<AcpAdapterV2UserInputRequest>,
3355 transportRequestId: string,
3356 ) =>
3357 runRuntimeCallbackAtGeneration(
3358 generation,
3359 request.pipe(
3360 Effect.flatMap((value) =>
3361 beginUserInputRequest(value, generation, transportRequestId),
3362 ),
3363 ),
3364 ).pipe(
3365 Effect.flatMap((pending) => {
3366 if (Option.isNone(pending)) return Effect.never;
3367 const { answers, context, requestId, transportRequestId } = pending.value;
3368 return Deferred.await(answers).pipe(
3369 Effect.flatMap((result) =>
3370 runRuntimeCallbackAtGeneration(generation, Effect.succeed(result)).pipe(
3371 Effect.flatMap((checked) =>
3372 Option.isSome(checked)
3373 ? Effect.succeed({
3374 acknowledgeNativeResponse: acknowledgeNativeResponse(
3375 generation,
3376 transportRequestId,
3377 ),
3378 answers: checked.value,
3379 })
3380 : Effect.never,
3381 ),
3382 ),
3383 ),
3384 Effect.ensuring(
3385 runRuntimeCallbackAtGeneration(
3386 generation,
3387 Effect.gen(function* () {
3388 yield* Ref.update(pendingRuntimeRequests, (current) => {
3389 const updated = new Map(current);
3390 updated.delete(String(requestId));
3391 return updated;
3392 });
3393 yield* rearmRootTurnRecoveryTimers(context);
3394 }),
3395 ).pipe(Effect.asVoid),
3396 ),
3397 );
3398 }),
1776 Effect.mapError(3399 Effect.mapError(
1777 (cause) =>3400 (cause) =>
1778 new EffectAcpErrors.AcpTransportError({3401 new EffectAcpErrors.AcpTransportError({
@@ -1783,7 +3406,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 4 unmodified lines
1783 );3406 );
1784 3407
1785 const cancelPendingRuntimeRequests = Effect.fnUntraced(function* () {3408 const cancelPendingRuntimeRequests = Effect.fnUntraced(function* () {
1786 const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()];3409 const requests = yield* Ref.modify(pendingRuntimeRequests, (current) => [
3410 [...current.values()],
3411 new Map<string, PendingRuntimeRequest>(),
3412 ]);
1787 if (requests.length === 0) return;3413 if (requests.length === 0) return;
1788 3414
1789 const now = yield* DateTime.now;3415 const now = yield* DateTime.now;
@@ -1830,61 +3456,423 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 40 unmodified lines
1830 );3456 );
1831 });3457 });
1832 3458
1833 yield* runtime.handleElicitation((params) =>3459 /**
1834 Effect.gen(function* () {3460 * Direct Stop after a soft steer clears carryover without an active turn.
1835 if (params.mode === "url") {3461 * Emit the same interrupted terminal events terminalizeOpenRunOwnedItems
1836 return { action: { action: "decline" } } as const;3462 * would have, context-free (no ActiveAcpTurn).
3463 */
3464 const terminalizeCarryoverSubagents = Effect.fnUntraced(function* (
3465 carryover: {
3466 readonly sessionId: string;
3467 readonly subagents: ReadonlyArray<ActiveAcpSubagent>;
3468 } | null,
3469 ) {
3470 if (carryover === null) return;
3471 const now = yield* DateTime.now;
3472 for (const subagent of carryover.subagents) {
3473 if (subagent.task.status !== "running" && subagent.task.status !== "pending") {
3474 continue;
1837 }3475 }
1838 const questions = Object.entries(params.requestedSchema.properties ?? {}).map(3476 const nativeTaskId = subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id;
1839 ([id, property], index): OrchestrationV2UserInputQuestion => {3477 const nativeItemRef = {
1840 const record = unknownRecord(property);3478 driver,
1841 const enumValues = Array.isArray(record?.enum)3479 nativeId: nativeTaskId,
1842 ? record.enum.filter((value): value is string => typeof value === "string")3480 strength: "strong" as const,
1843 : [];3481 };
1844 const options =3482 const parentProviderThreadId = subagent.parentProviderThreadId;
1845 enumValues.length > 03483 const result = subagent.assistantText || subagent.task.result;
1846 ? enumValues.map((value) => ({ label: value, description: value }))3484 subagent.task = {
1847 : record?.type === "boolean"3485 ...subagent.task,
1848 ? [3486 status: "interrupted",
1849 { label: "true", description: "Yes" },3487 result,
1850 { label: "false", description: "No" },3488 completedAt: now,
1851 ]3489 updatedAt: now,
1852 : [];3490 };
1853 return {3491 yield* emitProviderEvent({
1854 id,3492 type: "node.updated",
1855 header: nonEmptyText(record?.title, `Question ${index + 1}`),3493 driver,
1856 question: nonEmptyText(record?.description, params.message),3494 node: {
1857 options,3495 id: subagent.task.id,
1858 };3496 threadId: subagent.task.threadId,
3497 runId: subagent.task.runId,
3498 parentNodeId: subagent.task.parentNodeId,
3499 rootNodeId: subagent.task.parentNodeId,
3500 kind: "subagent",
3501 status: "interrupted",
3502 countsForRun: false,
3503 providerThreadId: parentProviderThreadId,
3504 providerTurnId: subagent.providerTurnId,
3505 nativeItemRef,
3506 runtimeRequestId: null,
3507 checkpointScopeId: null,
3508 startedAt: subagent.task.startedAt,
3509 completedAt: now,
3510 },
3511 });
3512 yield* emitProviderEvent({
3513 type: "node.updated",
3514 driver,
3515 node: {
3516 id: subagent.childRootNodeId,
3517 threadId: subagent.childThreadId,
3518 runId: null,
3519 parentNodeId: null,
3520 rootNodeId: subagent.childRootNodeId,
3521 kind: "root_turn",
3522 status: "interrupted",
3523 countsForRun: false,
3524 providerThreadId: subagent.task.providerThreadId,
3525 providerTurnId: null,
3526 nativeItemRef,
3527 runtimeRequestId: null,
3528 checkpointScopeId: null,
3529 startedAt: subagent.task.startedAt,
3530 completedAt: now,
3531 },
3532 });
3533 yield* emitProviderEvent({
3534 type: "subagent.updated",
3535 driver,
3536 subagent: subagent.task,
3537 });
3538 yield* emitProviderEvent({
3539 type: "turn_item.updated",
3540 driver,
3541 turnItem: {
3542 id: subagent.turnItemId,
3543 threadId: subagent.task.threadId,
3544 runId: subagent.task.runId,
3545 nodeId: subagent.task.id,
3546 providerThreadId: parentProviderThreadId,
3547 providerTurnId: subagent.providerTurnId,
3548 nativeItemRef,
3549 parentItemId: null,
3550 ordinal: subagent.turnItemOrdinal,
3551 status: "interrupted",
3552 title: subagent.task.title,
3553 startedAt: subagent.task.startedAt,
3554 completedAt: now,
3555 updatedAt: now,
3556 type: "subagent",
3557 subagentId: subagent.task.id,
3558 origin: "provider_native",
3559 driver,
3560 providerInstanceId: subagent.task.providerInstanceId,
3561 childThreadId: subagent.childThreadId,
3562 prompt: subagent.task.prompt,
3563 result,
1859 },3564 },
1860 );
1861 const ordinal = yield* Ref.getAndUpdate(
1862 nextElicitationOrdinal,
1863 (current) => current + 1,
1864 );
1865 const nativeRequestId = `${params.sessionId}:elicitation:${ordinal}`;
1866 const answers = yield* requestUserInput({
1867 nativeItemId: nativeRequestId,
1868 nativeRequestId,
1869 questions,
1870 });3565 });
1871 return answers === null3566 }
1872 ? ({ action: { action: "cancel" } } as const)3567 });
1873 : ({3568
1874 action: {3569 const wireAcpRuntimeHandlers = Effect.fnUntraced(function* () {
1875 action: "accept",3570 const handlerGeneration = yield* Ref.get(runtimeCallbackGeneration);
1876 content: elicitationContent(3571 const requestUserInput = (request: AcpAdapterV2UserInputRequest) =>
1877 answers,3572 Effect.gen(function* () {
1878 new Set(Object.keys(params.requestedSchema.properties ?? {})),3573 const transportRequestId = yield* claimNativeTransportRequest(
3574 handlerGeneration,
3575 (transport) => acpNativeUserInputRequestMatches(request, transport),
3576 );
3577 const correlated = yield* runRuntimeCallbackAtGeneration(
3578 handlerGeneration,
3579 transportRequestId === undefined
3580 ? new EffectAcpErrors.AcpTransportError({
3581 detail:
3582 "Could not correlate the ACP user input request with its transport ID",
3583 cause: "Could not correlate xAI user input transport request",
3584 })
3585 : Effect.succeed(transportRequestId),
3586 );
3587 if (Option.isNone(correlated)) return yield* Effect.never;
3588 return yield* requestUserInputWithAdmission(
3589 handlerGeneration,
3590 Effect.succeed(request),
3591 correlated.value,
3592 );
3593 });
3594 yield* runtime.handleSessionUpdate((notification) =>
3595 runRuntimeCallbackAtGeneration(
3596 handlerGeneration,
3597 handleSessionUpdate(notification),
3598 ).pipe(
3599 Effect.asVoid,
3600 Effect.mapError(
3601 (cause) =>
3602 new EffectAcpErrors.AcpTransportError({
3603 detail: "Failed to project an ACP session update",
3604 cause,
3605 }),
3606 ),
3607 ),
3608 );
3609 yield* runtime.handleRequestPermission((params) =>
3610 Effect.gen(function* () {
3611 const transportRequestId = yield* claimNativeTransportRequest(
3612 handlerGeneration,
3613 ({ method, payload }) =>
3614 method === "session/request_permission" &&
3615 unknownRecord(payload)?.sessionId === params.sessionId &&
3616 unknownRecord(unknownRecord(payload)?.toolCall)?.toolCallId ===
3617 params.toolCall.toolCallId,
3618 );
3619 const correlated = yield* runRuntimeCallbackAtGeneration(
3620 handlerGeneration,
3621 transportRequestId === undefined
3622 ? new EffectAcpErrors.AcpTransportError({
3623 detail:
3624 "Could not correlate the ACP permission request with its transport ID",
3625 cause: "Could not correlate session/request_permission transport request",
3626 })
3627 : Effect.succeed(transportRequestId),
3628 );
3629 if (Option.isNone(correlated)) return yield* Effect.never;
3630 const correlatedTransportRequestId = correlated.value;
3631 const admitted = yield* runRuntimeCallbackAtGeneration(
3632 handlerGeneration,
3633 Effect.gen(function* () {
3634 const context = yield* activeContext;
3635 const disposition = acpPermissionDisposition(context.input.runtimePolicy, params);
3636 if (disposition === "allow") {
3637 const optionId = selectAutoApprovedPermissionOption(params);
3638 return {
3639 _tag: "Immediate" as const,
3640 response:
3641 optionId === undefined
3642 ? ({ outcome: { outcome: "cancelled" } } as const)
3643 : ({ outcome: { outcome: "selected", optionId } } as const),
3644 };
3645 }
3646 if (disposition === "deny") {
3647 const optionId = selectPermissionOptionId(params, "decline");
3648 return {
3649 _tag: "Immediate" as const,
3650 response:
3651 optionId === undefined
3652 ? ({ outcome: { outcome: "cancelled" } } as const)
3653 : ({ outcome: { outcome: "selected", optionId } } as const),
3654 };
3655 }
3656 return {
3657 _tag: "Pending" as const,
3658 pending: yield* beginApprovalRequest(
3659 context,
3660 params,
3661 handlerGeneration,
3662 correlatedTransportRequestId,
1879 ),3663 ),
1880 },3664 };
1881 } as const);3665 }),
1882 }),3666 );
1883 );3667 if (Option.isNone(admitted)) {
3668 return yield* Effect.never;
3669 }
3670 if (admitted.value._tag === "Immediate") {
3671 const response = admitted.value.response;
3672 const checked = yield* runRuntimeCallbackAtGeneration(
3673 handlerGeneration,
3674 Effect.gen(function* () {
3675 const nativeResponseAcknowledgement = yield* Deferred.make<
3676 void,
3677 EffectAcpErrors.AcpError
3678 >();
3679 yield* registerNativeResponseAcknowledgement(
3680 handlerGeneration,
3681 correlatedTransportRequestId,
3682 nativeResponseAcknowledgement,
3683 );
3684 return response;
3685 }),
3686 );
3687 return Option.isSome(checked) ? checked.value : yield* Effect.never;
3688 }
3689 const {
3690 context,
3691 decision: pendingDecision,
3692 requestId,
3693 transportRequestId: pendingTransportRequestId,
3694 } = admitted.value.pending;
3695 const decision = yield* Deferred.await(pendingDecision).pipe(
3696 Effect.ensuring(
3697 runRuntimeCallbackAtGeneration(
3698 handlerGeneration,
3699 Effect.gen(function* () {
3700 yield* Ref.update(pendingRuntimeRequests, (current) => {
3701 const updated = new Map(current);
3702 updated.delete(String(requestId));
3703 return updated;
3704 });
3705 yield* rearmRootTurnRecoveryTimers(context);
3706 }),
3707 ).pipe(Effect.asVoid),
3708 ),
3709 );
3710 const response = (() => {
3711 if (decision === "cancel") {
3712 return { outcome: { outcome: "cancelled" } } as const;
3713 }
3714 const optionId = selectPermissionOptionId(params, decision);
3715 return optionId === undefined
3716 ? ({ outcome: { outcome: "cancelled" } } as const)
3717 : ({ outcome: { outcome: "selected", optionId } } as const);
3718 })();
3719 const checked = yield* runRuntimeCallbackAtGeneration(
3720 handlerGeneration,
3721 Effect.succeed(response),
3722 );
3723 if (Option.isNone(checked)) return yield* Effect.never;
3724 yield* acknowledgeNativeResponse(handlerGeneration, pendingTransportRequestId);
3725 return checked.value;
3726 }).pipe(
3727 Effect.mapError(
3728 (cause) =>
3729 new EffectAcpErrors.AcpTransportError({
3730 detail: "Failed to handle an ACP permission request",
3731 cause,
3732 }),
3733 ),
3734 ),
3735 );
3736 yield* runtime.handleElicitation((params) =>
3737 Effect.gen(function* () {
3738 const transportRequestId = yield* claimNativeTransportRequest(
3739 handlerGeneration,
3740 ({ method, payload }) => {
3741 const record = unknownRecord(payload);
3742 return (
3743 method === "session/elicitation" &&
3744 record?.sessionId === params.sessionId &&
3745 record.message === params.message &&
3746 record.mode === params.mode &&
3747 (params.mode === "url"
3748 ? record.elicitationId === params.elicitationId && record.url === params.url
3749 : acpCanonicalJson(record.requestedSchema) ===
3750 acpCanonicalJson(params.requestedSchema))
3751 );
3752 },
3753 );
3754 const correlated = yield* runRuntimeCallbackAtGeneration(
3755 handlerGeneration,
3756 transportRequestId === undefined
3757 ? new EffectAcpErrors.AcpTransportError({
3758 detail:
3759 "Could not correlate the ACP elicitation request with its transport ID",
3760 cause: "Could not correlate session/elicitation transport request",
3761 })
3762 : Effect.succeed(transportRequestId),
3763 );
3764 if (Option.isNone(correlated)) return yield* Effect.never;
3765 const correlatedTransportRequestId = correlated.value;
3766 if (params.mode === "url") {
3767 const admitted = yield* runRuntimeCallbackAtGeneration(
3768 handlerGeneration,
3769 Effect.gen(function* () {
3770 const nativeResponseAcknowledgement = yield* Deferred.make<
3771 void,
3772 EffectAcpErrors.AcpError
3773 >();
3774 yield* registerNativeResponseAcknowledgement(
3775 handlerGeneration,
3776 correlatedTransportRequestId,
3777 nativeResponseAcknowledgement,
3778 );
3779 return { action: { action: "decline" } } as const;
3780 }),
3781 );
3782 if (Option.isNone(admitted)) return yield* Effect.never;
3783 return admitted.value;
3784 }
3785 const questions = Object.entries(params.requestedSchema.properties ?? {}).map(
3786 ([id, property], index): OrchestrationV2UserInputQuestion => {
3787 const record = unknownRecord(property);
3788 const enumValues = Array.isArray(record?.enum)
3789 ? record.enum.filter((value): value is string => typeof value === "string")
3790 : [];
3791 const options =
3792 enumValues.length > 0
3793 ? enumValues.map((value) => ({ label: value, description: value }))
3794 : record?.type === "boolean"
3795 ? [
3796 { label: "true", description: "Yes" },
3797 { label: "false", description: "No" },
3798 ]
3799 : [];
3800 return {
3801 id,
3802 header: nonEmptyText(record?.title, `Question ${index + 1}`),
3803 question: nonEmptyText(record?.description, params.message),
3804 options,
3805 };
3806 },
3807 );
3808 const userInput = yield* requestUserInputWithAdmission(
3809 handlerGeneration,
3810 Effect.gen(function* () {
3811 const ordinal = yield* Ref.getAndUpdate(
3812 nextElicitationOrdinal,
3813 (current) => current + 1,
3814 );
3815 const nativeRequestId = `${params.sessionId}:elicitation:${ordinal}`;
3816 return {
3817 nativeItemId: nativeRequestId,
3818 nativeRequestId,
3819 questions,
3820 };
3821 }),
3822 correlatedTransportRequestId,
3823 );
3824 const response =
3825 userInput.answers === null
3826 ? ({ action: { action: "cancel" } } as const)
3827 : ({
3828 action: {
3829 action: "accept",
3830 content: elicitationContent(
3831 userInput.answers,
3832 new Set(Object.keys(params.requestedSchema.properties ?? {})),
3833 ),
3834 },
3835 } as const);
3836 yield* userInput.acknowledgeNativeResponse;
3837 return response;
3838 }),
3839 );
3840 if (flavor.registerExtensions !== undefined) {
3841 yield* flavor.registerExtensions({
3842 runtime,
3843 requestUserInput,
3844 applyBackgroundTaskMutation: (mutation) =>
3845 Effect.gen(function* () {
3846 // Direct Stop quarantine: drop residual task lifecycle from
3847 // the stopped run instead of mutating wake machinery.
3848 if (yield* Ref.get(stoppedRunQuarantine)) return;
3849 // Root-session tasks only: a cancelled subagent's re-run in
3850 // its child session must not gate root wake machinery.
3851 if ((yield* Ref.get(activeSessionId)) !== mutation.sessionId) return;
3852 yield* applyLateBackgroundMutation(mutation.sessionId, mutation);
3853 }),
3854 });
3855 }
3856 });
3857
3858 const spawnAcpRuntime = Effect.fnUntraced(function* () {
3859 if (runtimeScope !== undefined) {
3860 yield* Scope.close(runtimeScope, Exit.void);
3861 }
3862 runtimeScope = yield* Scope.make();
3863 const runtimeGeneration = yield* Ref.get(runtimeCallbackGeneration);
3864 runtime = yield* flavor
3865 .makeRuntime(makeRuntimeInput(runtimeGeneration))
3866 .pipe(Effect.provideService(Scope.Scope, runtimeScope));
3867 });
3868
3869 const restartAcpRuntime = Effect.fnUntraced(function* () {
3870 yield* spawnAcpRuntime();
3871 yield* wireAcpRuntimeHandlers();
3872 });
1884 3873
1885 if (flavor.registerExtensions !== undefined) {3874 yield* spawnAcpRuntime();
1886 yield* flavor.registerExtensions({ runtime, requestUserInput });3875 yield* wireAcpRuntimeHandlers();
1887 }
1888 3876
1889 const started = yield* runtime.start();3877 const started = yield* runtime.start();
1890 yield* Ref.set(activeSessionId, started.sessionId);3878 yield* Ref.set(activeSessionId, started.sessionId);
@@ -1893,12 +3881,15 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 2 unmodified lines
1893 const canLoadSession = started.initializeResult.agentCapabilities?.loadSession === true;3881 const canLoadSession = started.initializeResult.agentCapabilities?.loadSession === true;
1894 const canResumeSession =3882 const canResumeSession =
1895 started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null;3883 started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null;
1896 const supportsImagePrompts =3884 const supportsImagePrompts = acpSupportsImagePrompts({
1897 started.initializeResult.agentCapabilities?.promptCapabilities?.image === true;3885 flavorSupportsImagePrompts: flavor.supportsImagePrompts,
3886 negotiatedImage:
3887 started.initializeResult.agentCapabilities?.promptCapabilities?.image === true,
3888 });
1898 3889
1899 const activateSession = Effect.fnUntraced(function* (3890 const activateSession = Effect.fnUntraced(function* (
1900 sessionId: string,3891 sessionId: string,
1901 threadId: ThreadId,3892 threadId: ThreadId | null,
1902 ) {3893 ) {
1903 const activationOptions = { mcpServers: acpMcpServers(threadId) };3894 const activationOptions = { mcpServers: acpMcpServers(threadId) };
1904 if (canLoadSession) {3895 if (canLoadSession) {
@@ -1996,16 +3987,90 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 91 unmodified lines
1996 completedAt,3987 completedAt,
1997 });3988 });
1998 3989
3990 const drainTrailingRootTurnChunks = Effect.fnUntraced(function* () {
3991 if (!flavor.settleRootTurnWhenIdle) return;
3992 // Projected via handleSessionUpdate, not getEvents(). Cooperative yield
3993 // only — replay uses TestClock; Effect.sleep here would stall settlement.
3994 yield* Effect.yieldNow;
3995 yield* Effect.yieldNow;
3996 });
3997
3998 const terminalizeOpenRunOwnedItems = Effect.fnUntraced(function* (
3999 context: ActiveAcpTurn,
4000 options: { readonly terminalizeSubagents: boolean },
4001 ) {
4002 for (const tool of context.tools.values()) {
4003 const status = toolStatus(tool.status);
4004 if (status === "pending" || status === "running") {
4005 yield* emitTool(context, tool, "interrupted");
4006 }
4007 }
4008 if (!options.terminalizeSubagents) return;
4009 for (const subagent of context.subagents.values()) {
4010 if (subagent.task.status !== "running" && subagent.task.status !== "pending") {
4011 continue;
4012 }
4013 yield* emitSubagent(context, {
4014 nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id,
4015 prompt: subagent.task.prompt,
4016 title: subagent.task.title,
4017 model: subagent.task.model,
4018 status: "interrupted",
4019 childSessionId: subagent.childSessionId,
4020 result: subagent.task.result,
4021 suppressNormalTool: true,
4022 });
4023 }
4024 });
4025
4026 const terminalizeOpenForegroundTools = Effect.fnUntraced(function* (
4027 context: ActiveAcpTurn,
4028 ) {
4029 for (const tool of context.tools.values()) {
4030 if (!acpCompletedTurnShouldTerminalizeTool(tool, flavor)) continue;
4031 yield* emitTool(context, tool, "completed");
4032 }
4033 });
4034
4035 const quarantineStoppedRun = Effect.fnUntraced(function* () {
4036 yield* continuationPermit.withPermit(
4037 Effect.gen(function* () {
4038 yield* Ref.update(continuationGeneration, (value) => value + 1);
4039 yield* Ref.set(stoppedRunQuarantine, true);
4040 yield* Ref.set(wakeBuffer, []);
4041 yield* Ref.set(continuationRequested, false);
4042 yield* Ref.set(runningBackgroundTaskIds, new Set());
4043 yield* Ref.set(carryoverSubagents, null);
4044 yield* Ref.set(lastTurnRoute, null);
4045 }),
4046 );
4047 });
4048
1999 const finalizeTurn = Effect.fnUntraced(function* (4049 const finalizeTurn = Effect.fnUntraced(function* (
2000 context: ActiveAcpTurn,4050 context: ActiveAcpTurn,
2001 status: "completed" | "interrupted" | "failed" | "cancelled",4051 status: "completed" | "interrupted" | "failed" | "cancelled",
2002 failure?: OrchestrationV2ProviderFailure,4052 failure?: OrchestrationV2ProviderFailure,
4053 options?: { readonly drainTrailingChunks?: boolean },
2003 ) {4054 ) {
2004 if (context.finalized) return;4055 if (context.finalized) return;
4056 const settledStatus = context.interrupted ? "interrupted" : status;
2005 context.finalized = true;4057 context.finalized = true;
4058 if (options?.drainTrailingChunks === true) {
4059 yield* drainTrailingRootTurnChunks();
4060 }
4061 const directStopQuarantine = yield* Ref.get(stoppedRunQuarantine);
4062 if (settledStatus === "completed") {
4063 yield* terminalizeOpenForegroundTools(context);
4064 } else if (settledStatus === "interrupted") {
4065 // Direct Stop terminalizes every visible run-owned item. restart_active
4066 // keeps live subagent lineages for in-process replacement carryover.
4067 yield* terminalizeOpenRunOwnedItems(context, {
4068 terminalizeSubagents: directStopQuarantine,
4069 });
4070 }
2006 yield* closeTextStreams(context);4071 yield* closeTextStreams(context);
2007 const now = yield* DateTime.now;4072 const now = yield* DateTime.now;
2008 const turn = providerTurnPayload(context, status, now);4073 const turn = providerTurnPayload(context, settledStatus, now);
2009 yield* Ref.update(providerTurns, (current) => {4074 yield* Ref.update(providerTurns, (current) => {
2010 const updated = new Map(current);4075 const updated = new Map(current);
2011 updated.set(String(turn.id), turn);4076 updated.set(String(turn.id), turn);
@@ -2031,7 +4096,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 19 unmodified lines
2031 },4096 },
2032 });4097 });
2033 yield* emitProviderEvent(4098 yield* emitProviderEvent(
2034 status === "failed"4099 settledStatus === "failed"
2035 ? {4100 ? {
2036 type: "turn.terminal",4101 type: "turn.terminal",
2037 driver,4102 driver,
@@ -2042,7 +4107,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 4 unmodified lines
2042 context,4107 context,
2043 `terminal-failure:${context.providerTurnId}`,4108 `terminal-failure:${context.providerTurnId}`,
2044 ),4109 ),
2045 status,4110 status: settledStatus,
2046 failure: failure ?? makeProviderFailure({ class: "provider_error" }),4111 failure: failure ?? makeProviderFailure({ class: "provider_error" }),
2047 threadDisposition: "reusable",4112 threadDisposition: "reusable",
2048 }4113 }
@@ -2052,15 +4117,108 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines
2052 providerThreadId: context.input.providerThread.id,4117 providerThreadId: context.input.providerThread.id,
2053 providerTurnId: context.providerTurnId,4118 providerTurnId: context.providerTurnId,
2054 runOrdinal: context.input.runOrdinal,4119 runOrdinal: context.input.runOrdinal,
2055 status,4120 status: settledStatus,
2056 failure: null,4121 failure: null,
2057 threadDisposition: "reusable",4122 threadDisposition: "reusable",
2058 },4123 },
2059 );4124 );
4125 const liveSubagents = [...context.subagents.values()].filter(
4126 (subagent) => subagent.task.status === "running" || subagent.task.status === "pending",
4127 );
4128 // Direct Stop must not carry residual subagents into a later run.
4129 if (liveSubagents.length > 0 && !directStopQuarantine) {
4130 const sessionId = yield* Ref.get(activeSessionId);
4131 if (sessionId !== null) {
4132 yield* Ref.set(carryoverSubagents, { sessionId, subagents: liveSubagents });
4133 }
4134 }
2060 yield* Ref.set(activeTurn, null);4135 yield* Ref.set(activeTurn, null);
2061 yield* Deferred.succeed(context.completed, undefined).pipe(Effect.ignore);4136 yield* Deferred.succeed(context.completed, undefined).pipe(Effect.ignore);
2062 });4137 });
2063 4138
4139 const trySettleRootTurnWhenIdle = Effect.fnUntraced(function* (context: ActiveAcpTurn) {
4140 const pending = yield* Ref.get(pendingRuntimeRequests);
4141 const hasPendingRuntimeRequest = acpTurnHasPendingRuntimeRequest(
4142 context.providerTurnId,
4143 pending,
4144 );
4145 const hasRunningTool = [...context.tools.values()].some((tool) => {
4146 const status = toolStatus(tool.status);
4147 return status === "pending" || status === "running";
4148 });
4149 // Debounce already proved root-session quiescence; open segment handles
4150 // without an explicit close should not block settlement.
4151 const hasRunningSubagent = [...context.subagents.values()].some(
4152 (subagent) => subagent.task.status === "running",
4153 );
4154 if (
4155 !acpRootTurnIsIdle({
4156 finalized: context.finalized,
4157 interrupted: context.interrupted,
4158 assistantStreamOpen: false,
4159 reasoningStreamOpen: false,
4160 hasRunningTool,
4161 hasPendingRuntimeRequest,
4162 hasToolHistory: context.tools.size > 0,
4163 hasRunningSubagent,
4164 hasOutput: context.assistant.nextSegment > 0,
4165 })
4166 ) {
4167 return;
4168 }
4169 // Never session/cancel here. Speculative settle must not kill in-flight
4170 // Grok work; late tools would arrive with activeTurn null and drop.
4171 yield* finalizeTurn(context, "completed", undefined, { drainTrailingChunks: true });
4172 });
4173
4174 scheduleDeferredFinalize = (context) =>
4175 Effect.gen(function* () {
4176 if (!flavor.deferFinalizeForBackgroundWork) return;
4177 if (!context.promptSettled || context.finalized || context.interrupted) return;
4178 if (hasDeferredBackgroundWork(context)) return;
4179 context.backgroundFinalizeGeneration += 1;
4180 const generation = context.backgroundFinalizeGeneration;
4181 // Minimal quiet for all models (no per-model carveouts). Defer +
4182 // awaitingBackgroundHydration hold the turn through monitors; this
4183 // is only a short debounce after the last rearm so a slightly late
4184 // first post-hydration assistant chunk is not dropped. Multi-second
4185 // floors (4–20s) only prolonged Working on grok-4.5.
4186 yield* Effect.gen(function* () {
4187 yield* Effect.sleep("2000 millis");
4188 if (
4189 context.finalized ||
4190 context.interrupted ||
4191 context.backgroundFinalizeGeneration !== generation
4192 ) {
4193 return;
4194 }
4195 if (hasDeferredBackgroundWork(context)) return;
4196 const status = context.promptSettledStatus ?? "completed";
4197 yield* finalizeTurn(context, status, undefined, { drainTrailingChunks: true });
4198 }).pipe(Effect.forkIn(sessionScope), Effect.asVoid);
4199 });
4200
4201 scheduleSettleRootTurnWhenIdle = (context) =>
4202 Effect.gen(function* () {
4203 if (!flavor.settleRootTurnWhenIdle) return;
4204 context.settleScheduleGeneration += 1;
4205 const generation = context.settleScheduleGeneration;
4206 yield* Effect.gen(function* () {
4207 yield* Effect.sleep(`${acpRootTurnSettleDebounceMs} millis`);
4208 if (context.finalized || context.interrupted) return;
4209 if (context.settleScheduleGeneration !== generation) return;
4210 const active = yield* Ref.get(activeTurn);
4211 if (active !== context) return;
4212 yield* trySettleRootTurnWhenIdle(context);
4213 }).pipe(Effect.forkIn(sessionScope), Effect.asVoid);
4214 });
4215
4216 rearmRootTurnRecoveryTimers = (context) =>
4217 Effect.gen(function* () {
4218 if (!acpRootTurnShouldRearmRecoveryTimers(context)) return;
4219 yield* scheduleSettleRootTurnWhenIdle(context);
4220 });
4221
2064 const resolvePromptParts = Effect.fnUntraced(function* (4222 const resolvePromptParts = Effect.fnUntraced(function* (
2065 turnInput: ProviderAdapterV2TurnInput,4223 turnInput: ProviderAdapterV2TurnInput,
2066 ) {4224 ) {
@@ -2110,8 +4268,26 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 43 unmodified lines
2110 return prompt;4268 return prompt;
2111 });4269 });
2112 4270
2113 const startTurn = Effect.fn("AcpAdapterV2.startTurn")(4271 const restartRuntimeAfterTeardownIfRequired = Effect.fnUntraced(function* () {
4272 const restartRequired = yield* Ref.get(runtimeRestartRequired);
4273 if (!restartRequired) return false;
4274 yield* restartAcpRuntime();
4275 yield* Ref.set(runtimeRestartRequired, false);
4276 yield* Ref.set(activeSessionId, null);
4277 yield* Ref.set(activeSessionSetup, null);
4278 yield* Ref.set(activeSelection, null);
4279 yield* Ref.set(snapshot, {
4280 order: [],
4281 messages: new Map(),
4282 loadingRole: null,
4283 loadingIndex: 0,
4284 });
4285 return true;
4286 });
4287
4288 const startTurnUnlocked = Effect.fn("AcpAdapterV2.startTurn")(
2114 function* (turnInput: ProviderAdapterV2TurnInput) {4289 function* (turnInput: ProviderAdapterV2TurnInput) {
4290 yield* awaitRuntimeTeardown();
2115 const existing = yield* Ref.get(activeTurn);4291 const existing = yield* Ref.get(activeTurn);
2116 if (existing !== null) {4292 if (existing !== null) {
2117 return yield* new ProviderAdapterProtocolError({4293 return yield* new ProviderAdapterProtocolError({
@@ -2120,7 +4296,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 2 unmodified lines
2120 });4296 });
2121 }4297 }
2122 const requestedSessionId = nativeThreadId(driver, turnInput.providerThread);4298 const requestedSessionId = nativeThreadId(driver, turnInput.providerThread);
2123 if ((yield* Ref.get(activeSessionId)) !== requestedSessionId) {4299 const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired();
4300 const needsSessionActivation =
4301 (yield* Ref.get(activeSessionId)) !== requestedSessionId || restartAfterInterrupt;
4302 if (needsSessionActivation) {
2124 const activated = yield* activateSession(requestedSessionId, turnInput.threadId);4303 const activated = yield* activateSession(requestedSessionId, turnInput.threadId);
2125 yield* Ref.set(activeSessionId, activated.sessionId);4304 yield* Ref.set(activeSessionId, activated.sessionId);
2126 yield* Ref.set(activeSessionSetup, activated);4305 yield* Ref.set(activeSessionSetup, activated);
@@ -2147,11 +4326,32 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 20 unmodified lines
2147 yield* Ref.set(activeSelection, turnInput.modelSelection);4326 yield* Ref.set(activeSelection, turnInput.modelSelection);
2148 }4327 }
2149 }4328 }
2150 const prompt = yield* resolvePromptParts(turnInput);4329 yield* Ref.set(lastTurnRoute, {
4330 threadId: turnInput.threadId,
4331 providerThreadId: turnInput.providerThread.id,
4332 });
4333 yield* Ref.set(suppressPostSettleMonitorPrompt, false);
4334 yield* Ref.set(handledBackgroundTaskIdsInActiveTurn, new Set());
4335 // Continuation turns attach to wake traffic the agent already produced
4336 // after the prior root turn settled; do not re-prompt the ACP session.
4337 const isContinuationTurn =
4338 postSettleContinuationEnabled &&
4339 turnInput.message.createdBy === "agent" &&
4340 turnInput.message.creationSource === "provider";
4341 // Drop a sticky continuation offer when any new turn starts so idle
4342 // pin and further offers cannot wed on a completed or failed dispatch.
4343 yield* continuationPermit.withPermit(
4344 Effect.gen(function* () {
4345 yield* Ref.update(continuationGeneration, (value) => value + 1);
4346 yield* Ref.set(continuationRequested, false);
4347 }),
4348 );
4349 const prompt = isContinuationTurn ? null : yield* resolvePromptParts(turnInput);
2151 const startedAt = yield* DateTime.now;4350 const startedAt = yield* DateTime.now;
2152 const nativeTurnId = `${requestedSessionId}:turn:${turnInput.providerTurnOrdinal}`;4351 const nativeTurnId = `${requestedSessionId}:turn:${turnInput.providerTurnOrdinal}`;
2153 const providerTurnId = idAllocator.derive.providerTurn({ driver, nativeTurnId });4352 const providerTurnId = idAllocator.derive.providerTurn({ driver, nativeTurnId });
2154 const completed = yield* Deferred.make<void, never>();4353 const completed = yield* Deferred.make<void, never>();
4354 const promptWireSettled = yield* Deferred.make<void, never>();
2155 const context: ActiveAcpTurn = {4355 const context: ActiveAcpTurn = {
2156 input: turnInput,4356 input: turnInput,
2157 providerTurnId,4357 providerTurnId,
@@ -2165,11 +4365,35 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 7 unmodified lines
2165 subagents: new Map(),4365 subagents: new Map(),
2166 subagentsBySessionId: new Map(),4366 subagentsBySessionId: new Map(),
2167 pendingSubagentNotifications: new Map(),4367 pendingSubagentNotifications: new Map(),
4368 toolCallIdsByBackgroundTaskId: new Map(),
4369 persistentBackgroundTaskIds: new Set(),
4370 awaitingBackgroundHydration: new Set(),
4371 pendingInjectedReport: new Set(),
2168 plan: null,4372 plan: null,
2169 interrupted: false,4373 interrupted: false,
2170 finalized: false,4374 finalized: false,
4375 settleScheduleGeneration: 0,
4376 promptSettled: false,
4377 promptSettledStatus: null,
4378 promptWireSettled,
4379 backgroundFinalizeGeneration: 0,
2171 };4380 };
4381 const carryover = yield* Ref.getAndSet(carryoverSubagents, null);
4382 if (carryover !== null && carryover.sessionId === requestedSessionId) {
4383 for (const subagent of carryover.subagents) {
4384 const nativeId = subagent.task.nativeTaskRef?.nativeId ?? null;
4385 if (nativeId !== null) {
4386 context.subagents.set(nativeId, subagent);
4387 }
4388 if (subagent.childSessionId !== null) {
4389 context.subagentsBySessionId.set(subagent.childSessionId, subagent);
4390 }
4391 }
4392 }
2172 yield* Ref.set(activeTurn, context);4393 yield* Ref.set(activeTurn, context);
4394 // Direct Stop closes and recreates the old runtime before reaching
4395 // this reset. The quarantine remains session-scoped by design.
4396 yield* Ref.set(stoppedRunQuarantine, false);
2173 const runningTurn = providerTurnPayload(context, "running", null);4397 const runningTurn = providerTurnPayload(context, "running", null);
2174 yield* Ref.update(providerTurns, (current) => {4398 yield* Ref.update(providerTurns, (current) => {
2175 const updated = new Map(current);4399 const updated = new Map(current);
@@ -2206,35 +4430,100 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 30 unmodified lines
2206 createdAt: startedAt,4430 createdAt: startedAt,
2207 updatedAt: startedAt,4431 updatedAt: startedAt,
2208 });4432 });
2209 yield* runtime.prompt({ prompt }).pipe(4433 if (isContinuationTurn) {
2210 Effect.flatMap((result) => {4434 const drained = yield* Ref.modify(wakeBuffer, (current) => {
2211 const status =4435 const next: Array<EffectAcpSchema.SessionNotification> = [];
2212 result.stopReason === "cancelled"4436 return [current.slice(), next] as const;
2213 ? context.interrupted4437 });
2214 ? "interrupted"4438 yield* Ref.set(continuationRequested, false);
2215 : "cancelled"4439 // Treat attach mode as prompt-settled so deferred finalize / quiet
2216 : "completed";4440 // windows can complete the continuation after wake traffic drains.
2217 return finalizeTurn(context, status);4441 context.promptSettled = true;
2218 }),4442 context.promptSettledStatus = "completed";
4443 if (drained.length === 0) {
4444 yield* finalizeTurn(context, "completed", undefined, {
4445 drainTrailingChunks: true,
4446 });
4447 return;
4448 }
4449 for (const notification of drained) {
4450 yield* handleSessionUpdate(notification);
4451 }
4452 if (!context.finalized) {
4453 if (hasDeferredBackgroundWork(context)) {
4454 yield* rearmDeferredFinalize(context);
4455 } else {
4456 yield* scheduleDeferredFinalize(context);
4457 }
4458 }
4459 return;
4460 }
4461 const promptGeneration = yield* Ref.get(runtimeCallbackGeneration);
4462 yield* runtime.prompt({ prompt: prompt! }).pipe(
4463 // Wire settlement precedes the completion callback's permit request so
4464 // settled-soft classification can observe the native return even when
4465 // the completion fiber has not yet set promptSettled under the permit.
4466 Effect.tap(() =>
4467 Deferred.succeed(context.promptWireSettled, undefined).pipe(Effect.asVoid),
4468 ),
4469 Effect.flatMap((result) =>
4470 runRuntimeCallbackAtGeneration(
4471 promptGeneration,
4472 Effect.gen(function* () {
4473 if (context.finalized) return;
4474 const status =
4475 result.stopReason === "cancelled"
4476 ? context.interrupted
4477 ? "interrupted"
4478 : "cancelled"
4479 : "completed";
4480 // Grok monitors (and async subagents) keep working after the root
4481 // prompt RPC returns. Defer finalize so their later updates and
4482 // wake-turn traffic still project onto this run.
4483 if (
4484 flavor.deferFinalizeForBackgroundWork === true &&
4485 !context.interrupted &&
4486 hasDeferredBackgroundWork(context)
4487 ) {
4488 context.promptSettled = true;
4489 context.promptSettledStatus = status;
4490 return;
4491 }
4492 // Only completed turns drain trailing chunks. Interrupted turns
4493 // must not wait for residual output from a stopped prompt.
4494 yield* finalizeTurn(context, status, undefined, {
4495 drainTrailingChunks: status === "completed",
4496 });
4497 }),
4498 ).pipe(Effect.asVoid),
4499 ),
4500 // Prompt failure is not wire-settled: only a successful resolve marks
4501 // the signal. catchCause must not complete promptWireSettled.
2219 Effect.catchCause((cause) =>4502 Effect.catchCause((cause) =>
2220 finalizeTurn(4503 runRuntimeCallbackAtGeneration(
2221 context,4504 promptGeneration,
2222 context.interrupted ? "interrupted" : "failed",4505 Effect.gen(function* () {
2223 makeProviderFailure({4506 if (context.finalized) return;
2224 cause: Cause.squash(cause),4507 yield* finalizeTurn(
2225 class: "provider_error",4508 context,
4509 context.interrupted ? "interrupted" : "failed",
4510 makeProviderFailure({
4511 cause: Cause.squash(cause),
4512 class: "provider_error",
4513 }),
4514 ).pipe(
4515 Effect.andThen(
4516 Effect.logWarning("orchestration-v2.acp-prompt-failed", {
4517 driver,
4518 providerSessionId: input.providerSessionId,
4519 providerThreadId: turnInput.providerThread.id,
4520 providerTurnId,
4521 cause,
4522 }),
4523 ),
4524 );
2226 }),4525 }),
2227 ).pipe(4526 ).pipe(Effect.asVoid),
2228 Effect.andThen(
2229 Effect.logWarning("orchestration-v2.acp-prompt-failed", {
2230 driver,
2231 providerSessionId: input.providerSessionId,
2232 providerThreadId: turnInput.providerThread.id,
2233 providerTurnId,
2234 cause,
2235 }),
2236 ),
2237 ),
2238 ),4527 ),
2239 Effect.forkIn(sessionScope),4528 Effect.forkIn(sessionScope),
2240 );4529 );
@@ -2254,8 +4543,20 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 13 unmodified lines
2254 ),4543 ),
2255 );4544 );
2256 4545
4546 const startTurn = Effect.fn("AcpAdapterV2.startTurn.transition")(function* (
4547 turnInput: ProviderAdapterV2TurnInput,
4548 ) {
4549 return yield* runtimeTransitionPermit.withPermit(startTurnUnlocked(turnInput));
4550 });
4551
2257 yield* Effect.addFinalizer(() =>4552 yield* Effect.addFinalizer(() =>
2258 Effect.gen(function* () {4553 Effect.gen(function* () {
4554 yield* continuationPermit.withPermit(
4555 Effect.gen(function* () {
4556 yield* Ref.set(continuationClosed, true);
4557 yield* Ref.update(continuationGeneration, (value) => value + 1);
4558 }),
4559 );
2259 const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()];4560 const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()];
2260 yield* Effect.forEach(4561 yield* Effect.forEach(
2261 requests,4562 requests,
@@ -2265,14 +4566,40 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines
2265 : Deferred.succeed(request.answers, null).pipe(Effect.ignore),4566 : Deferred.succeed(request.answers, null).pipe(Effect.ignore),
2266 { discard: true },4567 { discard: true },
2267 );4568 );
4569 const closingError = new EffectAcpErrors.AcpTransportError({
4570 detail: "The ACP session closed before its admitted response reached the transport",
4571 cause: "ACP session transport closed",
4572 });
4573 yield* Effect.forEach(
4574 requests,
4575 (request) => Deferred.fail(request.nativeResponseAcknowledgement, closingError),
4576 { discard: true },
4577 );
4578 const transportHadOutstandingResponses = yield* closeNativeTransport;
4579 yield* options.testHooks?.afterNativeResponseTransportClosed?.() ?? Effect.void;
2268 const sessionCapabilities =4580 const sessionCapabilities =
2269 started.initializeResult.agentCapabilities?.sessionCapabilities;4581 started.initializeResult.agentCapabilities?.sessionCapabilities;
2270 if (sessionCapabilities?.close != null) {4582 if (sessionCapabilities?.close != null) {
2271 yield* runtime.closeSession().pipe(Effect.ignore);4583 yield* runtimeTransitionPermit.withPermitsIfAvailable(1)(
4584 Effect.gen(function* () {
4585 const teardownState = yield* Ref.get(runtimeTeardownState);
4586 const restartRequired = yield* Ref.get(runtimeRestartRequired);
4587 if (
4588 teardownState._tag === "Idle" &&
4589 !restartRequired &&
4590 !transportHadOutstandingResponses
4591 ) {
4592 yield* runtime.closeSession().pipe(Effect.ignore);
4593 }
4594 }),
4595 );
2272 }4596 }
2273 if (flavor.assertComplete !== undefined) {4597 if (flavor.assertComplete !== undefined) {
2274 yield* flavor.assertComplete.pipe(Effect.orDie);4598 yield* flavor.assertComplete.pipe(Effect.orDie);
2275 }4599 }
4600 if (runtimeScope !== undefined) {
4601 yield* Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore);
4602 }
2276 }),4603 }),
2277 );4604 );
2278 4605
@@ -2282,6 +4609,16 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines
2282 providerSessionId: input.providerSessionId,4609 providerSessionId: input.providerSessionId,
2283 providerSession,4610 providerSession,
2284 events: Stream.fromEffectRepeat(Queue.take(events)),4611 events: Stream.fromEffectRepeat(Queue.take(events)),
4612 ...(postSettleContinuationEnabled
4613 ? {
4614 hasPendingBackgroundWork: Effect.gen(function* () {
4615 if ((yield* Ref.get(wakeBuffer)).length > 0) return true;
4616 if (yield* Ref.get(continuationRequested)) return true;
4617 if ((yield* Ref.get(runningBackgroundTaskIds)).size > 0) return true;
4618 return false;
4619 }),
4620 }
4621 : {}),
2285 ensureThread: Effect.fn("AcpAdapterV2.ensureThread")(4622 ensureThread: Effect.fn("AcpAdapterV2.ensureThread")(
2286 function* (threadInput: ProviderAdapterV2EnsureThreadInput) {4623 function* (threadInput: ProviderAdapterV2EnsureThreadInput) {
2287 const now = yield* DateTime.now;4624 const now = yield* DateTime.now;
@@ -2320,35 +4657,41 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 32 unmodified lines
2320 readonly modelSelection?: ModelSelection;4657 readonly modelSelection?: ModelSelection;
2321 readonly runtimePolicy?: ProviderAdapterV2RuntimePolicy;4658 readonly runtimePolicy?: ProviderAdapterV2RuntimePolicy;
2322 }) {4659 }) {
2323 const sessionId = nativeThreadId(driver, threadInput.providerThread);4660 return yield* runtimeTransitionPermit.withPermit(
2324 if ((yield* Ref.get(activeSessionId)) !== sessionId) {4661 Effect.gen(function* () {
2325 yield* Ref.set(snapshot, {4662 yield* awaitRuntimeTeardown();
2326 order: [],4663 const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired();
2327 messages: new Map(),4664 const sessionId = nativeThreadId(driver, threadInput.providerThread);
2328 loadingRole: null,4665 if ((yield* Ref.get(activeSessionId)) !== sessionId || restartAfterInterrupt) {
2329 loadingIndex: 0,4666 yield* Ref.set(snapshot, {
2330 });4667 order: [],
2331 const activated = yield* activateSession(4668 messages: new Map(),
2332 sessionId,4669 loadingRole: null,
2333 threadInput.providerThread.appThreadId,4670 loadingIndex: 0,
2334 );4671 });
2335 yield* Ref.set(activeSessionId, activated.sessionId);4672 const activated = yield* activateSession(
2336 yield* Ref.set(activeSessionSetup, activated);4673 sessionId,
2337 const nextSelection = threadInput.modelSelection ?? input.modelSelection;4674 threadInput.providerThread.appThreadId,
2338 yield* configureSession(4675 );
2339 activated,4676 yield* Ref.set(activeSessionId, activated.sessionId);
2340 nextSelection,4677 yield* Ref.set(activeSessionSetup, activated);
2341 threadInput.runtimePolicy ?? input.runtimePolicy,4678 const nextSelection = threadInput.modelSelection ?? input.modelSelection;
2342 );4679 yield* configureSession(
2343 yield* Ref.set(activeSelection, nextSelection);4680 activated,
2344 }4681 nextSelection,
2345 const now = yield* DateTime.now;4682 threadInput.runtimePolicy ?? input.runtimePolicy,
2346 return {4683 );
2347 ...threadInput.providerThread,4684 yield* Ref.set(activeSelection, nextSelection);
2348 providerSessionId: input.providerSessionId,4685 }
2349 status: "idle" as const,4686 const now = yield* DateTime.now;
2350 updatedAt: now,4687 return {
2351 };4688 ...threadInput.providerThread,
4689 providerSessionId: input.providerSessionId,
4690 status: "idle" as const,
4691 updatedAt: now,
4692 };
4693 }),
4694 );
2352 },4695 },
2353 (effect, threadInput) =>4696 (effect, threadInput) =>
2354 effect.pipe(4697 effect.pipe(
@@ -2373,25 +4716,292 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 18 unmodified lines
2373 ),4716 ),
2374 interruptTurn: Effect.fn("AcpAdapterV2.interruptTurn")(4717 interruptTurn: Effect.fn("AcpAdapterV2.interruptTurn")(
2375 function* (turnInput: ProviderAdapterV2InterruptInput) {4718 function* (turnInput: ProviderAdapterV2InterruptInput) {
2376 const context = yield* Ref.get(activeTurn);4719 return yield* Effect.uninterruptibleMask((restore) =>
2377 if (context?.providerTurnId !== turnInput.providerTurnId) {4720 Effect.gen(function* () {
2378 return yield* new ProviderAdapterProtocolError({4721 yield* restore(runtimeTransitionPermit.take(1));
2379 driver,4722 return yield* Effect.gen(function* () {
2380 detail: `ACP provider turn ${turnInput.providerTurnId} is not active`,4723 const transition = yield* Effect.gen(function* () {
2381 });4724 const interruptContext = yield* Ref.get(activeTurn);
2382 }4725 // Settled steering restart: the native prompt already
2383 context.interrupted = true;4726 // returned and this is not a user Stop, so keep the
2384 yield* runtime.cancel.pipe(Effect.ensuring(cancelPendingRuntimeRequests()));4727 // process (and its background subagents) alive instead
2385 const stopped = yield* Deferred.await(context.completed).pipe(4728 // of hard-killing it. The turn still terminalizes below.
2386 Effect.timeoutOption("10 seconds"),4729 const softSteerCandidate =
4730 flavor.preserveRuntimeOnSettledInterrupt === true &&
4731 turnInput.requestRuntimeRestart !== true &&
4732 interruptContext?.providerTurnId === turnInput.providerTurnId;
4733 // Settlement is read under the callback permit after
4734 // draining admitted responses: the native prompt can have
4735 // returned on the wire while its completion callback has
4736 // not yet run, and a cancel sent in that window is exactly
4737 // what settled-soft mode exists to avoid. Also treat the
4738 // turn as settled when the wire signal is already done
4739 // (non-blocking; do not await under the permit).
4740 let settledSoftInterrupt = false;
4741 if (softSteerCandidate && interruptContext !== null) {
4742 const promptSettledUnderPermit = yield* runtimeCallbackPermit.withPermit(
4743 awaitAdmittedNativeResponses.pipe(
4744 Effect.andThen(
4745 Effect.sync(() => interruptContext.promptSettled === true),
4746 ),
4747 ),
4748 );
4749 const wireSettled = yield* Deferred.isDone(
4750 interruptContext.promptWireSettled,
4751 );
4752 settledSoftInterrupt = promptSettledUnderPermit || wireSettled;
4753 }
4754 const restartRuntime =
4755 !settledSoftInterrupt &&
4756 (turnInput.requestRuntimeRestart === true ||
4757 flavor.restartRuntimeOnEveryInterrupt === true);
4758 const hardRestart =
4759 restartRuntime && flavor.terminateRuntimeProcessGroupOnInterrupt === true;
4760 if (hardRestart) {
4761 const teardownState = yield* Ref.get(runtimeTeardownState);
4762 if (teardownState._tag === "Failed") {
4763 return yield* teardownState.error;
4764 }
4765 if (teardownState._tag === "InProgress") {
4766 // Concurrent Stop/restart: wait for the in-flight hard
4767 // teardown instead of failing the durable effect.
4768 yield* Effect.logInfo(
4769 "ACP interrupt awaiting in-progress hard teardown",
4770 {
4771 driver,
4772 providerTurnId: turnInput.providerTurnId,
4773 },
4774 );
4775 yield* Deferred.await(teardownState.completed);
4776 return undefined;
4777 }
4778 }
4779 const context = interruptContext;
4780 if (context?.providerTurnId !== turnInput.providerTurnId) {
4781 // A soft steering interrupt can clear the turn while
4782 // intentionally leaving the process alive. A queued user
4783 // Stop must still contain that orphan runtime. With a
4784 // different live turn active this stays a pure no-op
4785 // success instead: quarantine and teardown are
4786 // session-global and would maim the replacement turn.
4787 const containOrphanRuntime =
4788 context === null &&
4789 hardRestart &&
4790 turnInput.requestRuntimeRestart === true;
4791 // Transport death or a prior Stop already cleared the
4792 // turn. Failing here caused effect-worker retries while
4793 // the process was already gone; treat as success.
4794 yield* Effect.logWarning(
4795 containOrphanRuntime
4796 ? "ACP Stop raced a soft interrupt that cleared the turn; containing the orphan runtime"
4797 : "ACP interrupt raced transport teardown or a prior Stop; treating as already interrupted",
4798 {
4799 driver,
4800 requestedProviderTurnId: turnInput.providerTurnId,
4801 activeProviderTurnId: context?.providerTurnId ?? null,
4802 hardRestart,
4803 requestRuntimeRestart: turnInput.requestRuntimeRestart === true,
4804 teardownState: (yield* Ref.get(runtimeTeardownState))._tag,
4805 },
4806 );
4807 if (containOrphanRuntime) {
4808 const teardownBarrier = yield* Deferred.make<
4809 void,
4810 ProviderAdapterProtocolError
4811 >();
4812 yield* runtimeCallbackPermit.withPermit(
4813 Effect.gen(function* () {
4814 yield* awaitAdmittedNativeResponses;
4815 const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration);
4816 yield* quarantineNativeTransportAtGeneration(stoppedGeneration);
4817 yield* Ref.set(runtimeTeardownState, {
4818 _tag: "InProgress",
4819 completed: teardownBarrier,
4820 });
4821 yield* Ref.update(
4822 runtimeCallbackGeneration,
4823 (generation) => generation + 1,
4824 );
4825 }),
4826 );
4827 // Capture before quarantineStoppedRun clears carryover.
4828 const orphanCarryover = yield* Ref.getAndSet(carryoverSubagents, null);
4829 yield* quarantineStoppedRun();
4830 // Match the main hard-restart path: cancel pending
4831 // approvals/elicitations after quarantine, before kill.
4832 yield* cancelPendingRuntimeRequests();
4833 yield* terminalizeCarryoverSubagents(orphanCarryover);
4834 if (runtime.terminateProcessGroup === undefined) {
4835 const error = new ProviderAdapterProtocolError({
4836 driver,
4837 detail:
4838 "ACP runtime does not expose its required process-group teardown; the session is poisoned",
4839 });
4840 yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error });
4841 yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore);
4842 return yield* error;
4843 }
4844 const teardownExit = yield* runtime.terminateProcessGroup.pipe(
4845 Effect.exit,
4846 );
4847 if (Exit.isFailure(teardownExit)) {
4848 const error = new ProviderAdapterProtocolError({
4849 driver,
4850 detail:
4851 "ACP orphan runtime process-group teardown failed; the session is poisoned",
4852 payload: Cause.squash(teardownExit.cause),
4853 });
4854 yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error });
4855 yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore);
4856 return yield* error;
4857 }
4858 yield* Ref.set(runtimeRestartRequired, true);
4859 yield* Ref.set(runtimeTeardownState, { _tag: "Idle" });
4860 yield* Deferred.succeed(teardownBarrier, undefined).pipe(Effect.ignore);
4861 }
4862 return undefined;
4863 }
4864 const teardownBarrier = hardRestart
4865 ? yield* Deferred.make<void, ProviderAdapterProtocolError>()
4866 : null;
4867 if (teardownBarrier !== null) {
4868 yield* runtimeCallbackPermit.withPermit(
4869 Effect.gen(function* () {
4870 yield* awaitAdmittedNativeResponses;
4871 const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration);
4872 yield* quarantineNativeTransportAtGeneration(stoppedGeneration);
4873 yield* Ref.set(runtimeTeardownState, {
4874 _tag: "InProgress",
4875 completed: teardownBarrier,
4876 });
4877 yield* Ref.update(
4878 runtimeCallbackGeneration,
4879 (generation) => generation + 1,
4880 );
4881 yield* (
4882 options.testHooks?.afterHardTeardownTransportDrained?.() ??
4883 Effect.void
4884 );
4885 }),
4886 );
4887 }
4888 return {
4889 context,
4890 restartRuntime,
4891 hardRestart,
4892 teardownBarrier,
4893 settledSoftInterrupt,
4894 };
4895 });
4896 // Concurrent hard teardown already completed above.
4897 if (transition === undefined) return;
4898 const {
4899 context,
4900 restartRuntime,
4901 hardRestart,
4902 teardownBarrier,
4903 settledSoftInterrupt,
4904 } = transition;
4905 const poisonTeardown = Effect.fnUntraced(function* (
4906 detail: string,
4907 payload?: unknown,
4908 ) {
4909 const error = new ProviderAdapterProtocolError({
4910 driver,
4911 detail,
4912 ...(payload === undefined ? {} : { payload }),
4913 });
4914 yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error });
4915 yield* Deferred.fail(teardownBarrier!, error).pipe(Effect.ignore);
4916 return error;
4917 });
4918 const runTransition = Effect.gen(function* () {
4919 context.interrupted = true;
4920 // Quarantine only when this run is discarded (user Stop /
4921 // hard process kill). Soft in-process restarts must keep
4922 // carryoverSubagents so a still-running subagent can
4923 // complete into the replacement turn.
4924 if (hardRestart || turnInput.requestRuntimeRestart === true) {
4925 yield* quarantineStoppedRun();
4926 }
4927 yield* cancelPendingRuntimeRequests();
4928 // Finalize before process-group kill so projection/UI cannot
4929 // lag a dead transport, and concurrent interrupt effects see
4930 // activeTurn cleared (idempotent success) rather than racing.
4931 if (
4932 (hardRestart || context.promptSettled || settledSoftInterrupt) &&
4933 !context.finalized
4934 ) {
4935 // hardRestart: always terminalize locally.
4936 // promptSettled / settledSoftInterrupt (incl. wire-settled):
4937 // native prompt already returned; only deferred background
4938 // work remains, so session/cancel has nothing to acknowledge.
4939 yield* finalizeTurn(context, "interrupted");
4940 }
4941 if (hardRestart) {
4942 if (runtime.terminateProcessGroup === undefined) {
4943 return yield* poisonTeardown(
4944 "ACP runtime does not expose its required process-group teardown; the session is poisoned",
4945 ).pipe(Effect.flatMap(Effect.fail));
4946 }
4947 const teardownExit = yield* runtime.terminateProcessGroup!.pipe(
4948 Effect.exit,
4949 );
4950 if (Exit.isFailure(teardownExit)) {
4951 return yield* poisonTeardown(
4952 "ACP runtime process-group teardown failed; the session is poisoned",
4953 Cause.squash(teardownExit.cause),
4954 ).pipe(Effect.flatMap(Effect.fail));
4955 }
4956 // Process group is gone; the next turn must spawn a
4957 // replacement even if the flavor only set hardRestart
4958 // without restartRuntimeAfterInterrupt.
4959 yield* Ref.set(runtimeRestartRequired, true);
4960 yield* Ref.set(runtimeTeardownState, { _tag: "Idle" });
4961 yield* Deferred.succeed(teardownBarrier!, undefined).pipe(Effect.ignore);
4962 } else {
4963 // Settled soft interrupt: the native prompt already
4964 // returned, so session/cancel has nothing to
4965 // acknowledge and would only threaten still-running
4966 // background subagents. Skip it and leave the runtime
4967 // untouched for the replacement turn.
4968 if (!settledSoftInterrupt) {
4969 yield* runtime.cancel;
4970 }
4971 if (restartRuntime && flavor.restartRuntimeAfterInterrupt === true) {
4972 yield* Ref.set(runtimeRestartRequired, true);
4973 }
4974 }
4975 const stopped = yield* Deferred.await(context.completed).pipe(
4976 Effect.timeoutOption("10 seconds"),
4977 );
4978 if (Option.isNone(stopped)) {
4979 return yield* new ProviderAdapterProtocolError({
4980 driver,
4981 detail: `ACP provider turn ${turnInput.providerTurnId} did not acknowledge cancellation before the interrupt timeout`,
4982 });
4983 }
4984 });
4985 if (!hardRestart) {
4986 return yield* restore(runTransition);
4987 }
4988 return yield* runTransition.pipe(
4989 Effect.catchCause((cause) =>
4990 Effect.gen(function* () {
4991 const state = yield* Ref.get(runtimeTeardownState);
4992 if (state._tag === "InProgress") {
4993 yield* poisonTeardown(
4994 "ACP hard teardown failed unexpectedly; the session is poisoned",
4995 Cause.squash(cause),
4996 );
4997 }
4998 return yield* Effect.failCause(cause);
4999 }),
5000 ),
5001 );
5002 }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1)));
5003 }),
2387 );5004 );
2388 if (Option.isNone(stopped)) {
2389 yield* Ref.update(activeTurn, (current) => (current === context ? null : current));
2390 return yield* new ProviderAdapterProtocolError({
2391 driver,
2392 detail: `ACP provider turn ${turnInput.providerTurnId} did not acknowledge cancellation before the interrupt timeout`,
2393 });
2394 }
2395 },5005 },
2396 (effect, turnInput) =>5006 (effect, turnInput) =>
2397 effect.pipe(5007 effect.pipe(
@@ -2407,28 +5017,43 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 9 unmodified lines
2407 ),5017 ),
2408 ),5018 ),
2409 respondToRuntimeRequest: (requestInput) =>5019 respondToRuntimeRequest: (requestInput) =>
2410 Effect.gen(function* () {5020 Effect.uninterruptibleMask((restore) =>
2411 const pending = (yield* Ref.get(pendingRuntimeRequests)).get(5021 Effect.gen(function* () {
2412 String(requestInput.requestId),5022 yield* restore(runtimeTransitionPermit.take(1));
2413 );5023 return yield* Effect.gen(function* () {
2414 if (pending === undefined) {5024 yield* awaitRuntimeTeardown();
2415 return yield* new ProviderAdapterProtocolError({5025 const generation = yield* Ref.get(runtimeCallbackGeneration);
2416 driver,5026 const pending = (yield* Ref.get(pendingRuntimeRequests)).get(
2417 detail: `No pending ACP runtime request ${requestInput.requestId}`,5027 String(requestInput.requestId),
2418 });5028 );
2419 }5029 if (pending === undefined || pending.generation !== generation) {
2420 if (pending.type === "user_input") {5030 return yield* new ProviderAdapterProtocolError({
2421 yield* Deferred.succeed(pending.answers, requestInput.answers ?? null);5031 driver,
2422 return;5032 detail: `No pending ACP runtime request ${requestInput.requestId}`,
2423 }5033 });
2424 if (requestInput.decision === undefined) {5034 }
2425 return yield* new ProviderAdapterProtocolError({5035 const settled =
2426 driver,5036 pending.type === "user_input"
2427 detail: `ACP approval request ${requestInput.requestId} requires a decision`,5037 ? yield* Deferred.succeed(pending.answers, requestInput.answers ?? null)
2428 });5038 : requestInput.decision === undefined
2429 }5039 ? yield* new ProviderAdapterProtocolError({
2430 yield* Deferred.succeed(pending.decision, requestInput.decision);5040 driver,
2431 }).pipe(5041 detail: `ACP approval request ${requestInput.requestId} requires a decision`,
5042 })
5043 : yield* Deferred.succeed(pending.decision, requestInput.decision);
5044 if (!settled) {
5045 return yield* new ProviderAdapterProtocolError({
5046 driver,
5047 detail: `ACP runtime request ${requestInput.requestId} was already resolved`,
5048 });
5049 }
5050 yield* awaitNativeResponseAcknowledgements([
5051 [pending.transportRequestId, pending.nativeResponseAcknowledgement],
5052 ]);
5053 yield* Deferred.await(pending.nativeResponseAcknowledgement);
5054 }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1)));
5055 }),
5056 ).pipe(
2432 Effect.mapError(5057 Effect.mapError(
2433 (cause) =>5058 (cause) =>
2434 new ProviderAdapterRuntimeRequestResponseError({5059 new ProviderAdapterRuntimeRequestResponseError({
@@ -2440,44 +5065,50 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines
2440 ),5065 ),
2441 readThreadSnapshot: Effect.fn("AcpAdapterV2.readThreadSnapshot")(5066 readThreadSnapshot: Effect.fn("AcpAdapterV2.readThreadSnapshot")(
2442 function* (snapshotInput) {5067 function* (snapshotInput) {
2443 const sessionId = nativeThreadId(driver, snapshotInput.providerThread);5068 return yield* runtimeTransitionPermit.withPermit(
2444 if ((yield* Ref.get(activeSessionId)) !== sessionId) {5069 Effect.gen(function* () {
2445 if (!capabilities.threads.canReadThreadSnapshot) {5070 yield* awaitRuntimeTeardown();
2446 return yield* new ProviderAdapterProtocolError({5071 yield* restartRuntimeAfterTeardownIfRequired();
2447 driver,5072 const sessionId = nativeThreadId(driver, snapshotInput.providerThread);
2448 detail: "ACP driver does not support session/load snapshots",5073 if ((yield* Ref.get(activeSessionId)) !== sessionId) {
2449 });5074 if (!capabilities.threads.canReadThreadSnapshot) {
2450 }5075 return yield* new ProviderAdapterProtocolError({
2451 yield* Ref.set(snapshot, {5076 driver,
2452 order: [],5077 detail: "ACP driver does not support session/load snapshots",
2453 messages: new Map(),5078 });
2454 loadingRole: null,5079 }
2455 loadingIndex: 0,5080 yield* Ref.set(snapshot, {
2456 });5081 order: [],
2457 const activated = yield* runtime.loadSession(sessionId, {5082 messages: new Map(),
2458 mcpServers: acpMcpServers(snapshotInput.providerThread.appThreadId),5083 loadingRole: null,
2459 });5084 loadingIndex: 0,
2460 yield* Ref.set(activeSessionId, activated.sessionId);5085 });
2461 yield* Ref.set(activeSessionSetup, activated);5086 const activated = yield* runtime.loadSession(sessionId, {
2462 yield* Ref.set(activeSelection, null);5087 mcpServers: acpMcpServers(snapshotInput.providerThread.appThreadId),
2463 }5088 });
2464 const state = yield* Ref.get(snapshot);5089 yield* Ref.set(activeSessionId, activated.sessionId);
2465 const now = yield* DateTime.now;5090 yield* Ref.set(activeSessionSetup, activated);
2466 return {5091 yield* Ref.set(activeSelection, null);
2467 providerThread: {5092 }
2468 ...snapshotInput.providerThread,5093 const state = yield* Ref.get(snapshot);
2469 providerSessionId: input.providerSessionId,5094 const now = yield* DateTime.now;
2470 status: "idle" as const,5095 return {
2471 updatedAt: now,5096 providerThread: {
2472 },5097 ...snapshotInput.providerThread,
2473 providerTurns: [...(yield* Ref.get(providerTurns)).values()],5098 providerSessionId: input.providerSessionId,
2474 messages: state.order.flatMap((key) => {5099 status: "idle" as const,
2475 const message = state.messages.get(key);5100 updatedAt: now,
2476 return message === undefined ? [] : [message];5101 },
5102 providerTurns: [...(yield* Ref.get(providerTurns)).values()],
5103 messages: state.order.flatMap((key) => {
5104 const message = state.messages.get(key);
5105 return message === undefined ? [] : [message];
5106 }),
5107 runtimeRequests: [],
5108 providerPayload: { protocol: ACP_PROTOCOL, sessionId },
5109 };
2477 }),5110 }),
2478 runtimeRequests: [],5111 );
2479 providerPayload: { protocol: ACP_PROTOCOL, sessionId },
2480 };
2481 },5112 },
2482 (effect, snapshotInput) =>5113 (effect, snapshotInput) =>
2483 effect.pipe(5114 effect.pipe(
@@ -2502,44 +5133,50 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 18 unmodified lines
2502 ),5133 ),
2503 forkThread: Effect.fn("AcpAdapterV2.forkThread")(5134 forkThread: Effect.fn("AcpAdapterV2.forkThread")(
2504 function* (forkInput) {5135 function* (forkInput) {
2505 if (!capabilities.threads.canForkThread) {5136 return yield* runtimeTransitionPermit.withPermit(
2506 return yield* new ProviderAdapterProtocolError({5137 Effect.gen(function* () {
2507 driver,5138 yield* awaitRuntimeTeardown();
2508 detail: "ACP driver did not negotiate session/fork",5139 yield* restartRuntimeAfterTeardownIfRequired();
2509 });5140 if (!capabilities.threads.canForkThread) {
2510 }5141 return yield* new ProviderAdapterProtocolError({
2511 if (forkInput.providerTurnId !== undefined) {5142 driver,
2512 return yield* new ProviderAdapterProtocolError({5143 detail: "ACP driver did not negotiate session/fork",
2513 driver,5144 });
2514 detail: "ACP session/fork can only fork the current session head",5145 }
2515 });5146 if (forkInput.providerTurnId !== undefined) {
2516 }5147 return yield* new ProviderAdapterProtocolError({
2517 const sourceSessionId = nativeThreadId(driver, forkInput.sourceProviderThread);5148 driver,
2518 const forked = yield* runtime.forkSession(sourceSessionId, {5149 detail: "ACP session/fork can only fork the current session head",
2519 mcpServers: acpMcpServers(forkInput.targetThreadId),5150 });
2520 });5151 }
2521 yield* Ref.set(activeSessionId, forked.sessionId);5152 const sourceSessionId = nativeThreadId(driver, forkInput.sourceProviderThread);
2522 yield* Ref.set(activeSessionSetup, forked);5153 const forked = yield* runtime.forkSession(sourceSessionId, {
2523 yield* Ref.set(activeSelection, null);5154 mcpServers: acpMcpServers(forkInput.targetThreadId),
2524 const now = yield* DateTime.now;5155 });
2525 return makeProviderThread({5156 yield* Ref.set(activeSessionId, forked.sessionId);
2526 driver,5157 yield* Ref.set(activeSessionSetup, forked);
2527 providerInstanceId: options.instanceId,5158 yield* Ref.set(activeSelection, null);
2528 idAllocator,5159 const now = yield* DateTime.now;
2529 appThreadId: forkInput.targetThreadId,5160 return makeProviderThread({
2530 providerSessionId: input.providerSessionId,5161 driver,
2531 nativeThreadId: forked.sessionId,5162 providerInstanceId: options.instanceId,
2532 ...(forkInput.ownerNodeId === undefined5163 idAllocator,
2533 ? {}5164 appThreadId: forkInput.targetThreadId,
2534 : { ownerNodeId: forkInput.ownerNodeId }),5165 providerSessionId: input.providerSessionId,
2535 forkedFrom: {5166 nativeThreadId: forked.sessionId,
2536 providerThreadId: forkInput.sourceProviderThread.id,5167 ...(forkInput.ownerNodeId === undefined
2537 ...(forkInput.providerTurnId === undefined5168 ? {}
2538 ? {}5169 : { ownerNodeId: forkInput.ownerNodeId }),
2539 : { providerTurnId: forkInput.providerTurnId }),5170 forkedFrom: {
2540 },5171 providerThreadId: forkInput.sourceProviderThread.id,
2541 now,5172 ...(forkInput.providerTurnId === undefined
2542 });5173 ? {}
5174 : { providerTurnId: forkInput.providerTurnId }),
5175 },
5176 now,
5177 });
5178 }),
5179 );
2543 },5180 },
2544 (effect, forkInput) =>5181 (effect, forkInput) =>
2545 effect.pipe(5182 effect.pipe(
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+40−0
@@ -146,6 +146,46 @@ describe("CodexAdapterV2 assistant message streaming", () => { · 145 unmodified lines
146 assert.equal((yield* Ref.get(updates)).length, 2);146 assert.equal((yield* Ref.get(updates)).length, 2);
147 }),147 }),
148 );148 );
149
150 it.effect("retains buffered text until completion updates are emitted", () =>
151 Effect.gen(function* () {
152 const updates = yield* Ref.make<ReadonlyArray<CodexAgentMessageDeltaUpdate>>([]);
153 const failNext = yield* Ref.make(true);
154 const coalescer = yield* makeCodexAgentMessageDeltaCoalescer({
155 flushIntervalMs: 50,
156 emit: (update) =>
157 Ref.getAndSet(failNext, false).pipe(
158 Effect.flatMap((shouldFail) =>
159 shouldFail
160 ? Effect.die("projection unavailable")
161 : Ref.update(updates, (current) => [...current, update]),
162 ),
163 ),
164 });
165
166 yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "turn final" });
167 const failedFlush = yield* coalescer.flushTurn("turn-1").pipe(Effect.exit);
168 assert.equal(failedFlush._tag, "Failure");
169 yield* coalescer.flushTurn("turn-1");
170
171 yield* coalescer.append({ turnId: "turn-1", itemId: "message-2", delta: "item final" });
172 yield* Ref.set(failNext, true);
173 const failedComplete = yield* coalescer
174 .complete({ turnId: "turn-1", itemId: "message-2" })
175 .pipe(Effect.exit);
176 assert.equal(failedComplete._tag, "Failure");
177 const completedText = yield* coalescer.complete({
178 turnId: "turn-1",
179 itemId: "message-2",
180 });
181
182 assert.equal(completedText, "item final");
183 assert.deepEqual(yield* Ref.get(updates), [
184 { turnId: "turn-1", itemId: "message-1", text: "turn final", completed: true },
185 { turnId: "turn-1", itemId: "message-2", text: "item final", completed: true },
186 ]);
187 }),
188 );
149});189});
150 190
151describe("CodexAdapterV2 runtime policy", () => {191describe("CodexAdapterV2 runtime policy", () => {
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+25−18
@@ -868,31 +868,37 @@ export const makeCodexAgentMessageDeltaCoalescer = Effect.fn( · 867 unmodified lines
868 }) =>868 }) =>
869 flushLock.withPermit(869 flushLock.withPermit(
870 Effect.gen(function* () {870 Effect.gen(function* () {
871 const updates = yield* Ref.modify(buffered, (current) => {871 const current = yield* Ref.get(buffered);
872 const updates: Array<CodexAgentMessageDeltaUpdate> = [];
873 for (const message of current.values()) {
874 if (!options.predicate(message) || (options.onlyDirty && !message.dirty)) {
875 continue;
876 }
877 updates.push({
878 turnId: message.turnId,
879 itemId: message.itemId,
880 text: message.text,
881 completed: options.completed,
882 });
883 }
884 const emitUpdates = Effect.forEach(updates, input.emit, { discard: true });
885 yield* options.releaseSchedule === true
886 ? emitUpdates.pipe(Effect.ensuring(Ref.set(flushScheduled, false)))
887 : emitUpdates;
888 yield* Ref.update(buffered, (current) => {
872 const next = new Map(current);889 const next = new Map(current);
873 const pending: Array<CodexAgentMessageDeltaUpdate> = [];
874 for (const [key, message] of current) {890 for (const [key, message] of current) {
875 if (!options.predicate(message) || (options.onlyDirty && !message.dirty)) {891 if (!options.predicate(message) || (options.onlyDirty && !message.dirty)) {
876 continue;892 continue;
877 }893 }
878 pending.push({
879 turnId: message.turnId,
880 itemId: message.itemId,
881 text: message.text,
882 completed: options.completed,
883 });
884 if (options.completed) {894 if (options.completed) {
885 next.delete(key);895 next.delete(key);
886 } else {896 } else {
887 next.set(key, { ...message, dirty: false });897 next.set(key, { ...message, dirty: false });
888 }898 }
889 }899 }
890 return [pending, next] as const;900 return next;
891 });901 });
892 if (options.releaseSchedule === true) {
893 yield* Ref.set(flushScheduled, false);
894 }
895 yield* Effect.forEach(updates, input.emit, { discard: true });
896 }),902 }),
897 );903 );
898 904
@@ -936,14 +942,15 @@ export const makeCodexAgentMessageDeltaCoalescer = Effect.fn( · 37 unmodified lines
936 complete: ({ turnId, itemId, finalText }) =>942 complete: ({ turnId, itemId, finalText }) =>
937 flushLock.withPermit(943 flushLock.withPermit(
938 Effect.gen(function* () {944 Effect.gen(function* () {
939 const text = yield* Ref.modify(buffered, (current) => {945 const key = codexAgentMessageBufferKey(turnId, itemId);
940 const key = codexAgentMessageBufferKey(turnId, itemId);946 const existing = (yield* Ref.get(buffered)).get(key);
941 const existing = current.get(key);947 const text = finalText && finalText.length > 0 ? finalText : (existing?.text ?? "");
948 yield* input.emit({ turnId, itemId, text, completed: true });
949 yield* Ref.update(buffered, (current) => {
942 const next = new Map(current);950 const next = new Map(current);
943 next.delete(key);951 next.delete(key);
944 return [finalText && finalText.length > 0 ? finalText : (existing?.text ?? ""), next];952 return next;
945 });953 });
946 yield* input.emit({ turnId, itemId, text, completed: true });
947 return text;954 return text;
948 }),955 }),
949 ),956 ),
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts+253−2
@@ -1,9 +1,25 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
2import type * as EffectAcpSchema from "effect-acp/schema";3import type * as EffectAcpSchema from "effect-acp/schema";
3 4
4import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";5import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";
5import { AcpProviderCapabilitiesV2, acpPermissionDisposition } from "./AcpAdapterV2.ts";6import {
6import { GrokProviderCapabilitiesV2 } from "./GrokAdapterV2.ts";7 AcpProviderCapabilitiesV2,
8 acpCompletedTurnShouldTerminalizeTool,
9 acpPermissionDisposition,
10 acpRootSessionUpdateIngestsOutput,
11 acpRootTurnCompletionDrainMs,
12 acpRootTurnHasIngestedOutput,
13 acpRootTurnIsIdle,
14 acpRootTurnSettleDebounceMs,
15 acpRootTurnShouldRearmRecoveryTimers,
16 acpSupportsImagePrompts,
17} from "./AcpAdapterV2.ts";
18import {
19 makeGrokAcpAdapterFlavor,
20 GrokProviderCapabilitiesV2,
21 type GrokAdapterV2Options,
22} from "./GrokAdapterV2.ts";
7 23
8function permissionRequest(24function permissionRequest(
9 kind: EffectAcpSchema.ToolKind,25 kind: EffectAcpSchema.ToolKind,
@@ -37,7 +53,221 @@ function runtimePolicy(input: { · 27 unmodified lines
37 });53 });
38}54}
39 55
56describe("acpRootTurnSettleDebounceMs", () => {
57 it("keeps the historical debounce constant for re-enable experiments", () => {
58 assert.equal(acpRootTurnSettleDebounceMs, 2_000);
59 });
60});
61
62describe("acpRootTurnCompletionDrainMs", () => {
63 it("gives trailing root chunks a short landing window", () => {
64 assert.equal(acpRootTurnCompletionDrainMs, 100);
65 });
66});
67
68describe("acpRootSessionUpdateIngestsOutput", () => {
69 const sessionId = "session-1";
70
71 it("ignores empty assistant chunks used as Grok keepalives", () => {
72 assert.isFalse(
73 acpRootSessionUpdateIngestsOutput({
74 sessionId,
75 update: {
76 sessionUpdate: "agent_message_chunk",
77 content: { type: "text", text: "" },
78 },
79 }),
80 );
81 });
82
83 it("accepts non-empty assistant and reasoning chunks", () => {
84 assert.isTrue(
85 acpRootSessionUpdateIngestsOutput({
86 sessionId,
87 update: {
88 sessionUpdate: "agent_message_chunk",
89 content: { type: "text", text: "hello" },
90 },
91 }),
92 );
93 assert.isTrue(
94 acpRootSessionUpdateIngestsOutput({
95 sessionId,
96 update: {
97 sessionUpdate: "agent_thought_chunk",
98 content: { type: "text", text: "thinking" },
99 },
100 }),
101 );
102 });
103
104 it("accepts tool and plan updates", () => {
105 assert.isTrue(
106 acpRootSessionUpdateIngestsOutput({
107 sessionId,
108 update: {
109 sessionUpdate: "tool_call",
110 toolCallId: "tool-1",
111 title: "Read",
112 kind: "read",
113 status: "pending",
114 },
115 }),
116 );
117 assert.isTrue(
118 acpRootSessionUpdateIngestsOutput({
119 sessionId,
120 update: {
121 sessionUpdate: "plan",
122 entries: [{ content: "Step 1", status: "pending", priority: "medium" }],
123 },
124 }),
125 );
126 });
127});
128
129describe("acpRootTurnHasIngestedOutput", () => {
130 const empty = {
131 assistant: { current: null, nextSegment: 0 },
132 reasoning: { current: null, nextSegment: 0 },
133 tools: new Map(),
134 plan: null,
135 } as const;
136
137 it("is false before any root turn items land", () => {
138 assert.isFalse(acpRootTurnHasIngestedOutput(empty));
139 });
140
141 it("is true once assistant segments have streamed", () => {
142 assert.isTrue(
143 acpRootTurnHasIngestedOutput({
144 ...empty,
145 assistant: { current: null, nextSegment: 1 },
146 }),
147 );
148 });
149});
150
151describe("acpRootTurn recovery timer re-arm", () => {
152 it("re-arms idle settle after pending clears on active turns", () => {
153 assert.isTrue(acpRootTurnShouldRearmRecoveryTimers({ finalized: false, interrupted: false }));
154 });
155
156 it("skips re-arm when the turn is already terminal", () => {
157 assert.isFalse(acpRootTurnShouldRearmRecoveryTimers({ finalized: true, interrupted: false }));
158 assert.isFalse(acpRootTurnShouldRearmRecoveryTimers({ finalized: false, interrupted: true }));
159 });
160});
161
162describe("acpRootTurnIsIdle", () => {
163 const quiet = {
164 finalized: false,
165 interrupted: false,
166 assistantStreamOpen: false,
167 reasoningStreamOpen: false,
168 hasRunningTool: false,
169 hasPendingRuntimeRequest: false,
170 hasToolHistory: false,
171 hasRunningSubagent: false,
172 hasOutput: true,
173 } as const;
174
175 it("is false while assistant text is still streaming", () => {
176 assert.isFalse(acpRootTurnIsIdle({ ...quiet, assistantStreamOpen: true }));
177 });
178
179 it("is false while a tool is running", () => {
180 assert.isFalse(acpRootTurnIsIdle({ ...quiet, hasRunningTool: true }));
181 });
182
183 it("is false after tool history (prompt RPC owns terminalization)", () => {
184 assert.isFalse(acpRootTurnIsIdle({ ...quiet, hasToolHistory: true }));
185 });
186
187 it("is false while a native subagent task is still running", () => {
188 assert.isFalse(acpRootTurnIsIdle({ ...quiet, hasRunningSubagent: true }));
189 });
190
191 it("is false when only reasoning or tools have streamed", () => {
192 assert.isFalse(acpRootTurnIsIdle({ ...quiet, hasOutput: false }));
193 });
194
195 it("is false for assistant-only quiet (preamble-before-tools must not settle)", () => {
196 assert.isFalse(acpRootTurnIsIdle(quiet));
197 });
198
199 it("is false when tools finished and root is quiet (no speculative multi-wave settle)", () => {
200 assert.isFalse(
201 acpRootTurnIsIdle({
202 ...quiet,
203 hasToolHistory: true,
204 hasRunningTool: false,
205 }),
206 );
207 });
208});
209
40describe("GrokAdapterV2 capabilities", () => {210describe("GrokAdapterV2 capabilities", () => {
211 it("wires hard Stop teardown but soft non-Stop interrupts in the constructor flavor", () => {
212 const flavor = makeGrokAcpAdapterFlavor({
213 makeRuntime: () => Effect.never,
214 } as unknown as GrokAdapterV2Options);
215
216 assert.isFalse(flavor.interruptPromptOnCancel);
217 // User Stop (requestRuntimeRestart) keeps the hard process-group kill and
218 // respawn: Grok cancel is detach-and-continue, so only a process kill
219 // stops the work.
220 assert.isTrue(flavor.restartRuntimeAfterInterrupt);
221 assert.isTrue(flavor.terminateRuntimeProcessGroupOnInterrupt);
222 // Non-Stop interrupts (steering, restart_active) reuse the process and
223 // session; the cancelled work backgrounds and the model decides its fate.
224 assert.isUndefined(flavor.restartRuntimeOnEveryInterrupt);
225 assert.isTrue(flavor.preserveRuntimeOnSettledInterrupt);
226 });
227
228 it("terminalizes only foreground tools under the actual Grok flavor", () => {
229 const flavor = makeGrokAcpAdapterFlavor({
230 makeRuntime: () => Effect.never,
231 } as unknown as GrokAdapterV2Options);
232 const foreground = {
233 toolCallId: "foreground-1",
234 title: "Terminal",
235 status: "inProgress" as const,
236 data: {
237 rawInput: { command: "true" },
238 rawOutput: { type: "Bash", exit_code: 0 },
239 },
240 };
241 const monitor = {
242 toolCallId: "monitor-1",
243 title: "Monitor",
244 status: "inProgress" as const,
245 data: {
246 rawInput: { variant: "Monitor", command: "sleep 30" },
247 rawOutput: {
248 type: "Monitor",
249 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
250 },
251 },
252 };
253 const subagent = {
254 toolCallId: "subagent-1",
255 title: "Task",
256 status: "inProgress" as const,
257 data: {
258 rawInput: {
259 description: "Inspect interrupt handling",
260 prompt: "Review the adapter.",
261 subagent_type: "generalPurpose",
262 },
263 },
264 };
265
266 assert.isTrue(acpCompletedTurnShouldTerminalizeTool(foreground, flavor));
267 assert.isFalse(acpCompletedTurnShouldTerminalizeTool(monitor, flavor));
268 assert.isFalse(acpCompletedTurnShouldTerminalizeTool(subagent, flavor));
269 });
270
41 it("keeps optional protocol features conservative until a flavor or handshake confirms them", () => {271 it("keeps optional protocol features conservative until a flavor or handshake confirms them", () => {
42 assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsModelSwitchInSession);272 assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsModelSwitchInSession);
43 assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsRuntimeModeSwitchInSession);273 assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsRuntimeModeSwitchInSession);
@@ -45,6 +275,27 @@ describe("GrokAdapterV2 capabilities", () => { · 1 unmodified line
45 assert.isFalse(AcpProviderCapabilitiesV2.tools.supportsMcpTools);275 assert.isFalse(AcpProviderCapabilitiesV2.tools.supportsMcpTools);
46 });276 });
47 277
278 it("overrides ACP image capability false so screenshot attachments can prompt", () => {
279 // Handshake alone would refuse attachments (Grok advertises image:false).
280 assert.isFalse(
281 acpSupportsImagePrompts({
282 negotiatedImage: false,
283 }),
284 );
285 // Flavor override unblocks image content blocks for Grok.
286 assert.isTrue(
287 acpSupportsImagePrompts({
288 flavorSupportsImagePrompts: true,
289 negotiatedImage: false,
290 }),
291 );
292 assert.isTrue(
293 acpSupportsImagePrompts({
294 negotiatedImage: true,
295 }),
296 );
297 });
298
48 it("declares Grok Task envelopes as native subagents", () => {299 it("declares Grok Task envelopes as native subagents", () => {
49 assert.isFalse(GrokProviderCapabilitiesV2.threads.canForkThread);300 assert.isFalse(GrokProviderCapabilitiesV2.threads.canForkThread);
50 assert.isTrue(GrokProviderCapabilitiesV2.subagents.supportsSubagents);301 assert.isTrue(GrokProviderCapabilitiesV2.subagents.supportsSubagents);
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts+75−7
@@ -21,17 +21,26 @@ import { · 20 unmodified lines
21 resolveGrokAcpBaseModelId,21 resolveGrokAcpBaseModelId,
22} from "../../provider/acp/GrokAcpSupport.ts";22} from "../../provider/acp/GrokAcpSupport.ts";
23import {23import {
24 extractXAiAcpBackgroundToolMutation,
25 extractXAiAcpSubagentEndNotice,
26 extractXAiAcpSubagentUpdate,
24 extractXAiAskUserQuestionIdentity,27 extractXAiAskUserQuestionIdentity,
25 extractXAiAskUserQuestions,28 extractXAiAskUserQuestions,
26 extractXAiAcpSubagentUpdate,29 extractXAiBackgroundTaskCompletion,
30 extractXAiKilledBackgroundTasks,
31 extractXAiMonitorTaskId,
32 isXAiPersistentMonitor,
27 makeXAiAskUserQuestionCancelledResponse,33 makeXAiAskUserQuestionCancelledResponse,
28 makeXAiAskUserQuestionResponse,34 makeXAiAskUserQuestionResponse,
35 normalizeXAiAcpToolCallState,
36 registerXAiBackgroundTaskTracking,
29 XAiAskUserQuestionRequest,37 XAiAskUserQuestionRequest,
30} from "../../provider/acp/XAiAcpExtension.ts";38} from "../../provider/acp/XAiAcpExtension.ts";
31import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";39import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
32import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";40import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
33import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts";41import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts";
34import { IdAllocatorV2 } from "../IdAllocator.ts";42import { IdAllocatorV2 } from "../IdAllocator.ts";
43import { ProviderContinuationRequests } from "../ProviderContinuationRequests.ts";
35import { ProviderAdapterV2 } from "../ProviderAdapter.ts";44import { ProviderAdapterV2 } from "../ProviderAdapter.ts";
36import {45import {
37 ProviderAdapterDriverCreateError,46 ProviderAdapterDriverCreateError,
@@ -41,6 +50,7 @@ import { · 3 unmodified lines
41import {50import {
42 AcpProviderCapabilitiesV2,51 AcpProviderCapabilitiesV2,
43 makeAcpAdapterV2,52 makeAcpAdapterV2,
53 type AcpAdapterV2ExtensionContext,
44 type AcpAdapterV2Flavor,54 type AcpAdapterV2Flavor,
45 type AcpAdapterV2RuntimeInput,55 type AcpAdapterV2RuntimeInput,
46} from "./AcpAdapterV2.ts";56} from "./AcpAdapterV2.ts";
@@ -88,6 +98,7 @@ export interface GrokAdapterV2Options { · 41 unmodified lines
88 readonly idAllocator: IdAllocatorV2["Service"];98 readonly idAllocator: IdAllocatorV2["Service"];
89 readonly serverConfig: ServerConfig["Service"];99 readonly serverConfig: ServerConfig["Service"];
90 readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"];100 readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"];
101 readonly continuationRequests?: Parameters<typeof makeAcpAdapterV2>[0]["continuationRequests"];
91 readonly makeRuntime?: (102 readonly makeRuntime?: (
92 input: AcpAdapterV2RuntimeInput,103 input: AcpAdapterV2RuntimeInput,
93 ) => Effect.Effect<104 ) => Effect.Effect<
@@ -101,7 +112,16 @@ export interface GrokAdapterV2Options { · 7 unmodified lines
101export const registerGrokAcpExtensions: NonNullable<AcpAdapterV2Flavor["registerExtensions"]> = ({112export const registerGrokAcpExtensions: NonNullable<AcpAdapterV2Flavor["registerExtensions"]> = ({
102 runtime,113 runtime,
103 requestUserInput,114 requestUserInput,
115 applyBackgroundTaskMutation,
104}) =>116}) =>
117 registerXAiBackgroundTaskTracking(runtime, applyBackgroundTaskMutation).pipe(
118 Effect.andThen(registerGrokAskUserQuestionExtensions({ runtime, requestUserInput })),
119 );
120
121const registerGrokAskUserQuestionExtensions = ({
122 runtime,
123 requestUserInput,
124}: Pick<AcpAdapterV2ExtensionContext, "runtime" | "requestUserInput">) =>
105 Effect.forEach(125 Effect.forEach(
106 ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const,126 ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const,
107 (method) =>127 (method) =>
@@ -115,37 +135,78 @@ export const registerGrokAcpExtensions: NonNullable<AcpAdapterV2Flavor["register · 7 unmodified lines
115 }));135 }));
116 return requestUserInput({136 return requestUserInput({
117 nativeItemId: `${identity.sessionId}:xai-question:${identity.toolCallId}`,137 nativeItemId: `${identity.sessionId}:xai-question:${identity.toolCallId}`,
138 nativeMethod: method,
118 nativeRequestId: identity.toolCallId,139 nativeRequestId: identity.toolCallId,
140 nativeSessionId: identity.sessionId,
119 questions,141 questions,
120 }).pipe(142 }).pipe(
121 Effect.map((answers) =>143 Effect.flatMap(({ acknowledgeNativeResponse, answers }) =>
122 answers === null144 Effect.succeed(
123 ? makeXAiAskUserQuestionCancelledResponse()145 answers === null
124 : makeXAiAskUserQuestionResponse(params, answers),146 ? makeXAiAskUserQuestionCancelledResponse()
147 : makeXAiAskUserQuestionResponse(params, answers),
148 ).pipe(Effect.tap(() => acknowledgeNativeResponse)),
125 ),149 ),
126 );150 );
127 }),151 }),
128 { discard: true },152 { discard: true },
129 );153 );
130 154
131export function makeGrokAdapterV2(options: GrokAdapterV2Options) {155export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdapterV2Flavor {
132 const flavor: AcpAdapterV2Flavor = {156 return {
133 driver: GROK_PROVIDER,157 driver: GROK_PROVIDER,
134 capabilities: GrokProviderCapabilitiesV2,158 capabilities: GrokProviderCapabilitiesV2,
159 // Idle settle over-settled preamble-before-tools turns and cancelled the
160 // prompt while Grok continued, freezing T3 projection mid-turn.
161 settleRootTurnWhenIdle: false,
162 interruptPromptOnCancel: false,
163 // User Stop (requestRuntimeRestart) still hard-kills the process group
164 // and respawns: Grok `session/cancel` is detach-and-continue (the CLI
165 // re-runs cancelled foreground work as a background task no client RPC
166 // can kill, E3 harness 2026-07-18), so only a process kill truly stops
167 // work. Non-Stop interrupts (mid-prompt steering, restart_active) omit
168 // requestRuntimeRestart and stay soft: the session survives in the same
169 // process, cancelled work backgrounds, and the model itself decides via
170 // kill_command_or_subagent whether it keeps running.
171 restartRuntimeAfterInterrupt: true,
172 terminateRuntimeProcessGroupOnInterrupt: true,
173 // Steering restarts on a settled turn additionally skip session/cancel so
174 // fire-and-forget subagents survive the steer (E1 harness confirmed the
175 // Grok CLI accepts a concurrent session/prompt in that state).
176 preserveRuntimeOnSettledInterrupt: true,
177 // Grok ACP initialize reports promptCapabilities.image:false but the agent
178 // still accepts image content blocks (verified with real screenshots).
179 supportsImagePrompts: true,
135 resolveModelId: (selection) => resolveGrokAcpBaseModelId(selection.model),180 resolveModelId: (selection) => resolveGrokAcpBaseModelId(selection.model),
136 makeRuntime:181 makeRuntime:
137 options.makeRuntime ??182 options.makeRuntime ??
138 ((input) =>183 ((input) =>
139 makeGrokAcpRuntime({184 makeGrokAcpRuntime({
140 ...input,185 ...input,
186 interruptPromptOnCancel: input.interruptPromptOnCancel ?? false,
141 grokSettings: options.settings,187 grokSettings: options.settings,
142 environment: options.environment,188 environment: options.environment,
143 childProcessSpawner: options.childProcessSpawner,189 childProcessSpawner: options.childProcessSpawner,
144 })),190 })),
145 registerExtensions: registerGrokAcpExtensions,191 registerExtensions: registerGrokAcpExtensions,
146 extractSubagentUpdate: extractXAiAcpSubagentUpdate,192 extractSubagentUpdate: extractXAiAcpSubagentUpdate,
193 extractSubagentEndNotice: extractXAiAcpSubagentEndNotice,
194 normalizeToolCall: normalizeXAiAcpToolCallState,
195 extractBackgroundTaskId: extractXAiMonitorTaskId,
196 extractBackgroundToolMutation: extractXAiAcpBackgroundToolMutation,
197 extractBackgroundTaskCompletion: (toolCall) => [
198 ...extractXAiBackgroundTaskCompletion(toolCall),
199 ...extractXAiKilledBackgroundTasks(toolCall),
200 ],
201 isPersistentBackgroundTool: isXAiPersistentMonitor,
202 deferFinalizeForBackgroundWork: true,
203 enablePostSettleContinuation: true,
147 ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }),204 ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }),
148 };205 };
206}
207
208export function makeGrokAdapterV2(options: GrokAdapterV2Options) {
209 const flavor = makeGrokAcpAdapterFlavor(options);
149 return makeAcpAdapterV2({210 return makeAcpAdapterV2({
150 instanceId: options.instanceId,211 instanceId: options.instanceId,
151 flavor,212 flavor,
@@ -153,6 +214,9 @@ export function makeGrokAdapterV2(options: GrokAdapterV2Options) { · 1 unmodified line
153 idAllocator: options.idAllocator,214 idAllocator: options.idAllocator,
154 serverConfig: options.serverConfig,215 serverConfig: options.serverConfig,
155 ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }),216 ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }),
217 ...(options.continuationRequests === undefined
218 ? {}
219 : { continuationRequests: options.continuationRequests }),
156 });220 });
157}221}
158 222
@@ -176,6 +240,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapte · 17 unmodified lines
176 const idAllocator = yield* IdAllocatorV2;240 const idAllocator = yield* IdAllocatorV2;
177 const providerEventLoggers = yield* ProviderEventLoggers;241 const providerEventLoggers = yield* ProviderEventLoggers;
178 const serverConfig = yield* ServerConfig;242 const serverConfig = yield* ServerConfig;
243 const continuationRequests = yield* ProviderContinuationRequests;
179 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();244 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
180 return makeGrokAdapterV2({245 return makeGrokAdapterV2({
181 instanceId: input.instanceId,246 instanceId: input.instanceId,
@@ -185,6 +250,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapte · 3 unmodified lines
185 fileSystem,250 fileSystem,
186 idAllocator,251 idAllocator,
187 serverConfig,252 serverConfig,
253 continuationRequests,
188 nativeLogging: (threadId) =>254 nativeLogging: (threadId) =>
189 makeNativeLogger({255 makeNativeLogger({
190 nativeEventLogger: providerEventLoggers.native,256 nativeEventLogger: providerEventLoggers.native,
@@ -226,6 +292,7 @@ export const layer: Layer.Layer< · 35 unmodified lines
226 const idAllocator = yield* IdAllocatorV2;292 const idAllocator = yield* IdAllocatorV2;
227 const providerEventLoggers = yield* ProviderEventLoggers;293 const providerEventLoggers = yield* ProviderEventLoggers;
228 const serverConfig = yield* ServerConfig;294 const serverConfig = yield* ServerConfig;
295 const continuationRequests = yield* ProviderContinuationRequests;
229 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();296 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
230 return makeGrokAdapterV2({297 return makeGrokAdapterV2({
231 instanceId: GROK_DEFAULT_INSTANCE_ID,298 instanceId: GROK_DEFAULT_INSTANCE_ID,
@@ -235,6 +302,7 @@ export const layer: Layer.Layer< · 3 unmodified lines
235 fileSystem,302 fileSystem,
236 idAllocator,303 idAllocator,
237 serverConfig,304 serverConfig,
305 continuationRequests,
238 nativeLogging: (threadId) =>306 nativeLogging: (threadId) =>
239 makeNativeLogger({307 makeNativeLogger({
240 nativeEventLogger: providerEventLoggers.native,308 nativeEventLogger: providerEventLoggers.native,
apps/server/src/orchestration-v2/EffectWorker.test.ts+39−1
@@ -17,7 +17,11 @@ import * as Ref from "effect/Ref"; · 16 unmodified lines
17 17
18import { CheckpointRollbackServiceV2 } from "./CheckpointRollbackService.ts";18import { CheckpointRollbackServiceV2 } from "./CheckpointRollbackService.ts";
19import type { OrchestrationEffectV2 } from "./EffectOutbox.ts";19import type { OrchestrationEffectV2 } from "./EffectOutbox.ts";
20import { executorLayer, OrchestrationEffectExecutorV2 } from "./EffectWorker.ts";20import {
21 executorLayer,
22 isNonRetryableProviderTurnControlFailure,
23 OrchestrationEffectExecutorV2,
24} from "./EffectWorker.ts";
21import { RunFinalizationService } from "./RunFinalizationService.ts";25import { RunFinalizationService } from "./RunFinalizationService.ts";
22import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts";26import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts";
23import { ProviderTurnControlServiceV2 } from "./ProviderTurnControlService.ts";27import { ProviderTurnControlServiceV2 } from "./ProviderTurnControlService.ts";
@@ -133,6 +137,40 @@ function makeExecutorLayer(input: { · 109 unmodified lines
133 return executorLayer.pipe(Layer.provide(dependencies));137 return executorLayer.pipe(Layer.provide(dependencies));
134}138}
135 139
140it("does not retry pure interrupt races where the turn is already gone", () => {
141 assert.isTrue(
142 isNonRetryableProviderTurnControlFailure(
143 "provider-turn.interrupt",
144 "ProviderAdapterInterruptError: ... ACP provider turn provider-turn:x is not active",
145 ),
146 );
147 assert.isTrue(
148 isNonRetryableProviderTurnControlFailure(
149 "provider-turn.interrupt",
150 "Provider session provider-session:x is not active.",
151 ),
152 );
153 // Restart is compound (interrupt + detach + start). Do not swallow start failures.
154 assert.isFalse(
155 isNonRetryableProviderTurnControlFailure(
156 "provider-turn.restart",
157 "Provider session provider-session:x is not active.",
158 ),
159 );
160 assert.isFalse(
161 isNonRetryableProviderTurnControlFailure(
162 "provider-turn.start",
163 "Provider session provider-session:x is not active.",
164 ),
165 );
166 assert.isFalse(
167 isNonRetryableProviderTurnControlFailure(
168 "provider-turn.interrupt",
169 "ACP hard teardown failed unexpectedly; the session is poisoned",
170 ),
171 );
172});
173
136it.effect("detaches a handed-off session only after the old turn terminalizes", () =>174it.effect("detaches a handed-off session only after the old turn terminalizes", () =>
137 Effect.gen(function* () {175 Effect.gen(function* () {
138 const now = yield* DateTime.now;176 const now = yield* DateTime.now;
apps/server/src/orchestration-v2/EffectWorker.ts+31−2
@@ -25,6 +25,30 @@ export class OrchestrationEffectExecutionError extends Schema.TaggedErrorClass<O · 24 unmodified lines
25 },25 },
26) {}26) {}
27 27
28/**
29 * Pure interrupt races with hard process teardown or a dead session produce
30 * "not active" protocol errors. Retrying those only delays recovery.
31 *
32 * Do not apply this to `provider-turn.restart`: that compound effect also runs
33 * detach and start. Swallowing a start failure that happens to mention
34 * "is not active" would drop the outbox item without ever starting the
35 * replacement turn.
36 */
37export function isNonRetryableProviderTurnControlFailure(
38 effectType: string,
39 errorText: string,
40): boolean {
41 if (effectType !== "provider-turn.interrupt") {
42 return false;
43 }
44 return (
45 /is not active/i.test(errorText) ||
46 /hard teardown is already in progress/i.test(errorText) ||
47 /treating as already interrupted/i.test(errorText) ||
48 /treating as already stopped/i.test(errorText)
49 );
50}
51
28export interface OrchestrationEffectExecutorV2Shape {52export interface OrchestrationEffectExecutorV2Shape {
29 readonly execute: (53 readonly execute: (
30 effect: OrchestrationEffectV2,54 effect: OrchestrationEffectV2,
@@ -348,14 +372,19 @@ export const layerWithOptions = ( · 317 unmodified lines
348 }372 }
349 373
350 const error = Cause.pretty(exit.cause);374 const error = Cause.pretty(exit.cause);
375 const nonRetryable = isNonRetryableProviderTurnControlFailure(effect.request.type, error);
351 yield* Effect.logWarning("Orchestration effect execution failed", {376 yield* Effect.logWarning("Orchestration effect execution failed", {
352 effectId: effect.id,377 effectId: effect.id,
353 effectType: effect.request.type,378 effectType: effect.request.type,
354 attemptCount: effect.attemptCount,379 attemptCount: effect.attemptCount,
380 nonRetryable,
355 error,381 error,
356 });382 });
357 const updated =383 // Prefer succeed for terminal interrupt races so the outbox does not
358 effect.attemptCount >= maxAttempts384 // keep a failed interrupt around; fail only when we must not retry.
385 const updated = nonRetryable
386 ? yield* outbox.succeed({ effectId: effect.id, workerId })
387 : effect.attemptCount >= maxAttempts
359 ? yield* outbox.fail({ effectId: effect.id, workerId, error })388 ? yield* outbox.fail({ effectId: effect.id, workerId, error })
360 : yield* outbox.retry({389 : yield* outbox.retry({
361 effectId: effect.id,390 effectId: effect.id,
apps/server/src/orchestration-v2/ProjectionStore.ts+2−0
@@ -573,6 +573,7 @@ function activeLocalTurnItems( · 572 unmodified lines
573 item,573 item,
574 runs: projection.runs,574 runs: projection.runs,
575 attempts: projection.attempts,575 attempts: projection.attempts,
576 items: projection.turnItems,
576 }),577 }),
577 )578 )
578 .map((item, position) => ({579 .map((item, position) => ({
@@ -668,6 +669,7 @@ function visibleTurnItemsThroughRun(input: { · 89 unmodified lines
668 isOrchestrationV2SupersededInterrupt({669 isOrchestrationV2SupersededInterrupt({
669 item,670 item,
670 attempts: input.sourceProjection.attempts,671 attempts: input.sourceProjection.attempts,
672 items: input.sourceProjection.turnItems,
671 })673 })
672 ) {674 ) {
673 return false;675 return false;
apps/server/src/orchestration-v2/ProviderAdapter.ts+2−0
@@ -407,6 +407,8 @@ export interface ProviderAdapterV2SteerInput { · 406 unmodified lines
407export interface ProviderAdapterV2InterruptInput {407export interface ProviderAdapterV2InterruptInput {
408 readonly providerThread: OrchestrationV2ProviderThread;408 readonly providerThread: OrchestrationV2ProviderThread;
409 readonly providerTurnId: ProviderTurnId;409 readonly providerTurnId: ProviderTurnId;
410 /** When true, the next `startTurn` may respawn the provider runtime (Grok Stop recovery). */
411 readonly requestRuntimeRestart?: boolean;
410}412}
411 413
412export interface ProviderAdapterV2RuntimeRequestResponseInput {414export interface ProviderAdapterV2RuntimeRequestResponseInput {
apps/server/src/orchestration-v2/ProviderContinuationRequests.ts+4−0
@@ -2,6 +2,7 @@ import { ProviderDriverKind, ProviderThreadId, ThreadId } from "@t3tools/contrac · 1 unmodified line
2import * as Context from "effect/Context";2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
5import * as Queue from "effect/Queue";6import * as Queue from "effect/Queue";
6 7
7export interface ProviderContinuationRequest {8export interface ProviderContinuationRequest {
@@ -9,6 +10,9 @@ export interface ProviderContinuationRequest { · 1 unmodified line
9 readonly providerThreadId: ProviderThreadId;10 readonly providerThreadId: ProviderThreadId;
10 readonly driver: ProviderDriverKind;11 readonly driver: ProviderDriverKind;
11 readonly detail: string | null;12 readonly detail: string | null;
13 readonly dispatchIfCurrent?: <A, E, R>(
14 effect: Effect.Effect<A, E, R>,
15 ) => Effect.Effect<Option.Option<A>, E, R>;
12}16}
13 17
14/**18/**
apps/server/src/orchestration-v2/ProviderContinuationService.test.ts+193−0
@@ -0,0 +1,193 @@
1import { assert, describe, it } from "@effect/vitest";
2import {
3 ProviderDriverKind,
4 ProviderThreadId,
5 ThreadId,
6 type OrchestrationV2ThreadProjection,
7} from "@t3tools/contracts";
8import * as Deferred from "effect/Deferred";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Queue from "effect/Queue";
13import * as Ref from "effect/Ref";
14import * as Semaphore from "effect/Semaphore";
15
16import { layer as idAllocatorLayer } from "./IdAllocator.ts";
17import {
18 type ProviderContinuationRequest,
19 ProviderContinuationRequests,
20 layer as continuationRequestsLayer,
21} from "./ProviderContinuationRequests.ts";
22import { workerLive } from "./ProviderContinuationService.ts";
23import { ThreadManagementService } from "./ThreadManagementService.ts";
24
25const threadId = ThreadId.make("thread-provider-continuation");
26const providerThreadId = ProviderThreadId.make("provider-thread-continuation");
27const driver = ProviderDriverKind.make("continuation-test");
28const projection = {
29 thread: { archivedAt: null },
30 messages: [],
31} as unknown as OrchestrationV2ThreadProjection;
32
33const request = (
34 dispatchIfCurrent?: ProviderContinuationRequest["dispatchIfCurrent"],
35 detail: string | null = null,
36): ProviderContinuationRequest => ({
37 threadId,
38 providerThreadId,
39 driver,
40 detail,
41 ...(dispatchIfCurrent === undefined ? {} : { dispatchIfCurrent }),
42});
43
44const makeGuard = Effect.fnUntraced(function* (completed?: Deferred.Deferred<void>) {
45 const generation = yield* Ref.make(0);
46 const permit = yield* Semaphore.make(1);
47 const capture = Effect.gen(function* () {
48 const captured = yield* Ref.updateAndGet(generation, (value) => value + 1);
49 return <A, E, R>(effect: Effect.Effect<A, E, R>) =>
50 permit
51 .withPermit(
52 Effect.gen(function* () {
53 if ((yield* Ref.get(generation)) !== captured) return Option.none();
54 return Option.some(yield* effect);
55 }),
56 )
57 .pipe(
58 completed === undefined
59 ? (effect) => effect
60 : Effect.ensuring(Deferred.succeed(completed, undefined)),
61 );
62 });
63 return {
64 capture,
65 invalidate: permit.withPermit(Ref.update(generation, (value) => value + 1)),
66 };
67});
68
69function testLayer(input: {
70 readonly dispatched: Queue.Queue<unknown>;
71 readonly getThreadProjection: () => Effect.Effect<OrchestrationV2ThreadProjection>;
72}) {
73 const threads = Layer.mock(ThreadManagementService)({
74 getThreadProjection: input.getThreadProjection,
75 dispatch: (command) => Queue.offer(input.dispatched, command).pipe(Effect.as({} as never)),
76 });
77 const worker = workerLive.pipe(
78 Layer.provide(Layer.mergeAll(idAllocatorLayer, continuationRequestsLayer, threads)),
79 );
80 return Layer.merge(continuationRequestsLayer, worker);
81}
82
83describe("ProviderContinuationService", () => {
84 it.effect("dispatches a current request exactly once", () => {
85 return Effect.gen(function* () {
86 const dispatched = yield* Queue.unbounded<unknown>();
87 yield* Effect.gen(function* () {
88 const requests = yield* ProviderContinuationRequests;
89 yield* requests.offer(request());
90 yield* Queue.take(dispatched);
91 yield* Effect.yieldNow;
92 assert.isTrue(Option.isNone(yield* Queue.poll(dispatched)));
93 }).pipe(
94 Effect.provide(
95 testLayer({ dispatched, getThreadProjection: () => Effect.succeed(projection) }),
96 ),
97 Effect.scoped,
98 );
99 });
100 });
101
102 it.effect("drops a request invalidated before dispatch", () => {
103 return Effect.gen(function* () {
104 const dispatched = yield* Queue.unbounded<unknown>();
105 yield* Effect.gen(function* () {
106 const requests = yield* ProviderContinuationRequests;
107 const guard = yield* makeGuard();
108 const dispatchIfCurrent = yield* guard.capture;
109 yield* guard.invalidate;
110 yield* requests.offer(request(dispatchIfCurrent));
111 yield* Effect.yieldNow;
112 yield* Effect.yieldNow;
113 assert.isTrue(Option.isNone(yield* Queue.poll(dispatched)));
114 }).pipe(
115 Effect.provide(
116 testLayer({ dispatched, getThreadProjection: () => Effect.succeed(projection) }),
117 ),
118 Effect.scoped,
119 );
120 });
121 });
122
123 it.effect("drops a request invalidated while projection is blocked", () => {
124 return Effect.gen(function* () {
125 const dispatched = yield* Queue.unbounded<unknown>();
126 const projectionEntered = yield* Deferred.make<void>();
127 const releaseProjection = yield* Deferred.make<void>();
128 const guardCompleted = yield* Deferred.make<void>();
129 yield* Effect.gen(function* () {
130 const requests = yield* ProviderContinuationRequests;
131 const guard = yield* makeGuard(guardCompleted);
132 const dispatchIfCurrent = yield* guard.capture;
133 yield* requests.offer(request(dispatchIfCurrent));
134 yield* Deferred.await(projectionEntered);
135 yield* guard.invalidate;
136 yield* Deferred.succeed(releaseProjection, undefined);
137 yield* Deferred.await(guardCompleted);
138 assert.isTrue(Option.isNone(yield* Queue.poll(dispatched)));
139 }).pipe(
140 Effect.provide(
141 testLayer({
142 dispatched,
143 getThreadProjection: () =>
144 Deferred.succeed(projectionEntered, undefined).pipe(
145 Effect.andThen(Deferred.await(releaseProjection)),
146 Effect.as(projection),
147 ),
148 }),
149 ),
150 Effect.scoped,
151 );
152 });
153 });
154
155 it.effect("does not revive an old request when a later generation is current", () => {
156 return Effect.gen(function* () {
157 const dispatched = yield* Queue.unbounded<unknown>();
158 const firstProjectionEntered = yield* Deferred.make<void>();
159 const releaseFirstProjection = yield* Deferred.make<void>();
160 let projectionCalls = 0;
161 yield* Effect.gen(function* () {
162 const requests = yield* ProviderContinuationRequests;
163 const guard = yield* makeGuard();
164 const first = yield* guard.capture;
165 yield* requests.offer(request(first, "A"));
166 yield* Deferred.await(firstProjectionEntered);
167 const second = yield* guard.capture;
168 yield* requests.offer(request(second, "B"));
169 yield* Deferred.succeed(releaseFirstProjection, undefined);
170 const command = yield* Queue.take(dispatched);
171 assert.equal((command as { readonly text?: unknown }).text, "B");
172 yield* Effect.yieldNow;
173 assert.isTrue(Option.isNone(yield* Queue.poll(dispatched)));
174 }).pipe(
175 Effect.provide(
176 testLayer({
177 dispatched,
178 getThreadProjection: () => {
179 projectionCalls += 1;
180 return projectionCalls === 1
181 ? Deferred.succeed(firstProjectionEntered, undefined).pipe(
182 Effect.andThen(Deferred.await(releaseFirstProjection)),
183 Effect.as(projection),
184 )
185 : Effect.succeed(projection);
186 },
187 }),
188 ),
189 Effect.scoped,
190 );
191 });
192 });
193});
apps/server/src/orchestration-v2/ProviderContinuationService.ts+12−1
@@ -32,14 +32,20 @@ export const workerLive = Layer.effectDiscard( · 31 unmodified lines
32 threadId: request.threadId,32 threadId: request.threadId,
33 providerThreadId: request.providerThreadId,33 providerThreadId: request.providerThreadId,
34 });34 });
35 // Still run the guard so adapters can clear sticky continuationRequested.
36 if (request.dispatchIfCurrent !== undefined) {
37 yield* request.dispatchIfCurrent(Effect.void);
38 }
35 return;39 return;
36 }40 }
41 // The ordinal is display metadata only: allocate.message appends a
42 // random UUID, so a stale projection read here cannot collide ids.
37 const messageId = yield* ids.allocate.message({43 const messageId = yield* ids.allocate.message({
38 threadId: request.threadId,44 threadId: request.threadId,
39 ordinal: projection.messages.length + 1,45 ordinal: projection.messages.length + 1,
40 });46 });
41 const commandId = CommandId.make(`provider-continuation:${messageId}`);47 const commandId = CommandId.make(`provider-continuation:${messageId}`);
42 yield* threads.dispatch({48 const dispatch = threads.dispatch({
43 type: "message.dispatch",49 type: "message.dispatch",
44 commandId,50 commandId,
45 threadId: request.threadId,51 threadId: request.threadId,
@@ -50,6 +56,11 @@ export const workerLive = Layer.effectDiscard( · 4 unmodified lines
50 createdBy: "agent",56 createdBy: "agent",
51 creationSource: "provider",57 creationSource: "provider",
52 });58 });
59 if (request.dispatchIfCurrent === undefined) {
60 yield* dispatch;
61 return;
62 }
63 yield* request.dispatchIfCurrent(dispatch);
53 },64 },
54 );65 );
55 66
apps/server/src/orchestration-v2/ProviderSessionManager.test.ts+93−0
@@ -1233,6 +1233,99 @@ it.effect( · 1232 unmodified lines
1233 }),1233 }),
1234);1234);
1235 1235
1236it.effect("ProviderSessionManagerV2 does not apply a stale idle pin to a replacement session", () =>
1237 Effect.gen(function* () {
1238 const state = yield* Ref.make(emptyState);
1239 const firstCheck = yield* Ref.make(true);
1240 const checkEntered = yield* Deferred.make<void>();
1241 const checkGate = yield* Deferred.make<void>();
1242 const effect = Effect.gen(function* () {
1243 const eventSink = yield* EventSinkV2;
1244 const idAllocator = yield* IdAllocatorV2;
1245 const manager = yield* ProviderSessionManagerV2;
1246 const now = yield* DateTime.now;
1247 const threadId = yield* idAllocator.allocate.thread({
1248 fixtureName: "provider-session-manager-stale-pin",
1249 projectId: yield* idAllocator.allocate.project({
1250 fixtureName: "provider-session-manager-stale-pin",
1251 }),
1252 });
1253 const providerSessionId = yield* idAllocator.allocate.providerSession({
1254 providerInstanceId: modelSelection.instanceId,
1255 threadId,
1256 });
1257
1258 yield* eventSink.write({
1259 events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })],
1260 });
1261 yield* manager.open({
1262 threadId,
1263 providerSessionId,
1264 modelSelection,
1265 runtimePolicy,
1266 });
1267
1268 // Park the first idle fiber inside an uninterruptible pending-work probe.
1269 yield* TestClock.adjust("1 second");
1270 yield* Deferred.await(checkEntered);
1271
1272 // close removes the map entry first, then waits to interrupt the idle
1273 // fiber (still uninterruptible). That window lets a replacement open
1274 // under the same providerSessionId before the stale probe finishes.
1275 const closeFiber = yield* manager.close(providerSessionId).pipe(Effect.forkDetach);
1276 for (let i = 0; i < 20; i += 1) {
1277 yield* Effect.yieldNow;
1278 }
1279 yield* manager.open({
1280 threadId,
1281 providerSessionId,
1282 modelSelection,
1283 runtimePolicy,
1284 });
1285 assert.equal((yield* Ref.get(state)).openCount, 2);
1286
1287 // Stale probe reports pending work against the old runtime; the pin
1288 // stamp must no-op on the replacement (runtime / generation mismatch).
1289 yield* Deferred.succeed(checkGate, undefined);
1290 yield* Fiber.join(closeFiber);
1291 for (let i = 0; i < 10; i += 1) {
1292 yield* Effect.yieldNow;
1293 }
1294
1295 assert.isTrue(Option.isSome(yield* manager.get(providerSessionId)));
1296 assert.equal((yield* Ref.get(state)).closeCount, 1);
1297
1298 // Replacement has no pending background work. After one idle window it
1299 // must release. A stale pin stamp would have deferred release until
1300 // maxIdlePinMs.
1301 yield* TestClock.adjust("1 second");
1302 yield* Effect.yieldNow;
1303 assert.isTrue(Option.isNone(yield* manager.get(providerSessionId)));
1304 assert.equal((yield* Ref.get(state)).closeCount, 2);
1305 });
1306
1307 yield* effect.pipe(
1308 Effect.provide(
1309 makeTestLayer({
1310 state,
1311 idleTimeoutMs: 1000,
1312 maxIdlePinMs: 60_000,
1313 hasPendingBackgroundWork: Effect.uninterruptible(
1314 Effect.gen(function* () {
1315 if (yield* Ref.getAndSet(firstCheck, false)) {
1316 yield* Deferred.succeed(checkEntered, undefined);
1317 yield* Deferred.await(checkGate);
1318 return true;
1319 }
1320 return false;
1321 }),
1322 ),
1323 }),
1324 ),
1325 );
1326 }),
1327);
1328
1236it.effect(1329it.effect(
1237 "ProviderSessionManagerV2 keeps active sessions alive until the provider turn terminates",1330 "ProviderSessionManagerV2 keeps active sessions alive until the provider turn terminates",
1238 () =>1331 () =>
apps/server/src/orchestration-v2/ProviderSessionManager.ts+28−12
@@ -605,31 +605,47 @@ export const layerWithOptions = ( · 604 unmodified lines
605 ) {605 ) {
606 return;606 return;
607 }607 }
608 // Capture runtime identity before yielding: a replacement session
609 // can reuse the same providerSessionId while this fiber is parked.
610 const probedRuntime = entry.runtime;
608 const hasPendingWork =611 const hasPendingWork =
609 entry.runtime.hasPendingBackgroundWork === undefined612 probedRuntime.hasPendingBackgroundWork === undefined
610 ? false613 ? false
611 : yield* entry.runtime.hasPendingBackgroundWork.pipe(614 : yield* probedRuntime.hasPendingBackgroundWork.pipe(
612 Effect.catchCause(() => Effect.succeed(false)),615 Effect.catchCause(() => Effect.succeed(false)),
613 );616 );
614 if (hasPendingWork) {617 if (hasPendingWork) {
615 const now = yield* Clock.currentTimeMillis;618 const now = yield* Clock.currentTimeMillis;
616 const pinnedSinceMs = entry.pinnedSinceMs ?? now;619 const pinnedSinceMs = entry.pinnedSinceMs ?? now;
617 if (now - pinnedSinceMs < maxIdlePinMs) {620 if (now - pinnedSinceMs < maxIdlePinMs) {
618 yield* Effect.logInfo("orchestration-v2.driver-session.idle-release-deferred", {621 const shouldContinuePin = yield* Ref.modify(sessions, (latest) => {
619 providerSessionId: input.providerSessionId,
620 pinnedForMs: now - pinnedSinceMs,
621 });
622 yield* Ref.update(sessions, (latest) => {
623 const latestEntry = latest.get(key);622 const latestEntry = latest.get(key);
624 if (latestEntry === undefined || latestEntry.busyCount > 0) {623 if (
625 return latest;624 latestEntry === undefined ||
625 latestEntry.busyCount > 0 ||
626 latestEntry.idleGeneration !== input.generation ||
627 latestEntry.runtime !== probedRuntime
628 ) {
629 return [false, latest] as const;
626 }630 }
627 const updated = new Map(latest);631 const updated = new Map(latest);
628 updated.set(key, { ...latestEntry, pinnedSinceMs });632 updated.set(key, { ...latestEntry, pinnedSinceMs });
629 return updated;633 return [true, updated] as const;
630 });634 });
631 yield* scheduleIdleReleaseInternal(input.providerSessionId);635 if (!shouldContinuePin) {
632 return;636 // Generation or runtime advanced while we probed pending work;
637 // the current owner of the entry owns idle release.
638 return;
639 }
640 yield* Effect.logInfo("orchestration-v2.driver-session.idle-release-deferred", {
641 providerSessionId: input.providerSessionId,
642 pinnedForMs: now - pinnedSinceMs,
643 });
644 // Re-check on this fiber after another idle window. Do not call
645 // scheduleIdleReleaseInternal: that cancels entry.idleFiber, which
646 // is this fiber, and can self-deadlock on Fiber.interrupt.
647 yield* Effect.sleep(Duration.millis(idleTimeoutMs));
648 return yield* releaseIfStillIdle(input);
633 }649 }
634 yield* Effect.logWarning("orchestration-v2.driver-session.idle-release-pin-expired", {650 yield* Effect.logWarning("orchestration-v2.driver-session.idle-release-pin-expired", {
635 providerSessionId: input.providerSessionId,651 providerSessionId: input.providerSessionId,
apps/server/src/orchestration-v2/ProviderTurnControlService.ts+43−5
@@ -129,6 +129,28 @@ export const layer: Layer.Layer< · 128 unmodified lines
129 }129 }
130 const session = yield* sessions.get(input.providerSessionId);130 const session = yield* sessions.get(input.providerSessionId);
131 if (Option.isNone(session)) {131 if (Option.isNone(session)) {
132 // Interrupt/restart against a already-released session must not fail
133 // the durable effect (and retry 5x). The turn may still look running
134 // in projection until recovery/finalization; there is no live adapter
135 // to interrupt.
136 if (input.operation === "interrupt" || input.operation === "restart") {
137 yield* Effect.logWarning(
138 "Provider interrupt/restart found no live session; treating as already stopped",
139 {
140 threadId: input.threadId,
141 operation: input.operation,
142 providerSessionId: input.providerSessionId,
143 providerTurnId: input.providerTurnId,
144 providerTurnStatus: providerTurn.status,
145 },
146 );
147 return {
148 projection,
149 providerThread: interruptProviderThread,
150 providerTurn,
151 session: Option.none(),
152 };
153 }
132 return yield* new ProviderTurnControlError({154 return yield* new ProviderTurnControlError({
133 threadId: input.threadId,155 threadId: input.threadId,
134 operation: input.operation,156 operation: input.operation,
@@ -147,6 +169,7 @@ export const layer: Layer.Layer< · 12 unmodified lines
147 yield* loaded.session.value.interruptTurn({169 yield* loaded.session.value.interruptTurn({
148 providerThread: loaded.providerThread,170 providerThread: loaded.providerThread,
149 providerTurnId: loaded.providerTurn.id,171 providerTurnId: loaded.providerTurn.id,
172 requestRuntimeRestart: true,
150 });173 });
151 }).pipe(174 }).pipe(
152 Effect.mapError((cause) =>175 Effect.mapError((cause) =>
@@ -163,13 +186,28 @@ export const layer: Layer.Layer< · 10 unmodified lines
163 interruptAndAwaitTerminal: (input) =>186 interruptAndAwaitTerminal: (input) =>
164 Effect.gen(function* () {187 Effect.gen(function* () {
165 const loaded = yield* load({ ...input, operation: "restart" });188 const loaded = yield* load({ ...input, operation: "restart" });
166 if (Option.isSome(loaded.session)) {189 if (Option.isNone(loaded.session)) {
167 yield* loaded.session.value.interruptTurn({190 // No live adapter: nothing can emit a terminal provider-turn update
168 providerThread: loaded.providerThread,191 // from interrupt. Do not poll for projection terminalization or the
169 providerTurnId: loaded.providerTurn.id,192 // restart effect stalls; let detach/start proceed.
170 });193 if (loaded.providerTurn.status === "running") {
194 yield* Effect.logWarning(
195 "Provider restart interrupt skipped; no live session for a still-projected running turn",
196 {
197 threadId: input.threadId,
198 providerSessionId: input.providerSessionId,
199 providerTurnId: input.providerTurnId,
200 },
201 );
202 }
203 return;
171 }204 }
172 205
206 yield* loaded.session.value.interruptTurn({
207 providerThread: loaded.providerThread,
208 providerTurnId: loaded.providerTurn.id,
209 });
210
173 for (let remaining = 1_000; remaining > 0; remaining -= 1) {211 for (let remaining = 1_000; remaining > 0; remaining -= 1) {
174 const projection = yield* projections.getThreadProjection(input.threadId);212 const projection = yield* projections.getThreadProjection(input.threadId);
175 const providerTurn = projection.providerTurns.find(213 const providerTurn = projection.providerTurns.find(
apps/server/src/orchestration-v2/ProviderTurnStartService.ts+17−0
@@ -439,6 +439,23 @@ export const layer: Layer.Layer< · 438 unmodified lines
439 }),439 }),
440 Effect.catchCause(() => Effect.succeed(false)),440 Effect.catchCause(() => Effect.succeed(false)),
441 ),441 ),
442 hasUnpairedRunInterruptRequest: () =>
443 projectionStore.getThreadProjection(projection.thread.id).pipe(
444 Effect.map((current) => {
445 const requestId = idAllocator.derive.runSignalTurnItem({
446 runId: run.id,
447 signal: "interrupt-request",
448 });
449 const resultId = idAllocator.derive.runSignalTurnItem({
450 runId: run.id,
451 signal: "interrupt-result",
452 });
453 const hasRequest = current.turnItems.some((item) => item.id === requestId);
454 const hasResult = current.turnItems.some((item) => item.id === resultId);
455 return hasRequest && !hasResult;
456 }),
457 Effect.catchCause(() => Effect.succeed(false)),
458 ),
442 message: {459 message: {
443 messageId: message.id,460 messageId: message.id,
444 text:461 text:
apps/server/src/orchestration-v2/RunExecutionService.test.ts+189−1
@@ -31,7 +31,7 @@ import * as Stream from "effect/Stream"; · 30 unmodified lines
31import { ServerSettingsService } from "../serverSettings.ts";31import { ServerSettingsService } from "../serverSettings.ts";
32import { CheckpointServiceV2 } from "./CheckpointService.ts";32import { CheckpointServiceV2 } from "./CheckpointService.ts";
33import { EventSinkV2 } from "./EventSink.ts";33import { EventSinkV2 } from "./EventSink.ts";
34import { layer as idAllocatorLayer } from "./IdAllocator.ts";34import { IdAllocatorV2, layer as idAllocatorLayer } from "./IdAllocator.ts";
35import type { ProviderAdapterV2Event, ProviderAdapterV2SessionRuntime } from "./ProviderAdapter.ts";35import type { ProviderAdapterV2Event, ProviderAdapterV2SessionRuntime } from "./ProviderAdapter.ts";
36import { ProviderEventIngestorV2 } from "./ProviderEventIngestor.ts";36import { ProviderEventIngestorV2 } from "./ProviderEventIngestor.ts";
37import {37import {
@@ -542,6 +542,194 @@ it.effect("does not pin ingestion on background items when the root turn is inte · 504 unmodified lines
542 }),542 }),
543);543);
544 544
545it.effect("omits run_interrupt_result when a superseding attempt already owns the run", () =>
546 Effect.gen(function* () {
547 const written = yield* captureInterruptTerminalTurnItems({
548 key: "steer-supersede",
549 shouldFinalizeRun: () => Effect.succeed(false),
550 });
551 assert.deepEqual(
552 written.map((item) => item.type),
553 [],
554 );
555 }),
556);
557
558it.effect("emits run_interrupt_result when superseded attempt still has a hard-stop request", () =>
559 Effect.gen(function* () {
560 const written = yield* captureInterruptTerminalTurnItems({
561 key: "stop-then-steer-supersede",
562 shouldFinalizeRun: () => Effect.succeed(false),
563 hasUnpairedRunInterruptRequest: () => Effect.succeed(true),
564 });
565 assert.deepEqual(
566 written.map((item) => item.type),
567 ["run_interrupt_result"],
568 );
569 const ids = backgroundScenarioIds("stop-then-steer-supersede");
570 const expectedRequestId = yield* Effect.gen(function* () {
571 const idAllocator = yield* IdAllocatorV2;
572 return idAllocator.derive.runSignalTurnItem({
573 runId: ids.runId,
574 signal: "interrupt-request",
575 });
576 }).pipe(Effect.provide(idAllocatorLayer));
577 assert.equal(written[0]?.parentItemId, expectedRequestId);
578 }),
579);
580
581it.effect("omits run_interrupt_result when superseded attempt request is already paired", () =>
582 Effect.gen(function* () {
583 const written = yield* captureInterruptTerminalTurnItems({
584 key: "stop-then-steer-already-paired",
585 shouldFinalizeRun: () => Effect.succeed(false),
586 hasUnpairedRunInterruptRequest: () => Effect.succeed(false),
587 });
588 assert.deepEqual(
589 written.map((item) => item.type),
590 [],
591 );
592 }),
593);
594
595it.effect("emits run_interrupt_result when hard-stop finalizes the active attempt", () =>
596 Effect.gen(function* () {
597 const written = yield* captureInterruptTerminalTurnItems({
598 key: "hard-stop",
599 shouldFinalizeRun: () => Effect.succeed(true),
600 });
601 assert.deepEqual(
602 written.map((item) => item.type),
603 ["run_interrupt_result"],
604 );
605 }),
606);
607
608function captureInterruptTerminalTurnItems(input: {
609 readonly key: string;
610 readonly shouldFinalizeRun: () => Effect.Effect<boolean, never>;
611 readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect<boolean, never>;
612}) {
613 return Effect.gen(function* () {
614 const ids = backgroundScenarioIds(input.key);
615 const providerInstanceId = ProviderInstanceId.make("codex");
616 const writtenItems = yield* Ref.make<
617 ReadonlyArray<{ readonly type: string; readonly parentItemId: string | null }>
618 >([]);
619 const ingestionDone = yield* Deferred.make<void>();
620 const captureTurnItem = (payload: {
621 readonly type: string;
622 readonly parentItemId: string | null;
623 }) =>
624 Ref.update(writtenItems, (current) => [
625 ...current,
626 { type: payload.type, parentItemId: payload.parentItemId },
627 ]);
628 const testLayer = runExecutionServiceLayer.pipe(
629 Layer.provide(
630 Layer.mergeAll(
631 Layer.mock(CheckpointServiceV2)({ captureBaseline: () => Effect.void }),
632 Layer.mock(EventSinkV2)({
633 write: (payload) =>
634 Effect.gen(function* () {
635 for (const event of payload.events) {
636 if (event.type === "turn-item.updated") {
637 yield* captureTurnItem(event.payload);
638 }
639 }
640 return [];
641 }),
642 writeWithEffects: (payload) =>
643 Effect.gen(function* () {
644 for (const event of payload.events) {
645 if (event.type === "turn-item.updated") {
646 yield* captureTurnItem(event.payload);
647 }
648 }
649 return [];
650 }),
651 writeIfRunCurrent: () => Effect.succeed({ committed: true, storedEvents: [] }),
652 }),
653 idAllocatorLayer,
654 Layer.mock(ProviderEventIngestorV2)({
655 ingestNormalized: () => Effect.succeed([]),
656 }),
657 ServerSettingsService.layerTest(),
658 ),
659 ),
660 );
661
662 yield* Effect.gen(function* () {
663 const runExecution = yield* RunExecutionServiceV2;
664 yield* runExecution.startRootRun({
665 commandId: CommandId.make(`command:${input.key}`),
666 appThread: { id: ids.threadId } as OrchestrationV2AppThread,
667 providerSessionId: ProviderSessionId.make(`session:${input.key}`),
668 session: {
669 events: Stream.empty,
670 subscribeEvents: Effect.succeed({
671 events: Stream.fromIterable([rootTerminalEvent(ids, "interrupted")]),
672 close: Deferred.succeed(ingestionDone, undefined),
673 }),
674 startTurn: () => Effect.void,
675 } as unknown as ProviderAdapterV2SessionRuntime,
676 run: {
677 id: ids.runId,
678 threadId: ids.threadId,
679 ordinal: 1,
680 providerInstanceId,
681 } as OrchestrationV2Run,
682 rootNode: {
683 id: ids.rootNodeId,
684 providerTurnId: ids.rootProviderTurnId,
685 } as OrchestrationV2ExecutionNode,
686 checkpointScope: {
687 id: CheckpointScopeId.make(`checkpoint-scope:${input.key}`),
688 } as OrchestrationV2CheckpointScope,
689 providerThread: {
690 id: ids.providerThreadId,
691 driver,
692 } as OrchestrationV2ProviderThread,
693 attempt: {
694 id: ids.attemptId,
695 providerTurnId: ids.rootProviderTurnId,
696 } as OrchestrationV2RunAttempt,
697 attemptId: ids.attemptId,
698 providerTurnOrdinal: 1,
699 shouldFinalizeRun: input.shouldFinalizeRun,
700 ...(input.hasUnpairedRunInterruptRequest === undefined
701 ? {}
702 : {
703 hasUnpairedRunInterruptRequest: input.hasUnpairedRunInterruptRequest,
704 }),
705 message: {
706 messageId: MessageId.make(`message:${input.key}`),
707 text: "interrupt projection",
708 attachments: [],
709 createdBy: "user",
710 creationSource: "web",
711 },
712 modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" },
713 runtimePolicy: {
714 runtimeMode: "full-access",
715 interactionMode: "default",
716 cwd: process.cwd(),
717 approvalPolicy: "never",
718 sandboxPolicy: {
719 type: "readOnly",
720 access: { type: "fullAccess" },
721 networkAccess: false,
722 },
723 },
724 });
725 }).pipe(Effect.provide(testLayer));
726
727 const closed = yield* Deferred.await(ingestionDone).pipe(Effect.timeoutOption("2 seconds"));
728 assert.isTrue(Option.isSome(closed), "event ingestion fiber did not finish");
729 return yield* Ref.get(writtenItems);
730 });
731}
732
545interface BackgroundScenarioIds {733interface BackgroundScenarioIds {
546 readonly threadId: ThreadId;734 readonly threadId: ThreadId;
547 readonly childThreadId: ThreadId;735 readonly childThreadId: ThreadId;
apps/server/src/orchestration-v2/RunExecutionService.ts+49−23
@@ -260,6 +260,7 @@ export interface RunExecutionServiceV2StartRootRunInput { · 259 unmodified lines
260 readonly relatedProviderThreadIds?: ReadonlyArray<ProviderThreadId>;260 readonly relatedProviderThreadIds?: ReadonlyArray<ProviderThreadId>;
261 readonly shouldStartProviderTurn?: () => Effect.Effect<boolean, never>;261 readonly shouldStartProviderTurn?: () => Effect.Effect<boolean, never>;
262 readonly shouldFinalizeRun?: () => Effect.Effect<boolean, never>;262 readonly shouldFinalizeRun?: () => Effect.Effect<boolean, never>;
263 readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect<boolean, never>;
263 readonly message: ProviderAdapterV2TurnMessage;264 readonly message: ProviderAdapterV2TurnMessage;
264 readonly modelSelection: ModelSelection;265 readonly modelSelection: ModelSelection;
265 readonly runtimePolicy: ProviderAdapterV2RuntimePolicy;266 readonly runtimePolicy: ProviderAdapterV2RuntimePolicy;
@@ -323,6 +324,7 @@ export const layer: Layer.Layer< · 57 unmodified lines
323 readonly providerThread: OrchestrationV2ProviderThread;324 readonly providerThread: OrchestrationV2ProviderThread;
324 readonly attempt: OrchestrationV2RunAttempt;325 readonly attempt: OrchestrationV2RunAttempt;
325 readonly shouldFinalizeRun?: () => Effect.Effect<boolean, never>;326 readonly shouldFinalizeRun?: () => Effect.Effect<boolean, never>;
327 readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect<boolean, never>;
326 readonly terminal: ProviderTerminalEvent;328 readonly terminal: ProviderTerminalEvent;
327 readonly failureItemPersisted: boolean;329 readonly failureItemPersisted: boolean;
328 }) =>330 }) =>
@@ -336,30 +338,37 @@ export const layer: Layer.Layer< · 7 unmodified lines
336 const shouldFinalizeRun =338 const shouldFinalizeRun =
337 input.shouldFinalizeRun === undefined ? true : yield* input.shouldFinalizeRun();339 input.shouldFinalizeRun === undefined ? true : yield* input.shouldFinalizeRun();
338 if (!shouldFinalizeRun) {340 if (!shouldFinalizeRun) {
339 // A newer attempt already owns the run and the command that created341 // Superseded attempt (steer / selection restart). Emit
340 // it terminalized this attempt as superseded. Preserve that domain342 // run_interrupt_result only when hard Stop left an unpaired request
341 // status while retaining the provider's interruption artifact.343 // for this run; plain steers and already-paired stops emit nothing.
342 if (input.terminal.status === "interrupted") {344 if (input.terminal.status === "interrupted") {
343 yield* eventSink.write({345 const hasUnpairedRequest =
344 events: [346 input.hasUnpairedRunInterruptRequest === undefined
345 {347 ? false
346 id: yield* idAllocator.allocate.event({ threadId: input.run.threadId }),348 : yield* input.hasUnpairedRunInterruptRequest();
347 type: "turn-item.updated" as const,349 if (hasUnpairedRequest) {
348 threadId: input.run.threadId,350 yield* eventSink.writeWithEffects({
349 runId: input.run.id,351 effects: [],
350 nodeId: input.rootNode.id,352 events: [
351 providerInstanceId: input.run.providerInstanceId,353 {
352 occurredAt: completedAt,354 id: yield* idAllocator.allocate.event({ threadId: input.run.threadId }),
353 payload: makeInterruptResultTurnItem({355 type: "turn-item.updated" as const,
354 idAllocator,356 threadId: input.run.threadId,
355 run: input.run,357 runId: input.run.id,
356 rootNode: input.rootNode,358 nodeId: input.rootNode.id,
357 providerThread: input.providerThread,359 providerInstanceId: input.run.providerInstanceId,
358 completedAt,360 occurredAt: completedAt,
359 }),361 payload: makeInterruptResultTurnItem({
360 },362 idAllocator,
361 ],363 run: input.run,
362 });364 rootNode: input.rootNode,
365 providerThread: input.providerThread,
366 completedAt,
367 }),
368 },
369 ],
370 });
371 }
363 }372 }
364 return;373 return;
365 }374 }
@@ -594,6 +603,11 @@ export const layer: Layer.Layer< · 228 unmodified lines
594 ...(input.shouldFinalizeRun === undefined603 ...(input.shouldFinalizeRun === undefined
595 ? {}604 ? {}
596 : { shouldFinalizeRun: input.shouldFinalizeRun }),605 : { shouldFinalizeRun: input.shouldFinalizeRun }),
606 ...(input.hasUnpairedRunInterruptRequest === undefined
607 ? {}
608 : {
609 hasUnpairedRunInterruptRequest: input.hasUnpairedRunInterruptRequest,
610 }),
597 terminal,611 terminal,
598 failureItemPersisted: terminal.status === "failed",612 failureItemPersisted: terminal.status === "failed",
599 }).pipe(613 }).pipe(
@@ -776,6 +790,12 @@ export const layer: Layer.Layer< · 176 unmodified lines
776 ...(input.shouldFinalizeRun === undefined790 ...(input.shouldFinalizeRun === undefined
777 ? {}791 ? {}
778 : { shouldFinalizeRun: input.shouldFinalizeRun }),792 : { shouldFinalizeRun: input.shouldFinalizeRun }),
793 ...(input.hasUnpairedRunInterruptRequest === undefined
794 ? {}
795 : {
796 hasUnpairedRunInterruptRequest:
797 input.hasUnpairedRunInterruptRequest,
798 }),
779 terminal: makeFailedTerminalEvent(799 terminal: makeFailedTerminalEvent(
780 makeProviderFailure({800 makeProviderFailure({
781 cause: Cause.squash(cause),801 cause: Cause.squash(cause),
@@ -847,6 +867,12 @@ export const layer: Layer.Layer< · 65 unmodified lines
847 ...(input.shouldFinalizeRun === undefined867 ...(input.shouldFinalizeRun === undefined
848 ? {}868 ? {}
849 : { shouldFinalizeRun: input.shouldFinalizeRun }),869 : { shouldFinalizeRun: input.shouldFinalizeRun }),
870 ...(input.hasUnpairedRunInterruptRequest === undefined
871 ? {}
872 : {
873 hasUnpairedRunInterruptRequest:
874 input.hasUnpairedRunInterruptRequest,
875 }),
850 terminal: makeFailedTerminalEvent(876 terminal: makeFailedTerminalEvent(
851 makeProviderFailure({877 makeProviderFailure({
852 cause: Cause.squash(cause),878 cause: Cause.squash(cause),
apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts+6−1
@@ -490,7 +490,12 @@ it.live("restarts selection as a new attempt and retries after old-session clean · 489 unmodified lines
490 projection.providerTurns.map((turn) => turn.status),490 projection.providerTurns.map((turn) => turn.status),
491 ["interrupted", "completed"],491 ["interrupted", "completed"],
492 );492 );
493 assert.isTrue(projection.turnItems.some((item) => item.type === "run_interrupt_result"));493 assert.isFalse(
494 projection.turnItems.some(
495 (item) => item.type === "run_interrupt_request" || item.type === "run_interrupt_result",
496 ),
497 "selection restart supersede must not project hard-Stop interrupt items",
498 );
494 assert.equal(projection.runs[0]?.modelSelection.model, replacementSelection.model);499 assert.equal(projection.runs[0]?.modelSelection.model, replacementSelection.model);
495 assert.isTrue(captured.failedReplacementOpen);500 assert.isTrue(captured.failedReplacementOpen);
496 // The old pooled process remains available to its other threads; this501 // The old pooled process remains available to its other threads; this
apps/server/src/orchestration-v2/testkit/fixtures/message_steering/cursor_output.ts+6−5
@@ -24,7 +24,7 @@ export function assertCursorMessageSteeringOutput( · 23 unmodified lines
24 24
25 const projection = projectionFor(result, transcript.scenario);25 const projection = projectionFor(result, transcript.scenario);
26 assertSemanticProjectionIntegrity(projection);26 assertSemanticProjectionIntegrity(projection);
27 assertTurnItemTypes(projection, ["user_message", "run_interrupt_result", "assistant_message"]);27 assertTurnItemTypes(projection, ["user_message", "assistant_message"]);
28 assertRuntimeRequestCounts(projection, { total: 0 });28 assertRuntimeRequestCounts(projection, { total: 0 });
29 assertUserMessagesInclude(projection, [29 assertUserMessagesInclude(projection, [
30 MESSAGE_STEERING_INITIAL_PROMPT,30 MESSAGE_STEERING_INITIAL_PROMPT,
@@ -47,9 +47,10 @@ export function assertCursorMessageSteeringOutput( · 16 unmodified lines
47 );47 );
48 assert.equal(projection.runs[0]?.activeAttemptId, projection.attempts[1]?.id);48 assert.equal(projection.runs[0]?.activeAttemptId, projection.attempts[1]?.id);
49 assert.equal(projection.runs[0]?.rootNodeId, projection.attempts[1]?.rootNodeId);49 assert.equal(projection.runs[0]?.rootNodeId, projection.attempts[1]?.rootNodeId);
50 assert.notInclude(50 assert.isFalse(
51 projection.visibleTurnItems.map((row) => row.item.type),51 projection.turnItems.some(
52 "run_interrupt_result",52 (item) => item.type === "run_interrupt_request" || item.type === "run_interrupt_result",
53 "restart steering must keep its internal interruption out of visible chat history",53 ),
54 "steer supersede must not project run_interrupt_* items (those are hard Stop only)",
54 );55 );
55}56}
apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_output.ts+6−5
@@ -23,7 +23,7 @@ export function assertGrokMessageSteeringOutput( · 22 unmodified lines
23 23
24 const projection = projectionFor(result, transcript.scenario);24 const projection = projectionFor(result, transcript.scenario);
25 assertSemanticProjectionIntegrity(projection);25 assertSemanticProjectionIntegrity(projection);
26 assertTurnItemTypes(projection, ["user_message", "run_interrupt_result", "assistant_message"]);26 assertTurnItemTypes(projection, ["user_message", "assistant_message"]);
27 assertRuntimeRequestCounts(projection, { total: 0 });27 assertRuntimeRequestCounts(projection, { total: 0 });
28 assertUserMessagesInclude(projection, [28 assertUserMessagesInclude(projection, [
29 MESSAGE_STEERING_INITIAL_PROMPT,29 MESSAGE_STEERING_INITIAL_PROMPT,
@@ -46,9 +46,10 @@ export function assertGrokMessageSteeringOutput( · 16 unmodified lines
46 );46 );
47 assert.equal(projection.runs[0]?.activeAttemptId, projection.attempts[1]?.id);47 assert.equal(projection.runs[0]?.activeAttemptId, projection.attempts[1]?.id);
48 assert.equal(projection.runs[0]?.rootNodeId, projection.attempts[1]?.rootNodeId);48 assert.equal(projection.runs[0]?.rootNodeId, projection.attempts[1]?.rootNodeId);
49 assert.notInclude(49 assert.isFalse(
50 projection.visibleTurnItems.map((row) => row.item.type),50 projection.turnItems.some(
51 "run_interrupt_result",51 (item) => item.type === "run_interrupt_request" || item.type === "run_interrupt_result",
52 "restart steering must keep its internal interruption out of visible chat history",52 ),
53 "steer supersede must not project run_interrupt_* items (those are hard Stop only)",
53 );54 );
54}55}
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+34−0
@@ -753,6 +753,40 @@ describe("AcpSessionRuntime", () => { · 752 unmodified lines
753 ),753 ),
754 );754 );
755 755
756 it.effect("completes ad-hoc loadSession after replay becomes idle while RPC stays pending", () =>
757 Effect.gen(function* () {
758 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
759 yield* runtime.start();
760 const loaded = yield* runtime.loadSession("mock-session-1").pipe(Effect.timeout("2 seconds"));
761
762 expect(loaded.sessionId).toBe("mock-session-1");
763 expect(loaded.sessionSetupResult._meta).toMatchObject({
764 t3SessionLoadReady: "replay_idle",
765 });
766 }).pipe(
767 Effect.provide(
768 AcpSessionRuntime.layer({
769 authMethodId: "test",
770 spawn: {
771 command: mockAgentCommand,
772 args: mockAgentArgs,
773 env: {
774 T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY: "1",
775 T3_ACP_LOAD_SESSION_DELAY_MS: "10000",
776 },
777 },
778 cwd: process.cwd(),
779 sessionLoadReplayIdleGap: "50 millis",
780 sessionLoadTimeout: "1 second",
781 clientInfo: { name: "t3-test", version: "0.0.0" },
782 }),
783 ),
784 Effect.scoped,
785 Effect.provide(NodeServices.layer),
786 TestClock.withLive,
787 ),
788 );
789
756 it.effect("rejects invalid config option values before sending session/set_config_option", () => {790 it.effect("rejects invalid config option values before sending session/set_config_option", () => {
757 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-"));791 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-"));
758 const requestLogPath = NodePath.join(tempDir, "requests.ndjson");792 const requestLogPath = NodePath.join(tempDir, "requests.ndjson");
apps/server/src/provider/acp/AcpRuntimeModel.test.ts+93−0
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 parsePermissionRequest,9 parsePermissionRequest,
10 parseSessionModeState,10 parseSessionModeState,
11 parseSessionUpdateEvent,11 parseSessionUpdateEvent,
12 sessionUpdateCountsAsLoadReplayActivity,
12 sessionUpdateIsReplay,13 sessionUpdateIsReplay,
13 syntheticLoadSessionResponseFromInitialize,14 syntheticLoadSessionResponseFromInitialize,
14 toolCallProgressLength,15 toolCallProgressLength,
@@ -86,6 +87,98 @@ describe("AcpRuntimeModel", () => { · 71 unmodified lines
86 ).toBe(false);87 ).toBe(false);
87 });88 });
88 89
90 it("ignores Grok keepalive chunks when tracking session/load replay activity", () => {
91 expect(
92 sessionUpdateCountsAsLoadReplayActivity({
93 sessionId: "session-1",
94 update: {
95 sessionUpdate: "agent_message_chunk",
96 content: { type: "text", text: "" },
97 },
98 } satisfies EffectAcpSchema.SessionNotification),
99 ).toBe(false);
100 expect(
101 sessionUpdateCountsAsLoadReplayActivity({
102 _meta: { isReplay: true },
103 sessionId: "session-1",
104 update: {
105 sessionUpdate: "tool_call",
106 toolCallId: "replay-tool",
107 title: "Replay",
108 kind: "search",
109 status: "completed",
110 },
111 } satisfies EffectAcpSchema.SessionNotification),
112 ).toBe(true);
113 });
114
115 it("ignores load-replay activity from other sessions while a load gate is active", () => {
116 expect(
117 sessionUpdateCountsAsLoadReplayActivity(
118 {
119 sessionId: "session-other",
120 update: {
121 sessionUpdate: "agent_message_chunk",
122 content: { type: "text", text: "unrelated" },
123 },
124 } satisfies EffectAcpSchema.SessionNotification,
125 "session-loading",
126 ),
127 ).toBe(false);
128 expect(
129 sessionUpdateCountsAsLoadReplayActivity(
130 {
131 sessionId: "session-loading",
132 update: {
133 sessionUpdate: "agent_message_chunk",
134 content: { type: "text", text: "replay" },
135 },
136 } satisfies EffectAcpSchema.SessionNotification,
137 "session-loading",
138 ),
139 ).toBe(true);
140 });
141
142 it("counts mode/config/session/usage updates as load-replay activity", () => {
143 expect(
144 sessionUpdateCountsAsLoadReplayActivity({
145 sessionId: "session-1",
146 update: {
147 sessionUpdate: "current_mode_update",
148 currentModeId: "code",
149 },
150 } satisfies EffectAcpSchema.SessionNotification),
151 ).toBe(true);
152 expect(
153 sessionUpdateCountsAsLoadReplayActivity({
154 sessionId: "session-1",
155 update: {
156 sessionUpdate: "config_option_update",
157 configOptions: [],
158 },
159 } satisfies EffectAcpSchema.SessionNotification),
160 ).toBe(true);
161 expect(
162 sessionUpdateCountsAsLoadReplayActivity({
163 sessionId: "session-1",
164 update: {
165 sessionUpdate: "session_info_update",
166 title: "Restored",
167 },
168 } satisfies EffectAcpSchema.SessionNotification),
169 ).toBe(true);
170 expect(
171 sessionUpdateCountsAsLoadReplayActivity({
172 sessionId: "session-1",
173 update: {
174 sessionUpdate: "usage_update",
175 used: 1,
176 size: 10,
177 },
178 } satisfies EffectAcpSchema.SessionNotification),
179 ).toBe(true);
180 });
181
89 it("builds a synthetic load response from initialize model state", () => {182 it("builds a synthetic load response from initialize model state", () => {
90 const response = syntheticLoadSessionResponseFromInitialize({183 const response = syntheticLoadSessionResponseFromInitialize({
91 protocolVersion: 1,184 protocolVersion: 1,
apps/server/src/provider/acp/AcpRuntimeModel.ts+30−0
@@ -700,8 +700,38 @@ export function sessionUpdateIsReplay(params: EffectAcpSchema.SessionNotificatio · 699 unmodified lines
700 return isRecord(meta) && meta.isReplay === true;700 return isRecord(meta) && meta.isReplay === true;
701}701}
702 702
703/** Replay chunks and substantive updates during session/load; not Grok keepalives. */
704export function sessionUpdateCountsAsLoadReplayActivity(
705 params: EffectAcpSchema.SessionNotification,
706 gatedSessionId?: string,
707): boolean {
708 if (gatedSessionId !== undefined && params.sessionId !== gatedSessionId) {
709 return false;
710 }
711 if (sessionUpdateIsReplay(params)) return true;
712 const update = params.update;
713 switch (update.sessionUpdate) {
714 case "agent_message_chunk":
715 case "agent_thought_chunk":
716 return update.content.type === "text" && update.content.text.length > 0;
717 case "tool_call":
718 case "tool_call_update":
719 case "plan":
720 case "available_commands_update":
721 case "current_mode_update":
722 case "config_option_update":
723 case "session_info_update":
724 case "usage_update":
725 return true;
726 default:
727 return false;
728 }
729}
730
703export interface SessionLoadGate {731export interface SessionLoadGate {
704 readonly active: boolean;732 readonly active: boolean;
733 /** Only notifications for this session refresh load-replay idle activity. */
734 readonly sessionId: string;
705 readonly lastActivityAtMillis: number | undefined;735 readonly lastActivityAtMillis: number | undefined;
706 readonly idleGap: Duration.Duration;736 readonly idleGap: Duration.Duration;
707 readonly initializeResult: EffectAcpSchema.InitializeResponse;737 readonly initializeResult: EffectAcpSchema.InitializeResponse;
apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts+1030−0
@@ -0,0 +1,1030 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";
4import * as NodePath from "node:path";
5import * as NodeURL from "node:url";
6
7import { describe, expect, it } from "@effect/vitest";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
9import * as Clock from "effect/Clock";
10import * as Effect from "effect/Effect";
11import * as Exit from "effect/Exit";
12import * as Scope from "effect/Scope";
13
14import {
15 capturePosixOwnershipLedger,
16 isSafeCgroupPath,
17 makeLinuxCgroupController,
18 makePosixProcessTreeController,
19 observePosixOwnershipLedger,
20 observePosixOwnershipLedgerContinuously,
21 parseCgroup2Mount,
22 parseCgroup2Mounts,
23 parseUnifiedCgroupPath,
24 posixProcessIsZombie,
25 resolveLinuxCgroupTargetCommand,
26 signalLinuxCgroupRootTerm,
27 sweepStaleLinuxCgroupSiblings,
28 terminateLinuxCgroupLease,
29 terminatePosixOwnedProcessTree,
30 wrapCommandForLinuxCgroup,
31 type AcpOwnedPosixProcess,
32 type AcpLinuxCgroupLease,
33 type AcpPosixOwnershipRoot,
34 type AcpPosixProcessIdentity,
35 type AcpPosixProcessTreeController,
36} from "./AcpSessionRuntime.ts";
37
38const threadSpawnHelperSource = NodeURL.fileURLToPath(
39 new URL("../../../scripts/acp-thread-spawn-helper.c", import.meta.url),
40);
41
42const identity = (
43 pid: number,
44 ppid: number,
45 pgid: number,
46 sid: number,
47 startTime = String(pid),
48 state = "S",
49): AcpPosixProcessIdentity => ({
50 executable: `/proc/${pid}/exe`,
51 pgid,
52 pid,
53 ppid,
54 sid,
55 startTime,
56 state,
57});
58
59function makeController(input: {
60 readonly processes: ReadonlyArray<AcpPosixProcessIdentity>;
61 readonly onProcess?: (
62 processes: Map<number, AcpPosixProcessIdentity>,
63 pid: number,
64 signal: NodeJS.Signals,
65 ) => void;
66}) {
67 const processes = new Map(input.processes.map((process) => [process.pid, process]));
68 const signals: Array<string> = [];
69 const controller: AcpPosixProcessTreeController = {
70 childPidsOf: (pid) =>
71 [...processes.values()]
72 .filter((process) => process.ppid === pid)
73 .map((process) => process.pid),
74 childrenOf: (pid) => [...processes.values()].filter((process) => process.ppid === pid),
75 identity: (pid) => processes.get(pid),
76 snapshot: () => [...processes.values()],
77 signalProcess: (pid, signal) => {
78 signals.push(`process:${pid}:${signal}`);
79 if (input.onProcess) return input.onProcess(processes, pid, signal);
80 processes.delete(pid);
81 },
82 };
83 return { controller, processes, signals };
84}
85
86const server = () => identity(process.pid, 1, process.pid, process.pid, "server");
87
88function processExists(pid: number): boolean {
89 try {
90 process.kill(pid, 0);
91 return true;
92 } catch {
93 return false;
94 }
95}
96
97describe("terminatePosixOwnedProcessTree", () => {
98 it("parses unified cgroup paths and escaped cgroup2 mountinfo", () => {
99 expect(parseUnifiedCgroupPath("0::/user.slice/app.scope\n")).toBe("/user.slice/app.scope");
100 expect(parseUnifiedCgroupPath("0::/one\n0::/two\n")).toBeUndefined();
101 expect(isSafeCgroupPath("/user.slice/app.scope")).toBe(true);
102 expect(isSafeCgroupPath("/user.slice/../escape")).toBe(false);
103 expect(isSafeCgroupPath("/user.slice/app.scope (deleted)")).toBe(false);
104 expect(
105 parseCgroup2Mount(
106 "42 31 0:37 /user.slice /sys/fs/cgroup/user\\040mount rw - cgroup2 cgroup2 rw\n",
107 ),
108 ).toEqual({ mountPoint: "/sys/fs/cgroup/user mount", root: "/user.slice" });
109 expect(parseCgroup2Mount("42 31 0:37 / /sys/fs/cgroup rw - tmpfs tmpfs rw\n")).toBeUndefined();
110 expect(
111 parseCgroup2Mounts(
112 "41 31 0:36 /other /sys/fs/cgroup/other rw - cgroup2 cgroup2 rw\n" +
113 "42 31 0:37 /user.slice /sys/fs/cgroup/user rw - cgroup2 cgroup2 rw\n",
114 ),
115 ).toHaveLength(2);
116 });
117
118 it.live("sweeps empty t3-acp sibling leases owned by dead pids only", () =>
119 Effect.gen(function* () {
120 const scratchRoot = NodePath.join(process.cwd(), "tmp");
121 NodeFS.mkdirSync(scratchRoot, { recursive: true });
122 const parent = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-stale-siblings-"));
123 // Temp dirs are not cgroupfs: rmdir fails when control files exist, so the
124 // test injects populated/remove while still exercising the real selection
125 // and path-safety logic against a real parent directory tree.
126 const populatedByPath = new Map<string, "0" | "1">();
127 const writeSibling = (name: string, populated: "0" | "1" | null) => {
128 const path = NodePath.join(parent, name);
129 NodeFS.mkdirSync(path);
130 if (populated !== null) populatedByPath.set(path, populated);
131 return path;
132 };
133 const deadPid = 2_147_483_646;
134 // Synthetic live foreign pid (not this process, still reported alive by the probe).
135 const liveForeignPid = 2_147_483_645;
136 const staleEmpty = writeSibling(`t3-acp-${deadPid}-aaaa`, "0");
137 const currentPidSibling = writeSibling(`t3-acp-${process.pid}-bbbb`, "0");
138 const liveForeignSibling = writeSibling(`t3-acp-${liveForeignPid}-cccc`, "0");
139 const stalePopulated = writeSibling(`t3-acp-${deadPid}-dddd`, "1");
140 const unrelated = writeSibling("other-lease", "0");
141 const isProcessAlive = (pid: number) => pid === process.pid || pid === liveForeignPid;
142 try {
143 sweepStaleLinuxCgroupSiblings(parent, {
144 isProcessAlive,
145 readPopulated: (path) => populatedByPath.get(path),
146 remove: (path) => {
147 populatedByPath.delete(path);
148 NodeFS.rmdirSync(path);
149 },
150 });
151 expect(NodeFS.existsSync(staleEmpty)).toBe(false);
152 expect(NodeFS.existsSync(currentPidSibling)).toBe(true);
153 expect(NodeFS.existsSync(liveForeignSibling)).toBe(true);
154 expect(NodeFS.existsSync(stalePopulated)).toBe(true);
155 expect(NodeFS.existsSync(unrelated)).toBe(true);
156 // Missing populated state must not remove a sibling.
157 writeSibling(`t3-acp-${deadPid}-eeee`, null);
158 sweepStaleLinuxCgroupSiblings(parent, {
159 isProcessAlive,
160 readPopulated: (path) => populatedByPath.get(path),
161 remove: (path) => {
162 populatedByPath.delete(path);
163 NodeFS.rmdirSync(path);
164 },
165 });
166 expect(NodeFS.existsSync(NodePath.join(parent, `t3-acp-${deadPid}-eeee`))).toBe(true);
167 expect(NodeFS.existsSync(currentPidSibling)).toBe(true);
168 } finally {
169 NodeFS.rmSync(parent, { recursive: true, force: true });
170 }
171 }),
172 );
173
174 it.live("preserves exact argv and strips wrapper-only environment before exec", () =>
175 Effect.gen(function* () {
176 if ((yield* HostProcessPlatform) !== "linux") return;
177 const lease = makeLinuxCgroupController().create();
178 if (lease === undefined) return;
179 const scratchRoot = NodePath.join(process.cwd(), "tmp");
180 NodeFS.mkdirSync(scratchRoot, { recursive: true });
181 const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-wrapper-"));
182 const linkedNode = NodePath.join(scratch, "T3 Code AppImage 'quoted'");
183 const bareGrok = NodePath.join(scratch, "grok");
184 const relativeBin = NodePath.join(scratch, "relative-bin");
185 const directoryBin = NodePath.join(scratch, "directory-bin");
186 const validBin = NodePath.join(scratch, "valid-bin");
187 const outputPath = NodePath.join(scratch, "argv.json");
188 NodeFS.mkdirSync(relativeBin);
189 NodeFS.mkdirSync(NodePath.join(directoryBin, "grok"), { recursive: true });
190 NodeFS.mkdirSync(validBin);
191 NodeFS.symlinkSync(process.execPath, linkedNode);
192 NodeFS.symlinkSync(process.execPath, bareGrok);
193 NodeFS.symlinkSync(process.execPath, NodePath.join(relativeBin, "grok"));
194 NodeFS.symlinkSync(process.execPath, NodePath.join(validBin, "grok"));
195 expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: scratch })).toBe(bareGrok);
196 expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "" })).toBe(bareGrok);
197 // Undefined PATH uses Node's default search path (/usr/bin:/bin), not cwd alone
198 // (bareGrok lives only in cwd / PATH=scratch).
199 expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: undefined })).toBeUndefined();
200 expect(resolveLinuxCgroupTargetCommand("node", scratch, { PATH: undefined })).toBeDefined();
201 expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "relative-bin" })).toBe(
202 NodePath.join(relativeBin, "grok"),
203 );
204 expect(
205 resolveLinuxCgroupTargetCommand("grok", scratch, {
206 PATH: `directory-bin${NodePath.delimiter}valid-bin`,
207 }),
208 ).toBe(NodePath.join(validBin, "grok"));
209 const target = [
210 'const fs = require("node:fs");',
211 "fs.writeFileSync(process.argv[1], JSON.stringify({",
212 " args: process.argv.slice(2),",
213 " electron: process.env.ELECTRON_RUN_AS_NODE,",
214 " wrapper: process.env.T3_ACP_CGROUP_WRAPPER,",
215 "}));",
216 ].join("\n");
217 const wrapped = wrapCommandForLinuxCgroup(lease, linkedNode, [
218 "-e",
219 target,
220 outputPath,
221 "space value",
222 "single'quote",
223 'double"quote',
224 ]);
225 try {
226 const result = NodeChildProcess.spawnSync(wrapped.command, wrapped.args, {
227 encoding: "utf8",
228 env: {
229 ...process.env,
230 ELECTRON_RUN_AS_NODE: "1",
231 T3_ACP_CGROUP_WRAPPER: "1",
232 },
233 });
234 expect(result.status, result.stderr).toBe(0);
235 expect(NodeFS.readFileSync(outputPath, "utf8")).toBe(
236 '{"args":["space value","single\'quote","double\\"quote"]}',
237 );
238 } finally {
239 yield* terminateLinuxCgroupLease(lease).pipe(Effect.ignore);
240 NodeFS.rmSync(scratch, { recursive: true, force: true });
241 }
242 }),
243 );
244
245 it.live("kills a post-TERM detached double fork without touching an unrelated sentinel", () =>
246 Effect.gen(function* () {
247 if ((yield* HostProcessPlatform) !== "linux") return;
248 const controller = makeLinuxCgroupController();
249 const lease = controller.create();
250 if (lease === undefined) return;
251 expect(parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8"))).not.toBe(
252 lease.relativePath,
253 );
254 const scratchRoot = NodePath.join(process.cwd(), "tmp");
255 NodeFS.mkdirSync(scratchRoot, { recursive: true });
256 const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-kill-"));
257 const readyPath = NodePath.join(scratch, "ready");
258 const childPath = NodePath.join(scratch, "child");
259 const childProgram = "setInterval(() => {}, 1000);";
260 const launcherProgram = [
261 'const fs = require("node:fs");',
262 'const cp = require("node:child_process");',
263 "const child = cp.spawn(process.execPath, ['-e', process.argv[2]], { detached: true, stdio: 'ignore' });",
264 "fs.writeFileSync(process.argv[1], String(child.pid));",
265 "child.unref();",
266 ].join("\n");
267 const providerProgram = [
268 'const fs = require("node:fs");',
269 'const cp = require("node:child_process");',
270 "fs.writeFileSync(process.argv[1], String(process.pid));",
271 "process.on('SIGTERM', () => {",
272 " const launcher = cp.spawn(process.execPath, ['-e', process.argv[3], process.argv[2], process.argv[4]], { detached: true, stdio: 'ignore' });",
273 " launcher.unref();",
274 " process.exit(0);",
275 "});",
276 "setInterval(() => {}, 1000);",
277 ].join("\n");
278 const wrapped = wrapCommandForLinuxCgroup(lease, process.execPath, [
279 "-e",
280 providerProgram,
281 readyPath,
282 childPath,
283 launcherProgram,
284 childProgram,
285 ]);
286 const provider = NodeChildProcess.spawn(wrapped.command, wrapped.args, {
287 detached: true,
288 env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", T3_ACP_CGROUP_WRAPPER: "1" },
289 stdio: "ignore",
290 });
291 provider.unref();
292 const sentinel = NodeChildProcess.spawn(process.execPath, ["-e", childProgram], {
293 detached: true,
294 stdio: "ignore",
295 });
296 sentinel.unref();
297 let detachedChildPid: number | undefined;
298 try {
299 yield* Effect.gen(function* () {
300 while (!NodeFS.existsSync(readyPath)) yield* Effect.sleep("10 millis");
301 }).pipe(Effect.timeout("2 seconds"));
302 process.kill(provider.pid!, "SIGTERM");
303 detachedChildPid = yield* Effect.gen(function* () {
304 while (true) {
305 try {
306 const pid = Number(NodeFS.readFileSync(childPath, "utf8"));
307 if (Number.isSafeInteger(pid) && pid > 1) return pid;
308 } catch {
309 // The TERM-time launcher has not published its child yet.
310 }
311 yield* Effect.sleep("10 millis");
312 }
313 }).pipe(Effect.timeout("2 seconds"));
314 expect(processExists(detachedChildPid)).toBe(true);
315 yield* terminateLinuxCgroupLease(lease);
316 expect(processExists(sentinel.pid!)).toBe(true);
317 yield* Effect.gen(function* () {
318 while (processExists(detachedChildPid!)) yield* Effect.sleep("10 millis");
319 }).pipe(Effect.timeout("2 seconds"));
320 expect(NodeFS.existsSync(lease.path)).toBe(false);
321
322 const replacement = controller.create();
323 expect(replacement?.path).not.toBe(lease.path);
324 if (replacement !== undefined) {
325 NodeFS.mkdirSync(NodePath.join(replacement.path, "nested"));
326 yield* terminateLinuxCgroupLease(replacement);
327 expect(NodeFS.existsSync(replacement.path)).toBe(false);
328 }
329 } finally {
330 yield* terminateLinuxCgroupLease(lease).pipe(Effect.ignore);
331 try {
332 process.kill(-provider.pid!, "SIGKILL");
333 } catch {
334 // The contained provider already exited.
335 }
336 try {
337 process.kill(-sentinel.pid!, "SIGKILL");
338 } catch {
339 // The unrelated sentinel already exited.
340 }
341 if (detachedChildPid !== undefined && processExists(detachedChildPid)) {
342 try {
343 process.kill(detachedChildPid, "SIGKILL");
344 } catch {
345 // The detached child already exited.
346 }
347 }
348 NodeFS.rmSync(scratch, { recursive: true, force: true });
349 }
350 }),
351 );
352
353 it.live("re-kills repopulated cgroups and verifies root removal after retryable errors", () =>
354 Effect.gen(function* () {
355 let exists = true;
356 let killCalls = 0;
357 let populated = false;
358 let removeCalls = 0;
359 const lease: AcpLinuxCgroupLease = {
360 contains: () => false,
361 exists: () => exists,
362 path: "/test/t3-acp-repopulation",
363 relativePath: "/test/t3-acp-repopulation",
364 kill: () => {
365 killCalls += 1;
366 populated = false;
367 },
368 populated: () => populated,
369 remove: () => {
370 removeCalls += 1;
371 if (removeCalls === 1) {
372 populated = true;
373 const error = new Error("repopulated") as NodeJS.ErrnoException;
374 error.code = "EBUSY";
375 throw error;
376 }
377 if (removeCalls === 2) {
378 const error = new Error("nested path vanished") as NodeJS.ErrnoException;
379 error.code = "ENOENT";
380 throw error;
381 }
382 exists = false;
383 },
384 };
385
386 yield* terminateLinuxCgroupLease(lease);
387 expect(killCalls).toBeGreaterThanOrEqual(3);
388 expect(removeCalls).toBe(3);
389 expect(exists).toBe(false);
390 yield* terminateLinuxCgroupLease(lease);
391 expect(removeCalls).toBe(3);
392 }),
393 );
394
395 it("sends TERM only to the exact captured root while it remains in the child cgroup", () => {
396 const fixture = makeController({
397 processes: [server(), identity(100, process.pid, 100, 100, "owned")],
398 });
399 const ownedRoot: AcpOwnedPosixProcess = {
400 ...identity(100, process.pid, 100, 100, "owned"),
401 parentExecutable: server().executable,
402 parentStartTime: server().startTime,
403 };
404 const root: AcpPosixOwnershipRoot = { captureAttempted: true, value: ownedRoot };
405 const lease: AcpLinuxCgroupLease = {
406 contains: () => true,
407 exists: () => true,
408 path: "/test/t3-acp-root",
409 relativePath: "/test/t3-acp-root",
410 kill: () => undefined,
411 populated: () => true,
412 remove: () => undefined,
413 };
414 signalLinuxCgroupRootTerm({ controller: fixture.controller, lease, root });
415 expect(fixture.signals).toEqual(["process:100:SIGTERM"]);
416
417 const reused = makeController({
418 processes: [server(), identity(100, process.pid, 100, 100, "reused")],
419 });
420 signalLinuxCgroupRootTerm({ controller: reused.controller, lease, root });
421 expect(reused.signals).toEqual([]);
422
423 const migrated = makeController({
424 processes: [server(), identity(100, process.pid, 100, 100, "owned")],
425 });
426 signalLinuxCgroupRootTerm({
427 controller: migrated.controller,
428 lease: { ...lease, contains: () => false },
429 root,
430 });
431 expect(migrated.signals).toEqual([]);
432 });
433
434 it.live("finds a child forked by a non-leader pthread", () =>
435 Effect.gen(function* () {
436 if ((yield* HostProcessPlatform) !== "linux") return;
437 const scratchRoot = NodePath.join(process.cwd(), "tmp");
438 NodeFS.mkdirSync(scratchRoot, { recursive: true });
439 const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-pthread-spawn-"));
440 const binary = NodePath.join(scratch, "helper");
441 const pidPath = NodePath.join(scratch, "pids");
442 expect(NodeFS.statSync(threadSpawnHelperSource).isFile()).toBe(true);
443 const compile = NodeChildProcess.spawnSync(
444 "cc",
445 ["-pthread", threadSpawnHelperSource, "-o", binary],
446 {
447 encoding: "utf8",
448 },
449 );
450 if ((compile.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
451 NodeFS.rmSync(scratch, { recursive: true, force: true });
452 return;
453 }
454 expect(compile.status, compile.stderr).toBe(0);
455 const helper = NodeChildProcess.spawn(binary, [pidPath], { detached: true, stdio: "ignore" });
456 helper.unref();
457 try {
458 const published = yield* Effect.gen(function* () {
459 while (true) {
460 try {
461 const ids = NodeFS.readFileSync(pidPath, "utf8").trim().split(/\s+/).map(Number);
462 if (ids.length === 2 && ids.every(Number.isSafeInteger)) return ids;
463 } catch {
464 // The worker has not published its TID and child yet.
465 }
466 yield* Effect.sleep("10 millis");
467 }
468 }).pipe(Effect.timeout("2 seconds"));
469 const [workerTid, childPid] = published;
470 expect(workerTid).not.toBe(helper.pid);
471 expect(makePosixProcessTreeController("linux").childrenOf(helper.pid!)).toContainEqual(
472 expect.objectContaining({ pid: childPid, ppid: helper.pid }),
473 );
474 } finally {
475 try {
476 process.kill(-helper.pid!, "SIGKILL");
477 } catch {
478 // The isolated fixture already exited.
479 }
480 NodeFS.rmSync(scratch, { recursive: true, force: true });
481 }
482 }),
483 );
484
485 it.live("keeps ownership across a fork-before-exec executable transition", () =>
486 Effect.gen(function* () {
487 const beforeExec = { ...identity(110, 100, 110, 110), executable: "/provider/fork" };
488 const fixture = makeController({
489 processes: [server(), identity(100, process.pid, 100, 100), beforeExec],
490 });
491 const frontier = new Map<number, AcpOwnedPosixProcess>();
492 const childQueues = new Map<number, Array<number>>();
493 const ledger = new Map<string, AcpOwnedPosixProcess>();
494 const root: AcpPosixOwnershipRoot = { value: undefined };
495 observePosixOwnershipLedger({
496 childQueues,
497 controller: fixture.controller,
498 frontier,
499 ledger,
500 root,
501 rootPid: 100,
502 });
503 fixture.processes.set(110, { ...beforeExec, executable: "/usr/bin/bash" });
504 observePosixOwnershipLedger({
505 childQueues,
506 controller: fixture.controller,
507 frontier,
508 ledger,
509 root,
510 rootPid: 100,
511 });
512
513 expect([...ledger.values()].find((process) => process.pid === 110)?.executable).toBe(
514 "/usr/bin/bash",
515 );
516 yield* terminatePosixOwnedProcessTree({
517 controller: fixture.controller,
518 grace: 0,
519 ledger,
520 rootPid: 100,
521 });
522 expect(fixture.signals).toContain("process:110:SIGTERM");
523 expect(fixture.processes.has(110)).toBe(false);
524 }),
525 );
526
527 it("never adopts a replacement PID after initial root capture failed", () => {
528 const fixture = makeController({ processes: [server()] });
529 const frontier = new Map<number, AcpOwnedPosixProcess>();
530 const childQueues = new Map<number, Array<number>>();
531 const ledger = new Map<string, AcpOwnedPosixProcess>();
532 const root: AcpPosixOwnershipRoot = { value: undefined };
533 const capture = () => {
534 try {
535 observePosixOwnershipLedger({
536 childQueues,
537 controller: fixture.controller,
538 frontier,
539 ledger,
540 root,
541 rootPid: 100,
542 });
543 return undefined;
544 } catch (error) {
545 return error;
546 }
547 };
548 expect(capture()).toMatchObject({
549 detail: "ACP process 100 exited before its ownership ledger was captured",
550 });
551 fixture.processes.set(100, identity(100, process.pid, 100, 100, "reused"));
552 expect(capture()).toMatchObject({
553 detail: "ACP process 100 exited before its ownership ledger was captured",
554 });
555 expect(ledger.size).toBe(0);
556 });
557
558 it.live("rotates more than 64 live parents without scanning retained tombstones", () =>
559 Effect.gen(function* () {
560 const parents = Array.from({ length: 130 }, (_, index) =>
561 identity(1_000 + index, 100, 1_000 + index, 1_000 + index),
562 );
563 let childListReads = 0;
564 let identityCalls = 0;
565 let snapshotCalls = 0;
566 const fixture = makeController({
567 processes: [server(), identity(100, process.pid, 100, 100)],
568 });
569 const controller: AcpPosixProcessTreeController = {
570 ...fixture.controller,
571 childPidsOf: (pid) => {
572 childListReads += 1;
573 return fixture.controller.childPidsOf(pid);
574 },
575 identity: (pid) => {
576 identityCalls += 1;
577 return fixture.controller.identity(pid);
578 },
579 snapshot: () => {
580 snapshotCalls += 1;
581 return fixture.controller.snapshot();
582 },
583 };
584 const frontier = new Map<number, AcpOwnedPosixProcess>();
585 const childQueues = new Map<number, Array<number>>();
586 const ledger = new Map<string, AcpOwnedPosixProcess>();
587 const root: AcpPosixOwnershipRoot = { value: undefined };
588 for (let index = 0; index < 5_000; index += 1) {
589 const tombstone = identity(100_000 + index, 1, 100_000 + index, 100_000 + index);
590 ledger.set(`${tombstone.pid}:${tombstone.startTime}`, {
591 ...tombstone,
592 parentExecutable: undefined,
593 parentStartTime: "",
594 });
595 }
596 observePosixOwnershipLedger({
597 childQueues,
598 controller,
599 frontier,
600 ledger,
601 root,
602 rootPid: 100,
603 });
604 for (const parent of parents) {
605 fixture.processes.set(parent.pid, parent);
606 fixture.processes.set(
607 parent.pid + 10_000,
608 identity(parent.pid + 10_000, parent.pid, parent.pgid, parent.sid),
609 );
610 }
611 let passes = 0;
612 while (
613 !parents.every((parent) =>
614 [...ledger.values()].some((owned) => owned.pid === parent.pid + 10_000),
615 ) &&
616 passes < 40
617 ) {
618 childListReads = 0;
619 identityCalls = 0;
620 observePosixOwnershipLedger({
621 childQueues,
622 controller,
623 frontier,
624 ledger,
625 maxProcesses: 64,
626 root,
627 rootPid: 100,
628 });
629 expect(identityCalls + childListReads).toBeLessThanOrEqual(64);
630 passes += 1;
631 }
632
633 const missing = parents.filter(
634 (parent) => ![...ledger.values()].some((owned) => owned.pid === parent.pid + 10_000),
635 );
636 expect(missing, `missing after ${passes} passes`).toEqual([]);
637 expect(passes).toBeGreaterThan(2);
638 yield* terminatePosixOwnedProcessTree({
639 controller,
640 grace: 0,
641 ledger,
642 rootPid: 100,
643 });
644 expect(snapshotCalls).toBe(10);
645 expect(fixture.processes.size).toBe(1);
646 }),
647 );
648
649 it.live("polls only known descendants and stops with its scope", () =>
650 Effect.gen(function* () {
651 let childrenCalls = 0;
652 let identityCalls = 0;
653 let snapshotCalls = 0;
654 const fixture = makeController({
655 processes: [server(), identity(100, process.pid, 100, 100), identity(110, 100, 110, 110)],
656 });
657 const controller: AcpPosixProcessTreeController = {
658 ...fixture.controller,
659 childPidsOf: (pid) => {
660 childrenCalls += 1;
661 return fixture.controller.childPidsOf(pid);
662 },
663 identity: (pid) => {
664 identityCalls += 1;
665 return fixture.controller.identity(pid);
666 },
667 snapshot: () => {
668 snapshotCalls += 1;
669 return fixture.controller.snapshot();
670 },
671 };
672 const scope = yield* Scope.make();
673 const frontier = new Map<number, AcpOwnedPosixProcess>();
674 const childQueues = new Map<number, Array<number>>();
675 let ledgerValuesCalls = 0;
676 const ledger = new (class extends Map<string, AcpOwnedPosixProcess> {
677 override values(): MapIterator<AcpOwnedPosixProcess> {
678 ledgerValuesCalls += 1;
679 return super.values();
680 }
681 })();
682 const root: AcpPosixOwnershipRoot = { value: undefined };
683 for (let index = 0; index < 5_000; index += 1) {
684 const tombstone = identity(10_000 + index, 1, 10_000 + index, 10_000 + index);
685 ledger.set(`${tombstone.pid}:${tombstone.startTime}`, {
686 ...tombstone,
687 parentExecutable: undefined,
688 parentStartTime: "",
689 });
690 }
691 yield* observePosixOwnershipLedgerContinuously({
692 childQueues,
693 controller,
694 frontier,
695 ledger,
696 root,
697 rootPid: 100,
698 }).pipe(Effect.forkIn(scope));
699 const timerStartedAt = yield* Clock.currentTimeMillis;
700 yield* Effect.sleep(0);
701 const timerDelay = (yield* Clock.currentTimeMillis) - timerStartedAt;
702 yield* Effect.sleep("80 millis");
703
704 expect(timerDelay).toBeLessThan(250);
705 expect(ledgerValuesCalls).toBe(0);
706 expect(snapshotCalls).toBe(0);
707 expect(childrenCalls).toBeGreaterThan(0);
708 expect(childrenCalls).toBeLessThanOrEqual(12);
709 const callsAtClose = identityCalls + childrenCalls;
710 yield* Scope.close(scope, Exit.void);
711 yield* Effect.sleep("60 millis");
712 expect(identityCalls + childrenCalls).toBe(callsAtClose);
713 }),
714 );
715
716 it.live("terminates nested owned groups bottom-up and catches a TERM fork race", () =>
717 Effect.gen(function* () {
718 let forked = false;
719 const fixture = makeController({
720 processes: [
721 server(),
722 identity(100, process.pid, 100, 100),
723 identity(110, 100, 110, 110),
724 identity(115, 110, 115, 110),
725 identity(120, 110, 120, 120),
726 identity(121, 120, 120, 120),
727 ],
728 onProcess: (processes, pid, signal) => {
729 if (signal === "SIGTERM" && pid === 121 && !forked) {
730 forked = true;
731 processes.set(122, identity(122, 110, 122, 122));
732 }
733 processes.delete(pid);
734 },
735 });
736
737 yield* terminatePosixOwnedProcessTree({
738 controller: fixture.controller,
739 grace: 0,
740 rootPid: 100,
741 });
742
743 expect(fixture.processes.has(100)).toBe(false);
744 expect(fixture.processes.has(122)).toBe(false);
745 expect(fixture.signals.indexOf("process:121:SIGTERM")).toBeLessThan(
746 fixture.signals.indexOf("process:120:SIGTERM"),
747 );
748 expect(fixture.signals.indexOf("process:115:SIGTERM")).toBeLessThan(
749 fixture.signals.indexOf("process:110:SIGTERM"),
750 );
751 }),
752 );
753
754 it.live("falls back to exact PIDs for unknown group members and a missing leader", () =>
755 Effect.gen(function* () {
756 const unrelated = identity(999, 1, 110, 110, "unrelated");
757 const fixture = makeController({
758 processes: [
759 server(),
760 identity(100, process.pid, 100, 100),
761 identity(110, 100, 110, 110),
762 identity(111, 110, 110, 110),
763 unrelated,
764 ],
765 onProcess: (processes, pid) => {
766 processes.delete(pid);
767 if (pid === 111) processes.delete(110);
768 },
769 });
770
771 yield* terminatePosixOwnedProcessTree({
772 controller: fixture.controller,
773 grace: 0,
774 rootPid: 100,
775 });
776
777 expect(fixture.signals).toContain("process:111:SIGTERM");
778 expect(fixture.processes.get(999)).toEqual(unrelated);
779 }),
780 );
781
782 it.live("re-admits a still-owned child after PID reuse and never signals the T3 session", () =>
783 Effect.gen(function* () {
784 const reused = identity(110, 100, 110, 110, "reused");
785 const fixture = makeController({
786 processes: [
787 server(),
788 identity(100, process.pid, 100, 100),
789 identity(110, 100, 110, 110, "owned"),
790 identity(120, 100, 120, process.pid),
791 ],
792 onProcess: (processes, pid) => {
793 if (pid === 110) processes.set(110, reused);
794 else processes.delete(pid);
795 },
796 });
797
798 const result = yield* Effect.exit(
799 terminatePosixOwnedProcessTree({
800 controller: fixture.controller,
801 grace: 0,
802 rootPid: 100,
803 }),
804 );
805
806 // PID 110 morphs to a new identity under the owned root and never exits, so
807 // teardown fails closed after re-admitting and re-signalling the live child.
808 expect(Exit.isFailure(result)).toBe(true);
809 expect(fixture.processes.get(110)).toEqual(reused);
810 expect(
811 fixture.signals.filter((entry) => entry.startsWith("process:110:")).length,
812 ).toBeGreaterThan(0);
813 expect(fixture.signals.some((entry) => entry.includes(":120:"))).toBe(false);
814 }),
815 );
816
817 it.live("does not treat zombie residual entries as teardown survivors", () =>
818 Effect.gen(function* () {
819 expect(posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "Z" })).toBe(true);
820 expect(posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "S" })).toBe(false);
821 const withoutState: AcpPosixProcessIdentity = {
822 executable: "/proc/1/exe",
823 pgid: 1,
824 pid: 1,
825 ppid: 0,
826 sid: 1,
827 startTime: "1",
828 };
829 expect(posixProcessIsZombie(withoutState)).toBe(false);
830
831 const root = identity(100, process.pid, 100, 100);
832 const zombieChild = identity(110, 100, 110, 110, "110", "Z");
833 const fixture = makeController({
834 processes: [server(), root, zombieChild],
835 onProcess: (processes, pid) => {
836 // Kill leaves a zombie with the same pid/starttime until reaped.
837 const current = processes.get(pid);
838 if (current === undefined) return;
839 processes.set(pid, { ...current, state: "Z" });
840 },
841 });
842 yield* terminatePosixOwnedProcessTree({
843 controller: fixture.controller,
844 grace: 0,
845 rootPid: 100,
846 });
847 // Zombies remain visible but must not fail residual teardown.
848 expect(fixture.processes.get(100)?.state).toBe("Z");
849 expect(fixture.processes.get(110)?.state).toBe("Z");
850 }),
851 );
852
853 it("admits a live child after a free PID was previously reserved by a dead entry", () => {
854 const staleChild: AcpOwnedPosixProcess = {
855 ...identity(200, 100, 200, 200, "stale-child"),
856 parentExecutable: `/proc/100/exe`,
857 parentStartTime: "100",
858 };
859 const liveChild = identity(200, 100, 200, 200, "live-child");
860 const root = identity(100, process.pid, 100, 100);
861 const ownedRoot: AcpOwnedPosixProcess = {
862 ...root,
863 parentExecutable: `/proc/${process.pid}/exe`,
864 parentStartTime: "server",
865 };
866 const ledger = new Map<string, AcpOwnedPosixProcess>([
867 [`100:${root.startTime}`, ownedRoot],
868 [`200:${staleChild.startTime}`, staleChild],
869 ]);
870 const controller = makeController({
871 processes: [server(), root],
872 }).controller;
873
874 // First capture: PID 200 is free, so the dead reservation is dropped.
875 capturePosixOwnershipLedger({
876 controller,
877 ledger,
878 rootPid: 100,
879 table: [root],
880 });
881 expect([...ledger.values()].some((entry) => entry.pid === 200)).toBe(false);
882
883 // Second capture: a new process reuses PID 200 under the owned parent.
884 capturePosixOwnershipLedger({
885 controller: makeController({ processes: [server(), root, liveChild] }).controller,
886 ledger,
887 rootPid: 100,
888 table: [root, liveChild],
889 });
890
891 const admitted = [...ledger.values()].find((entry) => entry.pid === 200);
892 expect(admitted?.startTime).toBe("live-child");
893 });
894
895 it("admits a live child when a stale ledger entry still occupies the reused PID", () => {
896 const staleChild: AcpOwnedPosixProcess = {
897 ...identity(200, 100, 200, 200, "stale-child"),
898 parentExecutable: `/proc/100/exe`,
899 parentStartTime: "100",
900 };
901 const liveChild = identity(200, 100, 200, 200, "live-child");
902 const root = identity(100, process.pid, 100, 100);
903 const ownedRoot: AcpOwnedPosixProcess = {
904 ...root,
905 parentExecutable: `/proc/${process.pid}/exe`,
906 parentStartTime: "server",
907 };
908 const ledger = new Map<string, AcpOwnedPosixProcess>([
909 [`100:${root.startTime}`, ownedRoot],
910 [`200:${staleChild.startTime}`, staleChild],
911 ]);
912
913 capturePosixOwnershipLedger({
914 controller: makeController({ processes: [server(), root, liveChild] }).controller,
915 ledger,
916 rootPid: 100,
917 table: [root, liveChild],
918 });
919
920 expect([...ledger.values()].some((entry) => entry.startTime === "stale-child")).toBe(false);
921 const admitted = [...ledger.values()].find((entry) => entry.pid === 200);
922 expect(admitted?.startTime).toBe("live-child");
923 });
924
925 it.live("signals children forked under a still-live parent during the same pass", () =>
926 Effect.gen(function* () {
927 let forked = false;
928 const fixture = makeController({
929 processes: [
930 server(),
931 identity(100, process.pid, 100, 100),
932 identity(110, 100, 110, 110),
933 identity(111, 110, 111, 111),
934 ],
935 onProcess: (processes, pid) => {
936 // Bottom-up: signal 111 first while 110 is still live, then fork 112
937 // under 110 so the same pass must enqueue and signal 112.
938 if (pid === 111 && !forked) {
939 forked = true;
940 processes.set(112, identity(112, 110, 112, 112));
941 }
942 processes.delete(pid);
943 },
944 });
945
946 yield* terminatePosixOwnedProcessTree({
947 controller: fixture.controller,
948 grace: 0,
949 rootPid: 100,
950 });
951
952 expect(forked).toBe(true);
953 expect(fixture.processes.has(112)).toBe(false);
954 expect(fixture.signals.some((entry) => entry.startsWith("process:112:"))).toBe(true);
955 }),
956 );
957
958 it.live("retains the ledger across root exit and an explicit failure for finalizer retry", () =>
959 Effect.gen(function* () {
960 let ignoreSignals = true;
961 const ledger = new Map<string, AcpOwnedPosixProcess>();
962 const frontier = new Map<number, AcpOwnedPosixProcess>();
963 const childQueues = new Map<number, Array<number>>();
964 const root: AcpPosixOwnershipRoot = { value: undefined };
965 const fixture = makeController({
966 processes: [
967 server(),
968 identity(100, process.pid, 100, 100),
969 identity(110, 100, 110, 110),
970 identity(111, 110, 110, 110),
971 ],
972 onProcess: (processes, pid) => {
973 if (ignoreSignals && pid === 100) processes.delete(100);
974 if (!ignoreSignals) processes.delete(pid);
975 },
976 });
977 observePosixOwnershipLedger({
978 childQueues,
979 controller: fixture.controller,
980 frontier,
981 ledger,
982 root,
983 rootPid: 100,
984 });
985
986 const explicit = yield* Effect.exit(
987 terminatePosixOwnedProcessTree({
988 controller: fixture.controller,
989 grace: 0,
990 ledger,
991 rootPid: 100,
992 }),
993 );
994 expect(Exit.isFailure(explicit)).toBe(true);
995 expect(fixture.processes.has(100)).toBe(false);
996 expect(fixture.processes.has(111)).toBe(true);
997
998 fixture.processes.set(112, identity(112, 110, 112, 112));
999 observePosixOwnershipLedger({
1000 childQueues,
1001 controller: fixture.controller,
1002 frontier,
1003 ledger,
1004 root,
1005 rootPid: 100,
1006 });
1007 ignoreSignals = false;
1008 yield* terminatePosixOwnedProcessTree({
1009 controller: fixture.controller,
1010 grace: 0,
1011 ledger,
1012 rootPid: 100,
1013 });
1014 expect(fixture.processes.has(110)).toBe(false);
1015 expect(fixture.processes.has(111)).toBe(false);
1016 expect(fixture.processes.has(112)).toBe(false);
1017 }),
1018 );
1019
1020 it("fails closed where a stable POSIX identity provider is unavailable", () => {
1021 try {
1022 makePosixProcessTreeController("darwin");
1023 throw new Error("Expected Darwin process ownership to fail closed");
1024 } catch (error) {
1025 expect(error).toMatchObject({
1026 detail: "Detached ACP descendant ownership is unsupported on darwin",
1027 });
1028 }
1029 });
1030});
apps/server/src/provider/acp/AcpSessionRuntime.ts+1299−41
@@ -1,3 +1,8 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeCrypto from "node:crypto";
3import * as NodeFS from "node:fs";
4import * as NodePath from "node:path";
5
1import * as Cause from "effect/Cause";6import * as Cause from "effect/Cause";
2import * as Clock from "effect/Clock";7import * as Clock from "effect/Clock";
3import * as Context from "effect/Context";8import * as Context from "effect/Context";
@@ -5,11 +10,13 @@ import * as Crypto from "effect/Crypto"; · 1 unmodified line
5import * as Deferred from "effect/Deferred";10import * as Deferred from "effect/Deferred";
6import * as Duration from "effect/Duration";11import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
8import * as Fiber from "effect/Fiber";14import * as Fiber from "effect/Fiber";
9import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";16import * as Option from "effect/Option";
11import * as Queue from "effect/Queue";17import * as Queue from "effect/Queue";
12import * as Ref from "effect/Ref";18import * as Ref from "effect/Ref";
19import * as Schema from "effect/Schema";
13import * as Scope from "effect/Scope";20import * as Scope from "effect/Scope";
14import * as Semaphore from "effect/Semaphore";21import * as Semaphore from "effect/Semaphore";
15import * as Stream from "effect/Stream";22import * as Stream from "effect/Stream";
@@ -20,6 +27,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; · 4 unmodified lines
20import type * as EffectAcpSchema from "effect-acp/schema";27import type * as EffectAcpSchema from "effect-acp/schema";
21import type * as EffectAcpProtocol from "effect-acp/protocol";28import type * as EffectAcpProtocol from "effect-acp/protocol";
22import { resolveSpawnCommand } from "@t3tools/shared/shell";29import { resolveSpawnCommand } from "@t3tools/shared/shell";
30import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
23 31
24import {32import {
25 collectSessionConfigOptionValues,33 collectSessionConfigOptionValues,
@@ -28,6 +36,7 @@ import { · 2 unmodified lines
28 mergeToolCallState,36 mergeToolCallState,
29 parseSessionModeState,37 parseSessionModeState,
30 parseSessionUpdateEvent,38 parseSessionUpdateEvent,
39 sessionUpdateCountsAsLoadReplayActivity,
31 sessionUpdateIsReplay,40 sessionUpdateIsReplay,
32 waitForSessionLoadReplayIdle,41 waitForSessionLoadReplayIdle,
33 type SessionLoadGate,42 type SessionLoadGate,
@@ -63,6 +72,16 @@ export interface AcpSessionRuntimeOptions { · 29 unmodified lines
63 readonly resumeSessionId?: string;72 readonly resumeSessionId?: string;
64 readonly sessionLoadTimeout?: Duration.Input;73 readonly sessionLoadTimeout?: Duration.Input;
65 readonly sessionLoadReplayIdleGap?: Duration.Input;74 readonly sessionLoadReplayIdleGap?: Duration.Input;
75 readonly interruptPromptOnCancel?: boolean;
76 readonly ownDetachedProcessGroup?: boolean;
77 readonly ownDescendantProcessGroups?: boolean;
78 readonly processGroupPlatform?: NodeJS.Platform;
79 readonly processGroupTerminationGrace?: Duration.Input;
80 readonly windowsProcessTreeTerminator?: (
81 pid: number,
82 ) => Effect.Effect<void, AcpProcessGroupTerminationError>;
83 readonly posixProcessTreeController?: AcpPosixProcessTreeController;
84 readonly linuxCgroupController?: AcpLinuxCgroupController | null;
66 readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"];85 readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"];
67 readonly clientInfo: {86 readonly clientInfo: {
68 readonly name: string;87 readonly name: string;
@@ -76,6 +95,10 @@ export interface AcpSessionRuntimeOptions { · 7 unmodified lines
76 readonly logOutgoing?: boolean;95 readonly logOutgoing?: boolean;
77 readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>;96 readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>;
78 };97 };
98 readonly onIncomingRequest?: EffectAcpClient.AcpClientOptions["onIncomingRequest"];
99 readonly onTermination?: (error: EffectAcpErrors.AcpError) => Effect.Effect<void>;
100 readonly onOutgoingResponseFailure?: EffectAcpClient.AcpClientOptions["onOutgoingResponseFailure"];
101 readonly onOutgoingResponse?: EffectAcpClient.AcpClientOptions["onOutgoingResponse"];
79}102}
80 103
81export interface AcpSessionRequestLogEvent {104export interface AcpSessionRequestLogEvent {
@@ -86,6 +109,925 @@ export interface AcpSessionRequestLogEvent { · 4 unmodified lines
86 readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>;109 readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>;
87}110}
88 111
112export class AcpProcessGroupTerminationError extends Schema.TaggedErrorClass<AcpProcessGroupTerminationError>()(
113 "AcpProcessGroupTerminationError",
114 {
115 detail: Schema.String,
116 pid: Schema.optionalKey(Schema.Int),
117 exitCode: Schema.optionalKey(Schema.Number),
118 cause: Schema.optional(Schema.Defect()),
119 },
120) {
121 override get message(): string {
122 return this.detail;
123 }
124}
125
126const isAcpProcessGroupTerminationError = Schema.is(AcpProcessGroupTerminationError);
127
128export interface AcpPosixProcessIdentity {
129 readonly executable: string | undefined;
130 readonly pgid: number;
131 readonly pid: number;
132 readonly ppid: number;
133 readonly sid: number;
134 /**
135 * Linux `/proc/<pid>/stat` state when known (`R`/`S`/`D`/`Z`/…). Omitted on
136 * platforms or fixtures that do not surface it. Zombies (`Z`) are already
137 * dead and must not count as residual teardown survivors.
138 */
139 readonly state?: string;
140 readonly startTime: string;
141}
142
143export interface AcpPosixProcessTreeController {
144 readonly childPidsOf: (pid: number) => ReadonlyArray<number>;
145 readonly childrenOf: (pid: number) => ReadonlyArray<AcpPosixProcessIdentity>;
146 readonly identity: (pid: number) => AcpPosixProcessIdentity | undefined;
147 readonly snapshot: () => ReadonlyArray<AcpPosixProcessIdentity>;
148 readonly signalProcess: (pid: number, signal: NodeJS.Signals) => void;
149}
150
151export interface AcpLinuxCgroupLease {
152 readonly contains: (pid: number) => boolean;
153 readonly exists: () => boolean;
154 readonly path: string;
155 readonly relativePath: string;
156 readonly kill: () => void;
157 readonly populated: () => boolean;
158 readonly remove: () => void;
159}
160
161export interface AcpLinuxCgroupController {
162 readonly create: () => AcpLinuxCgroupLease | undefined;
163}
164
165export interface AcpPosixOwnershipRoot {
166 captureAttempted?: boolean;
167 value: AcpOwnedPosixProcess | undefined;
168}
169
170function decodeMountInfoPath(value: string): string {
171 return value.replace(/\\([0-7]{3})/g, (_match, octal: string) =>
172 String.fromCharCode(Number.parseInt(octal, 8)),
173 );
174}
175
176export function parseUnifiedCgroupPath(contents: string): string | undefined {
177 const matches = [...contents.matchAll(/^0::(\/.*)$/gm)];
178 return matches.length === 1 ? matches[0]![1] : undefined;
179}
180
181export function isSafeCgroupPath(value: string): boolean {
182 return (
183 value.startsWith("/") &&
184 !value.includes("(deleted)") &&
185 NodePath.posix.normalize(value) === value
186 );
187}
188
189export function parseCgroup2Mounts(
190 contents: string,
191): ReadonlyArray<{ readonly mountPoint: string; readonly root: string }> {
192 const mounts: Array<{ readonly mountPoint: string; readonly root: string }> = [];
193 for (const line of contents.split("\n")) {
194 const [mount, filesystem] = line.split(" - ");
195 if (mount === undefined || filesystem?.split(" ")[0] !== "cgroup2") continue;
196 const fields = mount.split(" ");
197 if (fields.length < 5) continue;
198 mounts.push({
199 mountPoint: decodeMountInfoPath(fields[4]!),
200 root: decodeMountInfoPath(fields[3]!),
201 });
202 }
203 return mounts;
204}
205
206export function parseCgroup2Mount(
207 contents: string,
208): { readonly mountPoint: string; readonly root: string } | undefined {
209 return parseCgroup2Mounts(contents)[0];
210}
211
212export function wrapCommandForLinuxCgroup(
213 lease: AcpLinuxCgroupLease,
214 command: string,
215 args: ReadonlyArray<string>,
216): { readonly command: string; readonly args: ReadonlyArray<string> } {
217 return {
218 command: process.execPath,
219 args: [
220 "-e",
221 [
222 'const fs = require("node:fs");',
223 "try {",
224 ' fs.writeFileSync(process.argv[1] + "/cgroup.procs", String(process.pid) + "\\n");',
225 ' const actual = fs.readFileSync("/proc/self/cgroup", "utf8").split("\\n").find((line) => line.startsWith("0::"))?.slice(3);',
226 " if (actual !== process.argv[2]) process.exit(126);",
227 " const env = { ...process.env };",
228 " delete env.ELECTRON_RUN_AS_NODE;",
229 " delete env.T3_ACP_CGROUP_WRAPPER;",
230 " process.execve(process.argv[3], process.argv.slice(3), env);",
231 "} catch { process.exit(125); }",
232 ].join("\n"),
233 lease.path,
234 lease.relativePath,
235 command,
236 ...args,
237 ],
238 };
239}
240
241export function resolveLinuxCgroupTargetCommand(
242 command: string,
243 cwd: string,
244 environment: NodeJS.ProcessEnv,
245): string | undefined {
246 // Match Node spawn PATH fallback when PATH is undefined. An explicitly empty
247 // PATH stays empty (lookup only via empty-segment → cwd).
248 const pathEnv = environment.PATH === undefined ? "/usr/bin:/bin" : environment.PATH;
249 const candidates = command.includes(NodePath.sep)
250 ? [NodePath.resolve(cwd, command)]
251 : pathEnv
252 .split(NodePath.delimiter)
253 .map((entry) =>
254 NodePath.join(
255 entry.length === 0
256 ? cwd
257 : NodePath.isAbsolute(entry)
258 ? entry
259 : NodePath.resolve(cwd, entry),
260 command,
261 ),
262 );
263 for (const candidate of candidates) {
264 try {
265 if (!NodeFS.statSync(candidate).isFile()) continue;
266 NodeFS.accessSync(candidate, NodeFS.constants.X_OK);
267 return candidate;
268 } catch {
269 // Try the next PATH entry.
270 }
271 }
272 return undefined;
273}
274
275const STALE_ACP_CGROUP_SIBLING = /^t3-acp-(\d+)-/;
276
277export interface SweepStaleLinuxCgroupSiblingsOptions {
278 readonly currentPid?: number;
279 readonly isProcessAlive?: (pid: number) => boolean;
280 readonly readPopulated?: (siblingPath: string) => "0" | "1" | undefined;
281 readonly remove?: (siblingPath: string) => void;
282}
283
284function defaultCgroupOwnerIsAlive(pid: number): boolean {
285 try {
286 process.kill(pid, 0);
287 return true;
288 } catch (cause) {
289 return (cause as NodeJS.ErrnoException | undefined)?.code !== "ESRCH";
290 }
291}
292
293function defaultReadCgroupPopulated(siblingPath: string): "0" | "1" | undefined {
294 const state = /(?:^|\n)populated ([01])(?:\n|$)/.exec(
295 NodeFS.readFileSync(NodePath.join(siblingPath, "cgroup.events"), "utf8"),
296 )?.[1];
297 return state === "0" || state === "1" ? state : undefined;
298}
299
300/** Best-effort removal of empty `t3-acp-<dead-pid>-*` sibling leases under a parent cgroup. */
301export function sweepStaleLinuxCgroupSiblings(
302 parentPath: string,
303 options: SweepStaleLinuxCgroupSiblingsOptions = {},
304): void {
305 const currentPid = options.currentPid ?? process.pid;
306 const isProcessAlive = options.isProcessAlive ?? defaultCgroupOwnerIsAlive;
307 const readPopulated = options.readPopulated ?? defaultReadCgroupPopulated;
308 const remove = options.remove ?? ((siblingPath: string) => NodeFS.rmdirSync(siblingPath));
309 try {
310 for (const entry of NodeFS.readdirSync(parentPath, { withFileTypes: true })) {
311 try {
312 if (!entry.isDirectory()) continue;
313 const match = STALE_ACP_CGROUP_SIBLING.exec(entry.name);
314 if (match === null) continue;
315 const ownerPid = Number(match[1]);
316 if (!Number.isSafeInteger(ownerPid) || ownerPid <= 1 || ownerPid === currentPid) continue;
317 if (isProcessAlive(ownerPid)) continue;
318 const siblingPath = NodePath.join(parentPath, entry.name);
319 if (NodePath.dirname(siblingPath) !== parentPath) continue;
320 if (readPopulated(siblingPath) !== "0") continue;
321 remove(siblingPath);
322 } catch {
323 // Ignore unreadable or busy siblings; never fail lease creation.
324 }
325 }
326 } catch {
327 // Ignore readdir failures on the parent path.
328 }
329}
330
331function tryCreateLinuxCgroupLease(
332 currentRelative: string,
333 mount: { readonly mountPoint: string; readonly root: string },
334): AcpLinuxCgroupLease | undefined {
335 if (
336 !isSafeCgroupPath(mount.root) ||
337 !isSafeCgroupPath(mount.mountPoint) ||
338 !(
339 mount.root === "/" ||
340 currentRelative === mount.root ||
341 currentRelative.startsWith(`${mount.root}/`)
342 )
343 ) {
344 return undefined;
345 }
346 const mountRelative =
347 mount.root === "/" ? currentRelative : currentRelative.slice(mount.root.length);
348 const currentPath = NodePath.resolve(mount.mountPoint, `.${mountRelative}`);
349 const resolvedMount = NodePath.resolve(mount.mountPoint);
350 if (currentPath !== resolvedMount && !currentPath.startsWith(`${resolvedMount}${NodePath.sep}`)) {
351 return undefined;
352 }
353 sweepStaleLinuxCgroupSiblings(currentPath);
354 const childName = `t3-acp-${process.pid}-${NodeCrypto.randomUUID().replaceAll("-", "")}`;
355 const childPath = NodePath.join(currentPath, childName);
356 const childRelative = NodePath.posix.join(currentRelative, childName);
357 if (NodePath.dirname(childPath) !== currentPath) return undefined;
358 try {
359 NodeFS.mkdirSync(childPath, { mode: 0o700 });
360 NodeFS.accessSync(NodePath.join(childPath, "cgroup.procs"), NodeFS.constants.W_OK);
361 NodeFS.accessSync(NodePath.join(childPath, "cgroup.kill"), NodeFS.constants.W_OK);
362 NodeFS.accessSync(NodePath.join(childPath, "cgroup.events"), NodeFS.constants.R_OK);
363 if (NodeFS.readFileSync(NodePath.join(childPath, "cgroup.type"), "utf8").trim() !== "domain") {
364 throw new Error("ACP cgroup is not a domain cgroup");
365 }
366 if (typeof process.execve !== "function") throw new Error("process.execve is unavailable");
367 } catch {
368 try {
369 NodeFS.rmdirSync(childPath);
370 } catch {
371 // The unavailable cgroup was never used for a child process.
372 }
373 return undefined;
374 }
375 return {
376 contains: (pid) =>
377 parseUnifiedCgroupPath(NodeFS.readFileSync(`/proc/${pid}/cgroup`, "utf8")) === childRelative,
378 exists: () => NodeFS.existsSync(childPath),
379 path: childPath,
380 relativePath: childRelative,
381 kill: () => NodeFS.writeFileSync(NodePath.join(childPath, "cgroup.kill"), "1\n"),
382 populated: () => {
383 const state = /(?:^|\n)populated ([01])(?:\n|$)/.exec(
384 NodeFS.readFileSync(NodePath.join(childPath, "cgroup.events"), "utf8"),
385 )?.[1];
386 if (state === undefined) throw new Error("ACP cgroup.events has no populated state");
387 return state === "1";
388 },
389 remove: () => {
390 const directories = (path: string): ReadonlyArray<string> =>
391 NodeFS.readdirSync(path, { withFileTypes: true }).flatMap((entry) =>
392 entry.isDirectory()
393 ? [...directories(NodePath.join(path, entry.name)), NodePath.join(path, entry.name)]
394 : [],
395 );
396 for (const directory of directories(childPath)) NodeFS.rmdirSync(directory);
397 NodeFS.rmdirSync(childPath);
398 },
399 };
400}
401
402export function makeLinuxCgroupController(): AcpLinuxCgroupController {
403 return {
404 create: () => {
405 let currentRelative: string | undefined;
406 let mounts: ReadonlyArray<{ readonly mountPoint: string; readonly root: string }> = [];
407 try {
408 currentRelative = parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8"));
409 mounts = parseCgroup2Mounts(NodeFS.readFileSync("/proc/self/mountinfo", "utf8"));
410 } catch {
411 return undefined;
412 }
413 if (
414 currentRelative === undefined ||
415 !currentRelative.startsWith("/") ||
416 mounts.length === 0
417 ) {
418 return undefined;
419 }
420 if (!isSafeCgroupPath(currentRelative)) return undefined;
421 // Prefer the first mount that successfully creates a writable child cgroup.
422 // Multi-mount hosts often list a read-only view before the delegated one.
423 for (const mount of mounts) {
424 const lease = tryCreateLinuxCgroupLease(currentRelative, mount);
425 if (lease !== undefined) return lease;
426 }
427 return undefined;
428 },
429 };
430}
431
432// OS-state polls (cgroup emptiness, process trees) must use wall time. Under
433// TestClock, Effect.sleep never resolves unless the test advances the clock.
434const wallClock = Clock.Clock.defaultValue();
435const withWallClock = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
436 Effect.provideService(effect, Clock.Clock, wallClock);
437
438export function terminateLinuxCgroupLease(
439 lease: AcpLinuxCgroupLease,
440): Effect.Effect<void, AcpProcessGroupTerminationError> {
441 const failure = (name: string, cause: unknown) =>
442 new AcpProcessGroupTerminationError({
443 cause,
444 detail: `Failed to ${name} ACP cgroup ${lease.path}`,
445 });
446 return Effect.gen(function* () {
447 for (let attempt = 0; attempt < 200; attempt += 1) {
448 if (!lease.exists()) return;
449 try {
450 lease.kill();
451 } catch (cause) {
452 const code = (cause as NodeJS.ErrnoException | undefined)?.code;
453 if (code !== "ENOENT") return yield* failure("kill", cause);
454 if (!lease.exists()) return;
455 yield* Effect.sleep("10 millis");
456 continue;
457 }
458 let populated: boolean;
459 try {
460 populated = lease.populated();
461 } catch (cause) {
462 const code = (cause as NodeJS.ErrnoException | undefined)?.code;
463 if (code === "ENOENT" && !lease.exists()) return;
464 if (code === "ENOENT") {
465 yield* Effect.sleep("10 millis");
466 continue;
467 }
468 return yield* failure("read state for", cause);
469 }
470 if (populated) {
471 yield* Effect.sleep("10 millis");
472 continue;
473 }
474 try {
475 lease.remove();
476 } catch (cause) {
477 const code = (cause as NodeJS.ErrnoException | undefined)?.code;
478 if (code === "ENOENT" && !lease.exists()) return;
479 if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "ENOENT") {
480 return yield* failure("remove", cause);
481 }
482 }
483 if (!lease.exists()) return;
484 yield* Effect.sleep("10 millis");
485 }
486 return yield* new AcpProcessGroupTerminationError({
487 detail: `ACP cgroup ${lease.path} remained populated after cgroup.kill`,
488 });
489 }).pipe(withWallClock);
490}
491
492export function signalLinuxCgroupRootTerm(input: {
493 readonly controller: AcpPosixProcessTreeController;
494 readonly lease: AcpLinuxCgroupLease;
495 readonly root: AcpPosixOwnershipRoot;
496}): void {
497 const root = input.root.value;
498 if (root === undefined) return;
499 try {
500 const observed = input.controller.identity(root.pid);
501 if (!samePosixProcessIdentity(root, observed) || !input.lease.contains(root.pid)) return;
502 input.controller.signalProcess(root.pid, "SIGTERM");
503 } catch {
504 // TERM is optional. cgroup.kill remains the authoritative teardown.
505 }
506}
507
508function readLinuxProcessIdentity(pid: number): AcpPosixProcessIdentity | undefined {
509 try {
510 const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8");
511 const commandEnd = stat.lastIndexOf(")");
512 if (commandEnd < 0) return undefined;
513 const fields = stat
514 .slice(commandEnd + 2)
515 .trim()
516 .split(/\s+/);
517 // After comm: state, ppid, pgrp, session, … starttime (field 22 / index 19).
518 const state = fields[0];
519 const ppid = Number(fields[1]);
520 const pgid = Number(fields[2]);
521 const sid = Number(fields[3]);
522 const startTime = fields[19];
523 if (
524 state === undefined ||
525 !Number.isSafeInteger(ppid) ||
526 !Number.isSafeInteger(pgid) ||
527 !Number.isSafeInteger(sid) ||
528 startTime === undefined
529 ) {
530 return undefined;
531 }
532 let executable: string | undefined;
533 try {
534 executable = NodeFS.readlinkSync(`/proc/${pid}/exe`);
535 } catch {
536 executable = undefined;
537 }
538 return { executable, pgid, pid, ppid, sid, startTime, state };
539 } catch {
540 return undefined;
541 }
542}
543
544/** Linux zombies keep pid/starttime until reaped; they are not live survivors. */
545export function posixProcessIsZombie(process: AcpPosixProcessIdentity): boolean {
546 return process.state === "Z";
547}
548
549function readLinuxChildPids(pid: number): ReadonlyArray<number> {
550 const childPids = new Set<number>();
551 let taskIds: ReadonlyArray<string> = [];
552 try {
553 taskIds = NodeFS.readdirSync(`/proc/${pid}/task`).filter((entry) =>
554 /^[1-9][0-9]*$/.test(entry),
555 );
556 } catch {
557 return [];
558 }
559 for (const taskId of taskIds) {
560 try {
561 for (const childPid of NodeFS.readFileSync(`/proc/${pid}/task/${taskId}/children`, "utf8")
562 .trim()
563 .split(/\s+/)
564 .filter(Boolean)
565 .map(Number)) {
566 childPids.add(childPid);
567 }
568 } catch {
569 // The task exited while its children were being read.
570 }
571 }
572 return [...childPids];
573}
574
575function makeLinuxProcessTreeController(): AcpPosixProcessTreeController {
576 return {
577 childPidsOf: readLinuxChildPids,
578 childrenOf: (pid) =>
579 readLinuxChildPids(pid).flatMap((childPid) => {
580 const identity = readLinuxProcessIdentity(childPid);
581 return identity === undefined ? [] : [identity];
582 }),
583 identity: readLinuxProcessIdentity,
584 snapshot: () =>
585 NodeFS.readdirSync("/proc", { withFileTypes: true }).flatMap((entry) => {
586 if (!entry.isDirectory() || !/^[1-9][0-9]*$/.test(entry.name)) return [];
587 const identity = readLinuxProcessIdentity(Number(entry.name));
588 return identity === undefined ? [] : [identity];
589 }),
590 signalProcess: (pid, signal) => process.kill(pid, signal),
591 };
592}
593
594export function observePosixOwnershipLedger(input: {
595 readonly childQueues: Map<number, Array<number>>;
596 readonly controller: AcpPosixProcessTreeController;
597 readonly frontier: Map<number, AcpOwnedPosixProcess>;
598 readonly ledger: Map<string, AcpOwnedPosixProcess>;
599 readonly maxProcesses?: number;
600 readonly root: AcpPosixOwnershipRoot;
601 readonly rootPid: number;
602}): void {
603 let rootIdentity: AcpPosixProcessIdentity | undefined = input.root.value;
604 if (rootIdentity === undefined) {
605 if (input.root.captureAttempted === true) {
606 throw new AcpProcessGroupTerminationError({
607 detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`,
608 });
609 }
610 input.root.captureAttempted = true;
611 rootIdentity = input.controller.identity(input.rootPid);
612 if (rootIdentity === undefined) {
613 throw new AcpProcessGroupTerminationError({
614 detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`,
615 });
616 }
617 const parent = input.controller.identity(rootIdentity.ppid);
618 const ownedRoot = {
619 ...rootIdentity,
620 parentExecutable: parent?.executable,
621 parentStartTime: parent?.startTime ?? "",
622 };
623 input.ledger.set(processIdentityKey(rootIdentity), ownedRoot);
624 input.frontier.set(rootIdentity.pid, ownedRoot);
625 input.root.value = ownedRoot;
626 }
627 const maxProcesses = Math.max(1, input.maxProcesses ?? Number.POSITIVE_INFINITY);
628 let remainingBudget = maxProcesses;
629 const pending = [...input.frontier.entries()];
630 const visited = new Set<number>();
631 while (pending.length > 0 && remainingBudget > 0) {
632 const entry = pending.shift();
633 if (entry === undefined) continue;
634 const [parentPid, parent] = entry;
635 input.frontier.delete(parentPid);
636 if (visited.has(parent.pid)) continue;
637 visited.add(parent.pid);
638 remainingBudget -= 1;
639 const observedParent = input.controller.identity(parent.pid);
640 if (!samePosixProcessIdentity(parent, observedParent)) {
641 input.childQueues.delete(parent.pid);
642 continue;
643 }
644 const refreshedParent = { ...parent, ...observedParent };
645 input.ledger.set(processIdentityKey(refreshedParent), refreshedParent);
646 input.frontier.set(refreshedParent.pid, refreshedParent);
647 let queuedChildren = input.childQueues.get(refreshedParent.pid);
648 if (queuedChildren === undefined) {
649 if (remainingBudget === 0) continue;
650 remainingBudget -= 1;
651 queuedChildren = [...input.controller.childPidsOf(refreshedParent.pid)];
652 }
653 while (queuedChildren.length > 0 && remainingBudget > 0) {
654 const childPid = queuedChildren.shift();
655 if (childPid === undefined) continue;
656 remainingBudget -= 1;
657 const child = input.controller.identity(childPid);
658 if (child === undefined) continue;
659 if (child.ppid !== parent.pid) continue;
660 let reservedPid = input.frontier.get(child.pid);
661 if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, child)) {
662 // PID reused under an owned parent; drop the stale reservation.
663 input.frontier.delete(child.pid);
664 for (const [key, owned] of [...input.ledger.entries()]) {
665 if (owned.pid === child.pid) input.ledger.delete(key);
666 }
667 reservedPid = undefined;
668 }
669 const childKey = processIdentityKey(child);
670 const owned =
671 reservedPid === undefined
672 ? {
673 ...child,
674 parentExecutable: refreshedParent.executable,
675 parentStartTime: refreshedParent.startTime,
676 }
677 : { ...reservedPid, ...child };
678 input.ledger.set(childKey, owned);
679 input.frontier.set(child.pid, owned);
680 }
681 if (queuedChildren.length === 0) input.childQueues.delete(refreshedParent.pid);
682 else input.childQueues.set(refreshedParent.pid, queuedChildren);
683 }
684}
685
686export function observePosixOwnershipLedgerContinuously(input: {
687 readonly childQueues: Map<number, Array<number>>;
688 readonly controller: AcpPosixProcessTreeController;
689 readonly frontier: Map<number, AcpOwnedPosixProcess>;
690 readonly ledger: Map<string, AcpOwnedPosixProcess>;
691 readonly root: AcpPosixOwnershipRoot;
692 readonly rootPid: number;
693}): Effect.Effect<never> {
694 return Effect.forever(
695 Effect.gen(function* () {
696 const startedAt = yield* Clock.currentTimeMillis;
697 yield* Effect.sync(() => observePosixOwnershipLedger({ ...input, maxProcesses: 64 })).pipe(
698 Effect.exit,
699 );
700 const targetInterval = input.frontier.size <= 32 ? 25 : input.frontier.size <= 128 ? 50 : 100;
701 const completedAt = yield* Clock.currentTimeMillis;
702 yield* Effect.sleep(`${Math.max(5, targetInterval - (completedAt - startedAt))} millis`);
703 }),
704 ).pipe(withWallClock);
705}
706
707export function makePosixProcessTreeController(
708 platform: NodeJS.Platform,
709): AcpPosixProcessTreeController {
710 if (platform === "linux") return makeLinuxProcessTreeController();
711 throw new AcpProcessGroupTerminationError({
712 detail: `Detached ACP descendant ownership is unsupported on ${platform}`,
713 });
714}
715
716function samePosixProcessIdentity(
717 expected: AcpPosixProcessIdentity,
718 observed: AcpPosixProcessIdentity | undefined,
719): boolean {
720 return (
721 observed !== undefined &&
722 observed.pid === expected.pid &&
723 observed.startTime === expected.startTime
724 );
725}
726
727export interface AcpOwnedPosixProcess extends AcpPosixProcessIdentity {
728 readonly parentExecutable: string | undefined;
729 readonly parentStartTime: string;
730}
731
732function processIdentityKey(process: AcpPosixProcessIdentity): string {
733 return `${process.pid}:${process.startTime}`;
734}
735
736function processDepth(
737 process: AcpPosixProcessIdentity,
738 ledgerByPid: ReadonlyMap<number, AcpOwnedPosixProcess>,
739): number {
740 let depth = 0;
741 let current = process;
742 const seen = new Set<number>();
743 while (!seen.has(current.pid)) {
744 seen.add(current.pid);
745 const parent = ledgerByPid.get(current.ppid);
746 if (parent === undefined) break;
747 depth += 1;
748 current = parent;
749 }
750 return depth;
751}
752
753export function capturePosixOwnershipLedger(input: {
754 readonly controller: AcpPosixProcessTreeController;
755 readonly ledger: Map<string, AcpOwnedPosixProcess>;
756 readonly root?: AcpPosixOwnershipRoot;
757 readonly rootPid: number;
758 readonly table?: ReadonlyArray<AcpPosixProcessIdentity>;
759}): ReadonlyArray<AcpPosixProcessIdentity> {
760 let table: ReadonlyArray<AcpPosixProcessIdentity>;
761 if (input.table === undefined) {
762 try {
763 table = input.controller.snapshot();
764 } catch (cause) {
765 throw new AcpProcessGroupTerminationError({
766 cause,
767 detail: `Failed to snapshot ACP process tree ${input.rootPid}`,
768 });
769 }
770 } else {
771 table = input.table;
772 }
773 const byPid = new Map(table.map((process) => [process.pid, process]));
774 let rootIdentity: AcpPosixProcessIdentity | undefined =
775 input.root?.value ??
776 [...input.ledger.values()].find((process) => process.pid === input.rootPid);
777 if (rootIdentity === undefined) {
778 rootIdentity = byPid.get(input.rootPid);
779 if (rootIdentity === undefined) {
780 throw new AcpProcessGroupTerminationError({
781 detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`,
782 });
783 }
784 const ownedRoot = {
785 ...rootIdentity,
786 parentExecutable: byPid.get(rootIdentity.ppid)?.executable,
787 parentStartTime: byPid.get(rootIdentity.ppid)?.startTime ?? "",
788 };
789 input.ledger.set(processIdentityKey(rootIdentity), ownedRoot);
790 if (input.root !== undefined) input.root.value = ownedRoot;
791 }
792 // Drop non-root ledger entries whose PID is free or whose identity no longer
793 // matches the live process at that PID. Either case can block admission of a
794 // legitimate child after PID reuse. Keep the root reservation even if the
795 // root briefly disappears mid-teardown (finalizer retry still needs it).
796 for (const [key, owned] of [...input.ledger.entries()]) {
797 if (owned.pid === input.rootPid) continue;
798 const live = byPid.get(owned.pid);
799 if (live === undefined || !samePosixProcessIdentity(owned, live)) {
800 input.ledger.delete(key);
801 }
802 }
803 const children = new Map<number, Array<AcpPosixProcessIdentity>>();
804 const retainedByPid = new Map(
805 [...input.ledger.values()]
806 .filter((owned) => samePosixProcessIdentity(owned, byPid.get(owned.pid)))
807 .map((owned) => [owned.pid, owned]),
808 );
809 for (const process of table) {
810 const siblings = children.get(process.ppid) ?? [];
811 siblings.push(process);
812 children.set(process.ppid, siblings);
813 }
814 const pending = [...input.ledger.values()].filter((owned) =>
815 samePosixProcessIdentity(owned, byPid.get(owned.pid)),
816 );
817 const visited = new Set<number>();
818 while (pending.length > 0) {
819 const parent = pending.shift();
820 if (parent === undefined || visited.has(parent.pid)) continue;
821 visited.add(parent.pid);
822 const observedParent = byPid.get(parent.pid);
823 if (!samePosixProcessIdentity(parent, observedParent)) continue;
824 const refreshedParent = { ...parent, ...observedParent };
825 input.ledger.set(processIdentityKey(parent), refreshedParent);
826 for (const process of children.get(refreshedParent.pid) ?? []) {
827 const reservedPid = retainedByPid.get(process.pid);
828 if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, process)) continue;
829 const owned =
830 reservedPid === undefined
831 ? {
832 ...process,
833 parentExecutable: refreshedParent.executable,
834 parentStartTime: refreshedParent.startTime,
835 }
836 : { ...reservedPid, ...process };
837 input.ledger.set(processIdentityKey(process), owned);
838 retainedByPid.set(process.pid, owned);
839 pending.push(owned);
840 }
841 }
842 return table;
843}
844
845export function terminatePosixOwnedProcessTree(input: {
846 readonly controller: AcpPosixProcessTreeController;
847 readonly discoveryPasses?: number;
848 readonly grace?: Duration.Input;
849 readonly ledger?: Map<string, AcpOwnedPosixProcess>;
850 readonly root?: AcpPosixOwnershipRoot;
851 readonly rootPid: number;
852}): Effect.Effect<void, AcpProcessGroupTerminationError> {
853 // This portable fallback can only admit processes whose exact parent chain is
854 // visible before it breaks. A descendant that double-forks before discovery
855 // requires pre-exec cgroup containment for a hard ownership guarantee.
856 const ledger = input.ledger ?? new Map<string, AcpOwnedPosixProcess>();
857
858 const fail = (detail: string, cause?: unknown) =>
859 new AcpProcessGroupTerminationError({ detail, ...(cause === undefined ? {} : { cause }) });
860 const snapshot = () => {
861 try {
862 return input.controller.snapshot();
863 } catch (cause) {
864 throw fail(`Failed to snapshot ACP process tree ${input.rootPid}`, cause);
865 }
866 };
867 const discover = (table: ReadonlyArray<AcpPosixProcessIdentity>) =>
868 capturePosixOwnershipLedger({
869 controller: input.controller,
870 ledger,
871 ...(input.root === undefined ? {} : { root: input.root }),
872 rootPid: input.rootPid,
873 table,
874 });
875 const signalPhase = (signal: NodeJS.Signals, includeRoot: boolean) => {
876 const table = snapshot();
877 discover(table);
878 const byPid = new Map(table.map((entry) => [entry.pid, entry]));
879 const current = input.controller.identity(process.pid);
880 if (current === undefined) throw fail("Cannot identify the current T3 process group");
881 const ledgerByPid = new Map(
882 [...ledger.values()].map((process) => [process.pid, process] as const),
883 );
884 const pending = [...ledger.values()].filter(
885 (candidate) =>
886 (includeRoot || candidate.pid !== input.rootPid) &&
887 samePosixProcessIdentity(candidate, byPid.get(candidate.pid)),
888 );
889 const retainedByPid = new Map(
890 [...ledger.values()]
891 .filter((owned) => samePosixProcessIdentity(owned, byPid.get(owned.pid)))
892 .map((owned) => [owned.pid, owned]),
893 );
894 const signalled = new Set<number>();
895 while (pending.length > 0) {
896 pending.sort(
897 (left, right) => processDepth(right, ledgerByPid) - processDepth(left, ledgerByPid),
898 );
899 const candidate = pending.shift();
900 if (candidate === undefined || signalled.has(candidate.pid)) continue;
901 const observed = input.controller.identity(candidate.pid);
902 if (
903 candidate.pid <= 1 ||
904 candidate.pid === process.pid ||
905 observed === undefined ||
906 observed.pgid === current.pgid ||
907 observed.sid === current.sid ||
908 !samePosixProcessIdentity(candidate, observed)
909 ) {
910 continue;
911 }
912 for (const child of input.controller.childrenOf(candidate.pid)) {
913 if (child.ppid !== candidate.pid) continue;
914 const reservedPid = retainedByPid.get(child.pid);
915 if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, child)) continue;
916 const owned =
917 reservedPid === undefined
918 ? {
919 ...child,
920 parentExecutable: observed.executable,
921 parentStartTime: observed.startTime,
922 }
923 : { ...reservedPid, ...child };
924 ledger.set(processIdentityKey(child), owned);
925 ledgerByPid.set(child.pid, owned);
926 retainedByPid.set(child.pid, owned);
927 // Signal newly discovered children in this same pass (fork-during-kill).
928 if (
929 (includeRoot || child.pid !== input.rootPid) &&
930 !signalled.has(child.pid) &&
931 samePosixProcessIdentity(owned, input.controller.identity(child.pid))
932 ) {
933 pending.push(owned);
934 }
935 }
936 try {
937 // Linux starttime prevents PID reuse across observations, but Node does
938 // not expose pidfd_send_signal. A final read-to-kill race remains.
939 input.controller.signalProcess(candidate.pid, signal);
940 signalled.add(candidate.pid);
941 } catch (cause) {
942 if (samePosixProcessIdentity(candidate, input.controller.identity(candidate.pid))) {
943 throw fail(`Failed to signal ACP process ${candidate.pid}`, cause);
944 }
945 }
946 }
947 };
948
949 return Effect.gen(function* () {
950 const passes = Math.max(2, Math.min(input.discoveryPasses ?? 4, 8));
951 for (let pass = 0; pass < passes; pass += 1) {
952 yield* Effect.try({
953 try: () => signalPhase("SIGTERM", false),
954 catch: (cause) => cause as AcpProcessGroupTerminationError,
955 });
956 yield* Effect.sleep("10 millis");
957 }
958 yield* Effect.try({
959 try: () => signalPhase("SIGTERM", true),
960 catch: (cause) => cause as AcpProcessGroupTerminationError,
961 });
962 const grace = input.grace ?? "1 second";
963 if (grace !== 0) yield* Effect.sleep(grace);
964 for (let pass = 0; pass < passes; pass += 1) {
965 yield* Effect.try({
966 try: () => signalPhase("SIGKILL", true),
967 catch: (cause) => cause as AcpProcessGroupTerminationError,
968 });
969 yield* Effect.sleep("10 millis");
970 }
971 const survivors = new Map(snapshot().map((entry) => [entry.pid, entry]));
972 const residual = [...ledger.values()].filter((owned) => {
973 const observed = survivors.get(owned.pid);
974 if (!samePosixProcessIdentity(owned, observed) || observed === undefined) {
975 return false;
976 }
977 // Already-killed zombies still appear in /proc until reaped; not live.
978 return !posixProcessIsZombie(observed);
979 });
980 if (residual.length > 0) {
981 return yield* fail(
982 `ACP process tree ${input.rootPid} retained owned processes: ${residual.map((entry) => entry.pid).join(", ")}`,
983 );
984 }
985 }).pipe(withWallClock);
986}
987
988export function windowsTaskkillResultIsSuccess(exitCode: number, _output?: string): boolean {
989 return exitCode === 0;
990}
991
992export const terminateWindowsProcessTreeWithTaskkill = (
993 spawner: ChildProcessSpawner.ChildProcessSpawner["Service"],
994 pid: number,
995): Effect.Effect<void, AcpProcessGroupTerminationError> =>
996 Effect.scoped(
997 Effect.gen(function* () {
998 const taskkill = yield* spawner.spawn(
999 ChildProcess.make("taskkill", ["/PID", String(pid), "/T", "/F"]),
1000 );
1001 const outputFiber = yield* Stream.decodeText(taskkill.all).pipe(
1002 Stream.mkString,
1003 Effect.forkScoped,
1004 );
1005 const exitCode = Number(yield* taskkill.exitCode);
1006 const output = yield* Fiber.join(outputFiber);
1007 // A missing leader cannot prove that inherited descendants exited.
1008 if (windowsTaskkillResultIsSuccess(exitCode, output)) return;
1009 const trimmedOutput = output.trim();
1010 return yield* new AcpProcessGroupTerminationError({
1011 detail: `taskkill exited ${exitCode} for ACP process tree ${pid}`,
1012 pid,
1013 exitCode,
1014 ...(trimmedOutput.length === 0
1015 ? {}
1016 : { cause: trimmedOutput.length > 500 ? trimmedOutput.slice(0, 500) : trimmedOutput }),
1017 });
1018 }),
1019 ).pipe(
1020 Effect.mapError((cause) =>
1021 isAcpProcessGroupTerminationError(cause)
1022 ? cause
1023 : new AcpProcessGroupTerminationError({
1024 cause,
1025 detail: `Failed to run taskkill for ACP process tree ${pid}`,
1026 pid,
1027 }),
1028 ),
1029 );
1030
89export function selectAcpAgentAuthMethod(1031export function selectAcpAgentAuthMethod(
90 authMethods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined,1032 authMethods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined,
91 preferredMethodId?: string,1033 preferredMethodId?: string,
@@ -237,6 +1179,13 @@ export class AcpSessionRuntime extends Context.Service< · 145 unmodified lines
237 * @see https://agentclientprotocol.com/protocol/schema#session/cancel1179 * @see https://agentclientprotocol.com/protocol/schema#session/cancel
238 */1180 */
239 readonly cancel: Effect.Effect<void, EffectAcpErrors.AcpError>;1181 readonly cancel: Effect.Effect<void, EffectAcpErrors.AcpError>;
1182 readonly processContainment:
1183 | "cgroup-v2"
1184 | "process-ledger-reduced-guarantee"
1185 | "process-group"
1186 | "none";
1187 /** Terminates the opt-in detached ACP process group and all inherited work. */
1188 readonly terminateProcessGroup?: Effect.Effect<void, AcpProcessGroupTerminationError>;
240 /**1189 /**
241 * Selects the active mode through the negotiated `mode` configuration option.1190 * Selects the active mode through the negotiated `mode` configuration option.
242 * This is a no-op when the requested mode is already active.1191 * This is a no-op when the requested mode is already active.
@@ -341,6 +1290,7 @@ export const make = ( · 98 unmodified lines
341 const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined));1290 const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined));
342 const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" });1291 const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" });
343 const promptSerializationSemaphore = yield* Semaphore.make(1);1292 const promptSerializationSemaphore = yield* Semaphore.make(1);
1293 const sessionLoadSemaphore = yield* Semaphore.make(1);
344 const activePromptFiberRef = yield* Ref.make<1294 const activePromptFiberRef = yield* Ref.make<
345 Option.Option<Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>>1295 Option.Option<Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>>
346 >(Option.none());1296 >(Option.none());
@@ -382,12 +1332,70 @@ export const make = ( · 35 unmodified lines
382 options.spawn.args,1332 options.spawn.args,
383 options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {},1333 options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {},
384 );1334 );
1335 const linuxCgroupLease =
1336 options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux"
1337 ? yield* Effect.sync(() => {
1338 try {
1339 if (options.linuxCgroupController === null) return undefined;
1340 return (options.linuxCgroupController ?? makeLinuxCgroupController()).create();
1341 } catch {
1342 return undefined;
1343 }
1344 })
1345 : undefined;
1346 // This is lifecycle containment for inherited provider work. It is not a
1347 // security boundary against a provider that can modify its delegated cgroup.
1348 if (linuxCgroupLease !== undefined) {
1349 yield* Scope.addFinalizer(
1350 runtimeScope,
1351 Effect.uninterruptible(terminateLinuxCgroupLease(linuxCgroupLease)).pipe(
1352 Effect.ignoreCause({ log: true }),
1353 ),
1354 );
1355 }
1356 const containedTargetCommand =
1357 linuxCgroupLease === undefined || NodePath.isAbsolute(spawnCommand.command)
1358 ? spawnCommand.command
1359 : yield* Effect.gen(function* () {
1360 const resolved = resolveLinuxCgroupTargetCommand(
1361 spawnCommand.command,
1362 options.spawn.cwd ?? options.cwd,
1363 { ...process.env, ...options.spawn.env },
1364 );
1365 if (resolved !== undefined) return resolved;
1366 return yield* new EffectAcpErrors.AcpSpawnError({
1367 command: options.spawn.command,
1368 cause: new Error("Contained ACP command was not found on PATH"),
1369 });
1370 });
1371 const containedSpawnCommand =
1372 linuxCgroupLease === undefined
1373 ? spawnCommand
1374 : {
1375 ...wrapCommandForLinuxCgroup(
1376 linuxCgroupLease,
1377 containedTargetCommand,
1378 spawnCommand.args,
1379 ),
1380 shell: false,
1381 };
1382 const spawnEnvironment =
1383 linuxCgroupLease === undefined
1384 ? options.spawn.env
1385 : {
1386 ...options.spawn.env,
1387 ELECTRON_RUN_AS_NODE: "1",
1388 T3_ACP_CGROUP_WRAPPER: "1",
1389 };
385 const child = yield* spawner1390 const child = yield* spawner
386 .spawn(1391 .spawn(
387 ChildProcess.make(spawnCommand.command, spawnCommand.args, {1392 ChildProcess.make(containedSpawnCommand.command, containedSpawnCommand.args, {
388 ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}),1393 ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}),
389 ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}),1394 ...(spawnEnvironment ? { env: spawnEnvironment, extendEnv: true } : {}),
390 shell: spawnCommand.shell,1395 ...(options.ownDetachedProcessGroup === undefined
1396 ? {}
1397 : { detached: options.ownDetachedProcessGroup }),
1398 shell: containedSpawnCommand.shell,
391 }),1399 }),
392 )1400 )
393 .pipe(1401 .pipe(
@@ -401,6 +1409,155 @@ export const make = ( · 7 unmodified lines
401 ),1409 ),
402 );1410 );
403 1411
1412 const posixOwnershipLedger = new Map<string, AcpOwnedPosixProcess>();
1413 const posixOwnershipFrontier = new Map<number, AcpOwnedPosixProcess>();
1414 const posixOwnershipChildQueues = new Map<number, Array<number>>();
1415 const posixOwnershipRoot: AcpPosixOwnershipRoot = { value: undefined };
1416 const posixController =
1417 options.ownDescendantProcessGroups === true
1418 ? yield* Effect.try({
1419 try: () => {
1420 if (options.posixProcessTreeController !== undefined) {
1421 return options.posixProcessTreeController;
1422 }
1423 if (options.processGroupPlatform === undefined) {
1424 throw new AcpProcessGroupTerminationError({
1425 detail: "POSIX ACP descendant ownership requires a supported host platform",
1426 });
1427 }
1428 return makePosixProcessTreeController(options.processGroupPlatform);
1429 },
1430 catch: (cause) =>
1431 new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }),
1432 })
1433 : undefined;
1434 if (posixController !== undefined) {
1435 const observeOwnership = () =>
1436 observePosixOwnershipLedger({
1437 childQueues: posixOwnershipChildQueues,
1438 controller: posixController,
1439 frontier: posixOwnershipFrontier,
1440 ledger: posixOwnershipLedger,
1441 root: posixOwnershipRoot,
1442 rootPid: Number(child.pid),
1443 });
1444 yield* Effect.try({
1445 try: observeOwnership,
1446 catch: (cause) =>
1447 new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }),
1448 });
1449 yield* observePosixOwnershipLedgerContinuously({
1450 childQueues: posixOwnershipChildQueues,
1451 controller: posixController,
1452 frontier: posixOwnershipFrontier,
1453 ledger: posixOwnershipLedger,
1454 root: posixOwnershipRoot,
1455 rootPid: Number(child.pid),
1456 }).pipe(Effect.forkIn(runtimeScope));
1457 }
1458
1459 const signalOwnedProcessGroup = (signal: NodeJS.Signals) =>
1460 Effect.try({
1461 try: () => {
1462 process.kill(-Number(child.pid), signal);
1463 return true;
1464 },
1465 catch: (cause) =>
1466 new AcpProcessGroupTerminationError({
1467 cause,
1468 detail: `Failed to signal ACP process group ${child.pid} with ${signal}`,
1469 }),
1470 }).pipe(
1471 Effect.catch((error) => {
1472 const cause = error.cause as NodeJS.ErrnoException | undefined;
1473 return cause?.code === "ESRCH" ? Effect.succeed(false) : Effect.fail(error);
1474 }),
1475 );
1476 const terminateWindowsProcessTree =
1477 options.windowsProcessTreeTerminator ??
1478 ((pid: number) => terminateWindowsProcessTreeWithTaskkill(spawner, pid));
1479 const terminatePosixProcessTree = (grace: Duration.Input, platformOverride?: NodeJS.Platform) =>
1480 Effect.gen(function* () {
1481 const platform = platformOverride ?? options.processGroupPlatform;
1482 if (platform === undefined || platform === "win32") {
1483 return yield* new AcpProcessGroupTerminationError({
1484 detail: "POSIX ACP descendant ownership requires a supported host platform",
1485 });
1486 }
1487 const controller = yield* Effect.try({
1488 try: () => posixController ?? makePosixProcessTreeController(platform),
1489 catch: (cause) =>
1490 isAcpProcessGroupTerminationError(cause)
1491 ? cause
1492 : new AcpProcessGroupTerminationError({
1493 cause,
1494 detail: `Failed to initialize ACP descendant ownership on ${platform}`,
1495 }),
1496 });
1497 yield* terminatePosixOwnedProcessTree({
1498 controller,
1499 grace,
1500 ledger: posixOwnershipLedger,
1501 root: posixOwnershipRoot,
1502 rootPid: Number(child.pid),
1503 });
1504 });
1505 const terminateOwnedProcessGroupImpl = Effect.gen(function* () {
1506 if (options.ownDetachedProcessGroup !== true) return;
1507 if (options.processGroupPlatform === undefined) {
1508 return yield* new AcpProcessGroupTerminationError({
1509 detail: "Detached ACP process-group ownership requires a host platform",
1510 });
1511 }
1512 if (options.processGroupPlatform === "win32") {
1513 return yield* terminateWindowsProcessTree(Number(child.pid));
1514 }
1515 if (linuxCgroupLease !== undefined) {
1516 if (posixController !== undefined) {
1517 yield* Effect.sync(() =>
1518 signalLinuxCgroupRootTerm({
1519 controller: posixController,
1520 lease: linuxCgroupLease,
1521 root: posixOwnershipRoot,
1522 }),
1523 );
1524 }
1525 const grace = options.processGroupTerminationGrace ?? "1 second";
1526 if (grace !== 0) yield* Effect.sleep(grace);
1527 return yield* terminateLinuxCgroupLease(linuxCgroupLease);
1528 }
1529 if (options.ownDescendantProcessGroups === true) {
1530 return yield* terminatePosixProcessTree(options.processGroupTerminationGrace ?? "1 second");
1531 }
1532 const groupExisted = yield* signalOwnedProcessGroup("SIGTERM");
1533 if (!groupExisted) return;
1534 const grace = options.processGroupTerminationGrace ?? "1 second";
1535 if (grace !== 0) {
1536 yield* Effect.sleep(grace);
1537 }
1538 yield* signalOwnedProcessGroup("SIGKILL");
1539 }).pipe(withWallClock);
1540 const terminateProcessGroup = yield* Effect.cached(
1541 Effect.uninterruptible(terminateOwnedProcessGroupImpl),
1542 );
1543 if (options.ownDetachedProcessGroup === true) {
1544 const hostPlatform = yield* HostProcessPlatform;
1545 const forceTerminateOwnedProcessGroup =
1546 hostPlatform === "win32"
1547 ? terminateWindowsProcessTreeWithTaskkill(spawner, Number(child.pid))
1548 : linuxCgroupLease !== undefined
1549 ? terminateLinuxCgroupLease(linuxCgroupLease)
1550 : options.ownDescendantProcessGroups === true
1551 ? terminatePosixProcessTree(0, hostPlatform)
1552 : signalOwnedProcessGroup("SIGKILL").pipe(Effect.asVoid);
1553 yield* Scope.addFinalizer(
1554 runtimeScope,
1555 Effect.uninterruptible(forceTerminateOwnedProcessGroup).pipe(
1556 Effect.ignoreCause({ log: true }),
1557 ),
1558 );
1559 }
1560
404 const acpContext = yield* Layer.build(1561 const acpContext = yield* Layer.build(
405 EffectAcpClient.layerChildProcess(child, {1562 EffectAcpClient.layerChildProcess(child, {
406 ...(options.protocolLogging?.logIncoming !== undefined1563 ...(options.protocolLogging?.logIncoming !== undefined
@@ -410,6 +1567,17 @@ export const make = ( · 3 unmodified lines
410 ? { logOutgoing: options.protocolLogging.logOutgoing }1567 ? { logOutgoing: options.protocolLogging.logOutgoing }
411 : {}),1568 : {}),
412 ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}),1569 ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}),
1570 ...(options.onIncomingRequest ? { onIncomingRequest: options.onIncomingRequest } : {}),
1571 onTermination: (error) =>
1572 (options.onTermination?.(error) ?? Effect.void).pipe(
1573 Effect.ensuring(
1574 Scope.close(runtimeScope, Exit.fail(error)).pipe(Effect.forkDetach, Effect.asVoid),
1575 ),
1576 ),
1577 ...(options.onOutgoingResponseFailure
1578 ? { onOutgoingResponseFailure: options.onOutgoingResponseFailure }
1579 : {}),
1580 ...(options.onOutgoingResponse ? { onOutgoingResponse: options.onOutgoingResponse } : {}),
413 }),1581 }),
414 ).pipe(Effect.provideService(Scope.Scope, runtimeScope));1582 ).pipe(Effect.provideService(Scope.Scope, runtimeScope));
415 1583
@@ -419,14 +1587,16 @@ export const make = ( · 3 unmodified lines
419 Effect.gen(function* () {1587 Effect.gen(function* () {
420 const gate = yield* Ref.get(sessionLoadGateRef);1588 const gate = yield* Ref.get(sessionLoadGateRef);
421 if (Option.isSome(gate) && gate.value.active) {1589 if (Option.isSome(gate) && gate.value.active) {
422 const lastActivityAtMillis = yield* Clock.currentTimeMillis;1590 if (sessionUpdateCountsAsLoadReplayActivity(notification, gate.value.sessionId)) {
423 yield* Ref.set(1591 const lastActivityAtMillis = yield* Clock.currentTimeMillis;
424 sessionLoadGateRef,1592 yield* Ref.set(
425 Option.some({1593 sessionLoadGateRef,
426 ...gate.value,1594 Option.some({
427 lastActivityAtMillis,1595 ...gate.value,
428 }),1596 lastActivityAtMillis,
429 );1597 }),
1598 );
1599 }
430 return;1600 return;
431 }1601 }
432 if (sessionUpdateIsReplay(notification)) {1602 if (sessionUpdateIsReplay(notification)) {
@@ -550,20 +1720,121 @@ export const make = ( · 117 unmodified lines
550 ): Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError> =>1720 ): Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError> =>
551 Effect.gen(function* () {1721 Effect.gen(function* () {
552 const current = yield* getStartedState;1722 const current = yield* getStartedState;
1723 const meta = sessionSetupResult._meta;
1724 const syntheticReplayIdle =
1725 meta !== null &&
1726 typeof meta === "object" &&
1727 !Array.isArray(meta) &&
1728 (meta as { readonly t3SessionLoadReady?: unknown }).t3SessionLoadReady === "replay_idle";
1729 const extractedModelConfigId = extractModelConfigId(sessionSetupResult);
1730 const nextModelConfigId =
1731 extractedModelConfigId ?? (syntheticReplayIdle ? current.modelConfigId : undefined);
1732 const nextModeState = parseSessionModeState(sessionSetupResult);
1733 if (nextModeState !== undefined) {
1734 yield* Ref.set(modeStateRef, nextModeState);
1735 } else if (!syntheticReplayIdle) {
1736 yield* Ref.set(modeStateRef, undefined);
1737 }
1738 // Synthetic replay-idle load responses only carry initialize model/mode
1739 // meta; preserve live configOptions so setConfigOption still validates.
1740 if (
1741 sessionSetupResult.configOptions !== undefined &&
1742 sessionSetupResult.configOptions !== null
1743 ) {
1744 yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult));
1745 } else if (!syntheticReplayIdle) {
1746 yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult));
1747 }
553 const nextState = {1748 const nextState = {
554 sessionId,1749 sessionId,
555 initializeResult: current.initializeResult,1750 initializeResult: current.initializeResult,
556 sessionSetupResult,1751 sessionSetupResult,
557 modelConfigId: extractModelConfigId(sessionSetupResult),1752 modelConfigId: nextModelConfigId,
558 } satisfies AcpStartedState;1753 } satisfies AcpStartedState;
559 yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult));
560 yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult));
561 yield* Ref.set(toolCallsRef, new Map());1754 yield* Ref.set(toolCallsRef, new Map());
562 yield* Ref.set(assistantSegmentRef, { nextSegmentIndex: 0 });1755 yield* Ref.set(assistantSegmentRef, { nextSegmentIndex: 0 });
563 yield* Ref.set(startStateRef, { _tag: "Started", result: nextState });1756 yield* Ref.set(startStateRef, { _tag: "Started", result: nextState });
564 return nextState;1757 return nextState;
565 });1758 });
566 1759
1760 const runLoadSessionWithReplayIdle = (
1761 loadPayload: EffectAcpSchema.LoadSessionRequest,
1762 initializeResult: EffectAcpSchema.InitializeResponse,
1763 ): Effect.Effect<EffectAcpSchema.LoadSessionResponse, EffectAcpErrors.AcpError> =>
1764 sessionLoadSemaphore.withPermits(1)(
1765 Effect.gen(function* () {
1766 const sessionLoadTimeout = Duration.fromInputUnsafe(
1767 options.sessionLoadTimeout ?? defaultSessionLoadTimeout,
1768 );
1769 const sessionLoadReplayIdleGap = Duration.fromInputUnsafe(
1770 options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap,
1771 );
1772
1773 yield* Ref.set(
1774 sessionLoadGateRef,
1775 Option.some({
1776 active: true,
1777 sessionId: loadPayload.sessionId,
1778 lastActivityAtMillis: undefined,
1779 idleGap: sessionLoadReplayIdleGap,
1780 initializeResult,
1781 }),
1782 );
1783
1784 return yield* Effect.gen(function* () {
1785 yield* logRequest({
1786 method: "session/load",
1787 payload: loadPayload,
1788 status: "started",
1789 });
1790
1791 const idleFiber = yield* waitForSessionLoadReplayIdle({
1792 gateRef: sessionLoadGateRef,
1793 }).pipe(Effect.forkIn(runtimeScope));
1794 const loaded = yield* Effect.raceFirst(
1795 acp.agent.loadSession(loadPayload),
1796 Fiber.join(idleFiber),
1797 ).pipe(
1798 Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)),
1799 Effect.timeoutOption(sessionLoadTimeout),
1800 Effect.flatMap((result) =>
1801 Option.match(result, {
1802 onNone: () =>
1803 Effect.fail(
1804 new EffectAcpErrors.AcpTransportError({
1805 operation: "call-rpc",
1806 method: "session/load",
1807 detail:
1808 "session/load timed out waiting for RPC response or replay idle gap",
1809 cause: undefined,
1810 }),
1811 ),
1812 onSome: Effect.succeed,
1813 }),
1814 ),
1815 Effect.tap((result) =>
1816 logRequest({
1817 method: "session/load",
1818 payload: loadPayload,
1819 status: "succeeded",
1820 result,
1821 }),
1822 ),
1823 Effect.onError((cause) =>
1824 logRequest({
1825 method: "session/load",
1826 payload: loadPayload,
1827 status: "failed",
1828 cause,
1829 }),
1830 ),
1831 );
1832
1833 return loaded;
1834 }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none())));
1835 }),
1836 );
1837
567 const setConfigOption = (1838 const setConfigOption = (
568 configId: string,1839 configId: string,
569 value: string | boolean,1840 value: string | boolean,
@@ -669,27 +1940,9 @@ export const make = ( · 99 unmodified lines
669 cwd: options.cwd,1940 cwd: options.cwd,
670 mcpServers: options.mcpServers ?? [],1941 mcpServers: options.mcpServers ?? [],
671 } satisfies EffectAcpSchema.LoadSessionRequest;1942 } satisfies EffectAcpSchema.LoadSessionRequest;
672 const resumed = yield* runLoggedRequest(1943
673 "session/load",1944 sessionId = options.resumeSessionId;
674 loadPayload,1945 sessionSetupResult = yield* runLoadSessionWithReplayIdle(loadPayload, initializeResult);
675 acp.agent.loadSession(loadPayload),
676 ).pipe(Effect.exit);
677 if (Exit.isSuccess(resumed)) {
678 sessionId = options.resumeSessionId;
679 sessionSetupResult = resumed.value;
680 } else {
681 const createPayload = {
682 cwd: options.cwd,
683 mcpServers: options.mcpServers ?? [],
684 } satisfies EffectAcpSchema.NewSessionRequest;
685 const created = yield* runLoggedRequest(
686 "session/new",
687 createPayload,
688 acp.agent.createSession(createPayload),
689 );
690 sessionId = created.sessionId;
691 sessionSetupResult = created;
692 }
693 } else {1946 } else {
694 const createPayload = {1947 const createPayload = {
695 cwd: options.cwd,1948 cwd: options.cwd,
@@ -760,6 +2013,14 @@ export const make = ( · 64 unmodified lines
760 });2013 });
761 2014
762 return {2015 return {
2016 processContainment:
2017 linuxCgroupLease !== undefined
2018 ? "cgroup-v2"
2019 : options.ownDescendantProcessGroups === true
2020 ? "process-ledger-reduced-guarantee"
2021 : options.ownDetachedProcessGroup === true
2022 ? "process-group"
2023 : "none",
763 handleRequestPermission: acp.handleRequestPermission,2024 handleRequestPermission: acp.handleRequestPermission,
764 handleElicitation: acp.handleElicitation,2025 handleElicitation: acp.handleElicitation,
765 handleReadTextFile: acp.handleReadTextFile,2026 handleReadTextFile: acp.handleReadTextFile,
@@ -782,17 +2043,13 @@ export const make = ( · 16 unmodified lines
782 getConfigOptions: Ref.get(configOptionsRef),2043 getConfigOptions: Ref.get(configOptionsRef),
783 loadSession: (sessionId, activationOptions) =>2044 loadSession: (sessionId, activationOptions) =>
784 start.pipe(2045 start.pipe(
785 Effect.flatMap(() => {2046 Effect.flatMap((started) => {
786 const requestPayload = {2047 const requestPayload = {
787 sessionId,2048 sessionId,
788 cwd: options.cwd,2049 cwd: options.cwd,
789 mcpServers: activationOptions?.mcpServers ?? options.mcpServers ?? [],2050 mcpServers: activationOptions?.mcpServers ?? options.mcpServers ?? [],
790 } satisfies EffectAcpSchema.LoadSessionRequest;2051 } satisfies EffectAcpSchema.LoadSessionRequest;
791 return runLoggedRequest(2052 return runLoadSessionWithReplayIdle(requestPayload, started.initializeResult);
792 "session/load",
793 requestPayload,
794 acp.agent.loadSession(requestPayload),
795 );
796 }),2053 }),
797 Effect.flatMap((response) => adoptSession(sessionId, response)),2054 Effect.flatMap((response) => adoptSession(sessionId, response)),
798 ),2055 ),
@@ -914,6 +2171,7 @@ export const make = ( · 115 unmodified lines
914 }),2171 }),
915 ),2172 ),
916 ),2173 ),
2174 ...(options.ownDetachedProcessGroup === true ? { terminateProcessGroup } : {}),
917 setMode: (modeId) =>2175 setMode: (modeId) =>
918 Ref.get(modeStateRef).pipe(2176 Ref.get(modeStateRef).pipe(
919 Effect.flatMap((modeState) => {2177 Effect.flatMap((modeState) => {
apps/server/src/provider/acp/GrokAcpSupport.test.ts+28−105
@@ -5,11 +5,33 @@ import * as EffectAcpErrors from "effect-acp/errors"; · 4 unmodified lines
5import {5import {
6 applyGrokAcpModelSelection,6 applyGrokAcpModelSelection,
7 buildGrokAcpSpawnInput,7 buildGrokAcpSpawnInput,
8 grokAcpSpawnArgs,8 grokAcpRuntimeProcessOwnership,
9 isValidGrokReasoningEffortToken,
10 resolveGrokAcpBaseModelId,9 resolveGrokAcpBaseModelId,
11} from "./GrokAcpSupport.ts";10} from "./GrokAcpSupport.ts";
12 11
12describe("grokAcpRuntimeProcessOwnership", () => {
13 it("opts Grok into detached process-tree ownership on the injected host platform", () => {
14 expect(grokAcpRuntimeProcessOwnership("linux")).toEqual({
15 ownDescendantProcessGroups: true,
16 ownDetachedProcessGroup: true,
17 processGroupPlatform: "linux",
18 });
19 });
20
21 it("uses the prior provider-group path on Darwin and Windows", () => {
22 expect(grokAcpRuntimeProcessOwnership("darwin")).toEqual({
23 ownDescendantProcessGroups: false,
24 ownDetachedProcessGroup: true,
25 processGroupPlatform: "darwin",
26 });
27 expect(grokAcpRuntimeProcessOwnership("win32")).toEqual({
28 ownDescendantProcessGroups: false,
29 ownDetachedProcessGroup: true,
30 processGroupPlatform: "win32",
31 });
32 });
33});
34
13describe("resolveGrokAcpBaseModelId", () => {35describe("resolveGrokAcpBaseModelId", () => {
14 it("normalizes empty and custom Grok model ids", () => {36 it("normalizes empty and custom Grok model ids", () => {
15 expect(resolveGrokAcpBaseModelId(undefined)).toBe("grok-build");37 expect(resolveGrokAcpBaseModelId(undefined)).toBe("grok-build");
@@ -18,35 +40,6 @@ describe("resolveGrokAcpBaseModelId", () => { · 2 unmodified lines
18 });40 });
19});41});
20 42
21describe("grokAcpSpawnArgs", () => {
22 it("inherits the Grok CLI config when no T3 runtime mode is set", () => {
23 expect(grokAcpSpawnArgs()).toEqual(["agent", "stdio"]);
24 });
25
26 it("forces Grok to ask when T3 is Supervised", () => {
27 expect(grokAcpSpawnArgs("approval-required")).toEqual([
28 "--permission-mode",
29 "default",
30 "agent",
31 "stdio",
32 ]);
33 });
34
35 it("maps Full access to Grok always-approve", () => {
36 expect(grokAcpSpawnArgs("full-access")).toEqual(["agent", "--always-approve", "stdio"]);
37 });
38
39 it("maps Auto-accept edits and Auto onto Grok permission modes", () => {
40 expect(grokAcpSpawnArgs("auto-accept-edits")).toEqual([
41 "--permission-mode",
42 "acceptEdits",
43 "agent",
44 "stdio",
45 ]);
46 expect(grokAcpSpawnArgs("auto")).toEqual(["--permission-mode", "auto", "agent", "stdio"]);
47 });
48});
49
50describe("buildGrokAcpSpawnInput", () => {43describe("buildGrokAcpSpawnInput", () => {
51 it("passes the T3 Code referrer through Grok OAuth env", () => {44 it("passes the T3 Code referrer through Grok OAuth env", () => {
52 const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", {45 const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", {
@@ -64,38 +57,15 @@ describe("buildGrokAcpSpawnInput", () => { · 11 unmodified lines
64 },57 },
65 });58 });
66 });59 });
67
68 it("puts Supervised on the Grok argv so config always-approve cannot win", () => {
69 const spawn = buildGrokAcpSpawnInput(
70 { binaryPath: "/usr/local/bin/grok" },
71 "/tmp/project",
72 undefined,
73 "approval-required",
74 );
75 expect(spawn.args).toEqual(["--permission-mode", "default", "agent", "stdio"]);
76 });
77});
78
79describe("isValidGrokReasoningEffortToken", () => {
80 it("accepts future ACP tokens and rejects malformed metadata values", () => {
81 expect(isValidGrokReasoningEffortToken("xhigh")).toBe(true);
82 expect(isValidGrokReasoningEffortToken("turbo_v2")).toBe(true);
83 expect(isValidGrokReasoningEffortToken("not a token")).toBe(false);
84 expect(isValidGrokReasoningEffortToken("-leading-dash")).toBe(false);
85 expect(isValidGrokReasoningEffortToken("x".repeat(33))).toBe(false);
86 });
87});60});
88 61
89describe("applyGrokAcpModelSelection", () => {62describe("applyGrokAcpModelSelection", () => {
90 const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => {63 const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => {
91 const modelCalls: Array<{64 const modelCalls: Array<string> = [];
92 modelId: string;
93 meta?: { readonly [key: string]: unknown } | null;
94 }> = [];
95 const runtime = {65 const runtime = {
96 setSessionModel: (modelId: string, meta?: { readonly [key: string]: unknown } | null) =>66 setSessionModel: (modelId: string) =>
97 Effect.gen(function* () {67 Effect.gen(function* () {
98 modelCalls.push(meta === undefined ? { modelId } : { modelId, meta });68 modelCalls.push(modelId);
99 if (failure) return yield* failure;69 if (failure) return yield* failure;
100 return {};70 return {};
101 }),71 }),
@@ -112,58 +82,11 @@ describe("applyGrokAcpModelSelection", () => { · 10 unmodified lines
112 requestedModelId: "grok-mock-alt",82 requestedModelId: "grok-mock-alt",
113 mapError: (cause) => cause.message,83 mapError: (cause) => cause.message,
114 });84 });
115 expect(modelCalls).toEqual([{ modelId: "grok-mock-alt" }]);85 expect(modelCalls).toEqual(["grok-mock-alt"]);
116 expect(result).toBe("grok-mock-alt");86 expect(result).toBe("grok-mock-alt");
117 }),87 }),
118 );88 );
119 89
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
167 it.effect("skips set_model when requested matches current", () =>90 it.effect("skips set_model when requested matches current", () =>
168 Effect.gen(function* () {91 Effect.gen(function* () {
169 const { runtime, modelCalls } = makeRecordingRuntime();92 const { runtime, modelCalls } = makeRecordingRuntime();
apps/server/src/provider/acp/GrokAcpSupport.ts+29−82
@@ -1,4 +1,5 @@
1import { type GrokSettings, ProviderDriverKind, type RuntimeMode } from "@t3tools/contracts";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts";
2import * as Crypto from "effect/Crypto";3import * as Crypto from "effect/Crypto";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
@@ -6,6 +7,7 @@ import * as Scope from "effect/Scope"; · 1 unmodified line
6import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";7import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
7import * as EffectAcpErrors from "effect-acp/errors";8import * as EffectAcpErrors from "effect-acp/errors";
8import type * as EffectAcpSchema from "effect-acp/schema";9import type * as EffectAcpSchema from "effect-acp/schema";
10import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
9import { normalizeModelSlug } from "@t3tools/shared/model";11import { normalizeModelSlug } from "@t3tools/shared/model";
10 12
11import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";13import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
@@ -24,33 +26,16 @@ interface GrokAcpRuntimeInput extends Omit<AcpSessionRuntimeOptions, "authMethod · 12 unmodified lines
24 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];26 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
25 readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined;27 readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined;
26 readonly environment?: NodeJS.ProcessEnv;28 readonly environment?: NodeJS.ProcessEnv;
27 readonly runtimeMode?: RuntimeMode;
28}
29
30export function grokAcpSpawnArgs(runtimeMode?: RuntimeMode): ReadonlyArray<string> {
31 switch (runtimeMode) {
32 case "approval-required":
33 return ["--permission-mode", "default", "agent", "stdio"];
34 case "auto-accept-edits":
35 return ["--permission-mode", "acceptEdits", "agent", "stdio"];
36 case "auto":
37 return ["--permission-mode", "auto", "agent", "stdio"];
38 case "full-access":
39 return ["agent", "--always-approve", "stdio"];
40 default:
41 return ["agent", "stdio"];
42 }
43}29}
44 30
45export function buildGrokAcpSpawnInput(31export function buildGrokAcpSpawnInput(
46 grokSettings: GrokAcpRuntimeGrokSettings | null | undefined,32 grokSettings: GrokAcpRuntimeGrokSettings | null | undefined,
47 cwd: string,33 cwd: string,
48 environment?: NodeJS.ProcessEnv,34 environment?: NodeJS.ProcessEnv,
49 runtimeMode?: RuntimeMode,
50): AcpSessionRuntime.AcpSpawnInput {35): AcpSessionRuntime.AcpSpawnInput {
51 return {36 return {
52 command: grokSettings?.binaryPath || "grok",37 command: grokSettings?.binaryPath || "grok",
53 args: [...grokAcpSpawnArgs(runtimeMode)],38 args: ["agent", "stdio"],
54 cwd,39 cwd,
55 env: {40 env: {
56 ...environment,41 ...environment,
@@ -65,6 +50,21 @@ function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): st · 8 unmodified lines
65 : GROK_AUTH_METHOD_CACHED_TOKEN;50 : GROK_AUTH_METHOD_CACHED_TOKEN;
66}51}
67 52
53export function grokAcpRuntimeProcessOwnership(
54 processGroupPlatform: NodeJS.Platform,
55): Pick<
56 AcpSessionRuntime.AcpSessionRuntimeOptions,
57 "ownDescendantProcessGroups" | "ownDetachedProcessGroup" | "processGroupPlatform"
58> {
59 return {
60 // macOS keeps the prior provider-group teardown until a stable libproc
61 // identity provider can cover Grok's nested detached tool groups.
62 ownDescendantProcessGroups: processGroupPlatform === "linux",
63 ownDetachedProcessGroup: true,
64 processGroupPlatform,
65 };
66}
67
68export const makeGrokAcpRuntime = (68export const makeGrokAcpRuntime = (
69 input: GrokAcpRuntimeInput,69 input: GrokAcpRuntimeInput,
70): Effect.Effect<70): Effect.Effect<
@@ -73,16 +73,15 @@ export const makeGrokAcpRuntime = ( · 2 unmodified lines
73 Crypto.Crypto | Scope.Scope73 Crypto.Crypto | Scope.Scope
74> =>74> =>
75 Effect.gen(function* () {75 Effect.gen(function* () {
76 const processGroupPlatform = yield* HostProcessPlatform.pipe(
77 Effect.provide(NodeServices.layer),
78 );
76 const acpContext = yield* Layer.build(79 const acpContext = yield* Layer.build(
77 AcpSessionRuntime.layer({80 AcpSessionRuntime.layer({
78 ...input,81 ...input,
79 spawn: buildGrokAcpSpawnInput(82 spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment),
80 input.grokSettings,
81 input.cwd,
82 input.environment,
83 input.runtimeMode,
84 ),
85 authMethodId: resolveGrokAuthMethodId(input.environment),83 authMethodId: resolveGrokAuthMethodId(input.environment),
84 ...grokAcpRuntimeProcessOwnership(processGroupPlatform),
86 }).pipe(85 }).pipe(
87 Layer.provide(86 Layer.provide(
88 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),87 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
@@ -107,17 +106,6 @@ export function resolveGrokAcpBaseModelId(model: string | null | undefined): str · 18 unmodified lines
107 return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? GROK_DEFAULT_MODEL_SLUG;106 return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? GROK_DEFAULT_MODEL_SLUG;
108}107}
109 108
110const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i;
111
112export function isValidGrokReasoningEffortToken(value: string): boolean {
113 return GROK_REASONING_EFFORT_TOKEN.test(value);
114}
115
116export function normalizeGrokReasoningEffort(value: string | undefined): string | undefined {
117 const effort = value?.trim();
118 return effort && isValidGrokReasoningEffortToken(effort) ? effort : undefined;
119}
120
121export function currentGrokModelIdFromSessionSetup(109export function currentGrokModelIdFromSessionSetup(
122 sessionSetupResult:110 sessionSetupResult:
123 | EffectAcpSchema.LoadSessionResponse111 | EffectAcpSchema.LoadSessionResponse
@@ -127,59 +115,18 @@ export function currentGrokModelIdFromSessionSetup( · 3 unmodified lines
127 return sessionSetupResult.models?.currentModelId?.trim() || undefined;115 return sessionSetupResult.models?.currentModelId?.trim() || undefined;
128}116}
129 117
130export function currentGrokReasoningEffortFromSessionSetup(
131 sessionSetupResult:
132 | EffectAcpSchema.LoadSessionResponse
133 | EffectAcpSchema.NewSessionResponse
134 | EffectAcpSchema.ResumeSessionResponse,
135): string | undefined {
136 const modelState = sessionSetupResult.models;
137 if (!modelState) {
138 return undefined;
139 }
140 const currentModelId = modelState.currentModelId.trim();
141 if (currentModelId.length === 0) {
142 return undefined;
143 }
144 const currentModel = modelState.availableModels.find(
145 (model) => model.modelId.trim() === currentModelId,
146 );
147 const reasoningEffort = currentModel?._meta?.reasoningEffort;
148 return typeof reasoningEffort === "string"
149 ? normalizeGrokReasoningEffort(reasoningEffort)
150 : undefined;
151}
152
153export function applyGrokAcpModelSelection<E>(input: {118export function applyGrokAcpModelSelection<E>(input: {
154 readonly runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "setSessionModel">;119 readonly runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "setSessionModel">;
155 readonly currentModelId: string | undefined;120 readonly currentModelId: string | undefined;
156 readonly currentReasoningEffort?: string | undefined;
157 readonly requestedModelId: string | undefined;121 readonly requestedModelId: string | undefined;
158 readonly requestedReasoningEffort?: string | undefined;
159 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;122 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
160}): Effect.Effect<string | undefined, E> {123}): Effect.Effect<string | undefined, E> {
161 // The product slug is never sent over the wire; it keeps the session's current model.124 const shouldSwitchModel =
162 const requestedModelId =125 input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId;
163 input.requestedModelId === GROK_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId;126 if (!shouldSwitchModel) {
164 const modelChanged = requestedModelId !== undefined && requestedModelId !== input.currentModelId;
165 const reasoningProvided = input.requestedReasoningEffort !== undefined;
166 const reasoningEffort = reasoningProvided
167 ? normalizeGrokReasoningEffort(input.requestedReasoningEffort)
168 : undefined;
169 const reasoningEffortChanged =
170 reasoningProvided && reasoningEffort !== input.currentReasoningEffort;
171 const targetModelId = requestedModelId ?? input.currentModelId;
172 if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) {
173 return Effect.succeed(input.currentModelId);127 return Effect.succeed(input.currentModelId);
174 }128 }
175 const reasoningMeta =
176 reasoningProvided && reasoningEffort !== undefined ? { reasoningEffort } : undefined;
177 // When reasoning was explicitly provided but invalid (normalize => undefined), we deliberately
178 // send no meta so the invalid value is dropped rather than forwarded. When reasoning was not
179 // provided at all, we also send no meta, but we only reach this call when the model itself
180 // changed - an omitted reasoning preference must not be treated as an explicit clear of the
181 // CLI-advertised default (e.g. Extra High) on same-model reselections.
182 return input.runtime129 return input.runtime
183 .setSessionModel(targetModelId, reasoningMeta)130 .setSessionModel(input.requestedModelId)
184 .pipe(Effect.mapError(input.mapError), Effect.as(targetModelId));131 .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId));
185}132}
apps/server/src/provider/acp/XAiAcpExtension.test.ts+1086−57
@@ -1,42 +1,77 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodePath from "node:path";
3import * as NodeURL from "node:url";
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { it } from "@effect/vitest";1import { it } from "@effect/vitest";
2import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
8import * as Schema from "effect/Schema";5import * as Schema from "effect/Schema";
9import { describe, expect } from "vite-plus/test";6import { describe, expect } from "vite-plus/test";
10 7
11import {8import {
12 extractXAiAskUserQuestions,9 extractXAiAcpBackgroundToolMutation,
10 extractXAiAcpSubagentEndNotice,
13 extractXAiAcpSubagentUpdate,11 extractXAiAcpSubagentUpdate,
12 extractXAiAskUserQuestions,
13 extractXAiBackgroundTaskCompletion,
14 extractXAiKilledBackgroundTasks,
15 extractXAiMonitorTaskId,
16 isGenericAcpToolTitle,
17 isXAiMonitorTool,
18 isXAiPersistentMonitor,
14 makeXAiAskUserQuestionCancelledResponse,19 makeXAiAskUserQuestionCancelledResponse,
15 makeXAiAskUserQuestionResponse,20 makeXAiAskUserQuestionResponse,
16 makeXAiPromptCompletionRuntime,21 makeXAiPromptCompletionRuntime,
22 normalizeXAiAcpToolCallState,
23 resolveXAiAcpToolTitle,
24 xAiBackgroundTaskLifecycleMutation,
25 xAiPromptCompleteFromSessionUpdate,
17 XAiAskUserQuestionRequest,26 XAiAskUserQuestionRequest,
18} from "./XAiAcpExtension.ts";27} from "./XAiAcpExtension.ts";
19import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";28import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
20 29
21const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));30const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest);
22const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
23 31
24const makePromptCompletionRuntime = (env: NodeJS.ProcessEnv) =>32describe("xAiPromptCompleteFromSessionUpdate", () => {
25 Effect.gen(function* () {33 it("maps live turn_completed snake_case payloads", () => {
26 const runtime = yield* AcpSessionRuntime.make({34 expect(
27 spawn: {35 xAiPromptCompleteFromSessionUpdate({
28 command: process.execPath,36 sessionId: "019f4428-4bf1-7e52-b7c6-c29b506543b1",
29 args: [mockAgentPath],37 update: {
30 env,38 sessionUpdate: "turn_completed",
31 },39 prompt_id: "t3-xai-prompt-1",
32 cwd: process.cwd(),40 stop_reason: "end_turn",
33 clientInfo: { name: "t3-test", version: "0.0.0" },41 },
34 authMethodId: "test",42 }),
43 ).toEqual({
44 sessionId: "019f4428-4bf1-7e52-b7c6-c29b506543b1",
45 promptId: "t3-xai-prompt-1",
46 stopReason: "end_turn",
35 });47 });
36 return yield* makeXAiPromptCompletionRuntime(runtime);
37 });48 });
38 49
39const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest);50 it("ignores non-turn updates and task-completed prompt ids", () => {
51 expect(
52 xAiPromptCompleteFromSessionUpdate({
53 sessionId: "root",
54 update: { sessionUpdate: "hook_execution", prompt_id: "t3-xai-prompt-1" },
55 }),
56 ).toBeNull();
57 expect(
58 xAiPromptCompleteFromSessionUpdate({
59 sessionId: "root",
60 update: {
61 sessionUpdate: "turn_completed",
62 prompt_id: "task-completed-call-abc",
63 stop_reason: "end_turn",
64 },
65 }),
66 ).toBeNull();
67 expect(
68 xAiPromptCompleteFromSessionUpdate({
69 sessionId: "root",
70 update: { sessionUpdate: "turn_completed", stop_reason: "end_turn" },
71 }),
72 ).toBeNull();
73 });
74});
40 75
41describe("XAiAcpExtension", () => {76describe("XAiAcpExtension", () => {
42 it("recognizes Grok Task starts as native subagents", () => {77 it("recognizes Grok Task starts as native subagents", () => {
@@ -62,6 +97,7 @@ describe("XAiAcpExtension", () => { · 19 unmodified lines
62 status: "running",97 status: "running",
63 childSessionId: null,98 childSessionId: null,
64 result: null,99 result: null,
100 suppressNormalTool: true,
65 });101 });
66 });102 });
67 103
@@ -95,6 +131,792 @@ describe("XAiAcpExtension", () => { · 27 unmodified lines
95 status: "completed",131 status: "completed",
96 childSessionId: "019f0220-e192-7c41-9e9d-b406bc3459c8",132 childSessionId: "019f0220-e192-7c41-9e9d-b406bc3459c8",
97 result: "Server audit complete.",133 result: "Server audit complete.",
134 suppressNormalTool: true,
135 });
136 });
137
138 it("keeps async spawn_subagent ACKs running and binds subagent_id", () => {
139 expect(
140 extractXAiAcpSubagentUpdate({
141 toolCallId: "call-spawn-1",
142 title: "spawn_subagent",
143 status: "completed",
144 data: {
145 rawInput: {
146 description: "Demo subagent wait and ls",
147 prompt: "Wait then ls.",
148 subagent_type: "general-purpose",
149 },
150 rawOutput: {
151 type: "Text",
152 text: [
153 "Subagent started in background.",
154 "subagent_id: 019f44a6-4820-7402-925d-bc862ee711dd",
155 "type: general-purpose",
156 "description: Demo subagent wait and ls",
157 "",
158 'Use get_command_or_subagent_output with task_ids=["019f44a6-4820-7402-925d-bc862ee711dd"] and timeout_ms to wait for results.',
159 ].join("\n"),
160 },
161 },
162 }),
163 ).toEqual({
164 nativeTaskId: "call-spawn-1",
165 prompt: "Wait then ls.",
166 title: "Demo subagent wait and ls",
167 model: null,
168 status: "running",
169 childSessionId: "019f44a6-4820-7402-925d-bc862ee711dd",
170 result: null,
171 suppressNormalTool: true,
172 });
173 });
174
175 it("hydrates subagent completion from get_command_or_subagent_output", () => {
176 expect(
177 extractXAiAcpSubagentUpdate({
178 toolCallId: "call-get-1",
179 title: "get_command_or_subagent_output",
180 status: "completed",
181 data: {
182 rawInput: {
183 task_ids: ["019f44a6-4820-7402-925d-bc862ee711dd"],
184 timeout_ms: 30000,
185 },
186 rawOutput: {
187 type: "Text",
188 text: [
189 "=== Task 019f44a6-4820-7402-925d-bc862ee711dd ===",
190 "Status: completed",
191 "Exit Code: 0",
192 "",
193 "=== Output ===",
194 "SUBAGENT_MARKER: 35 entries",
195 ].join("\n"),
196 },
197 },
198 }),
199 ).toEqual({
200 nativeTaskId: "call-get-1",
201 prompt: "",
202 title: null,
203 model: null,
204 status: "completed",
205 childSessionId: "019f44a6-4820-7402-925d-bc862ee711dd",
206 result: "SUBAGENT_MARKER: 35 entries",
207 suppressNormalTool: true,
208 });
209 });
210
211 it("attributes multi-task get_output envelopes to the result task_id", () => {
212 expect(
213 extractXAiAcpSubagentUpdate({
214 toolCallId: "call-get-multi",
215 title: "get_command_or_subagent_output",
216 status: "completed",
217 data: {
218 rawInput: {
219 task_ids: [
220 "019f44a6-4820-7402-925d-bc862ee711dd",
221 "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
222 ],
223 timeout_ms: 30000,
224 },
225 rawOutput: {
226 type: "TaskOutput",
227 Result: {
228 task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
229 status: "completed",
230 exit_code: 0,
231 output: "SECOND_SUB_DONE\n",
232 },
233 },
234 },
235 }),
236 ).toMatchObject({
237 childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
238 status: "completed",
239 result: "SECOND_SUB_DONE",
240 });
241 });
242
243 it("hydrates structured ACP TaskOutput tool envelopes", () => {
244 expect(
245 extractXAiAcpSubagentUpdate({
246 toolCallId: "call-get-2",
247 title: "[subagent:general-purpose] Sleep then return SUBAGENT_DONE (019f44b0)",
248 status: "completed",
249 data: {
250 rawInput: {
251 variant: "TaskOutput",
252 task_ids: ["019f44b0-1b73-7f32-bb1b-1ff696f536e3"],
253 timeout_ms: 20000,
254 },
255 rawOutput: {
256 type: "TaskOutput",
257 Result: {
258 task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
259 status: "completed",
260 exit_code: 0,
261 output:
262 "SUBAGENT_DONE\n\n<subagent_meta>id=019f44b0-1b73-7f32-bb1b-1ff696f536e3</subagent_meta>\n",
263 },
264 },
265 },
266 }),
267 ).toEqual({
268 nativeTaskId: "call-get-2",
269 prompt: "",
270 title: null,
271 model: null,
272 status: "completed",
273 childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
274 result: "SUBAGENT_DONE",
275 suppressNormalTool: true,
276 });
277 });
278
279 it("does not leak raw machine tags when get_output is only meta/result blocks", () => {
280 const metaOnlyToolCall = {
281 toolCallId: "call-get-meta-only",
282 title: "get_command_or_subagent_output",
283 status: "completed" as const,
284 data: {
285 rawInput: {
286 variant: "TaskOutput",
287 task_ids: ["019f44b0-1b73-7f32-bb1b-1ff696f536e3"],
288 timeout_ms: 20000,
289 },
290 rawOutput: {
291 type: "TaskOutput",
292 Result: {
293 task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
294 status: "completed",
295 exit_code: 0,
296 output:
297 "<subagent_meta>id=019f44b0-1b73-7f32-bb1b-1ff696f536e3</subagent_meta>\n<subagent_result>done</subagent_result>\n",
298 },
299 },
300 },
301 };
302 expect(extractXAiAcpSubagentUpdate(metaOnlyToolCall)).toEqual({
303 nativeTaskId: "call-get-meta-only",
304 prompt: "",
305 title: null,
306 model: null,
307 status: "completed",
308 childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
309 result: null,
310 suppressNormalTool: true,
311 });
312 expect(extractXAiBackgroundTaskCompletion(metaOnlyToolCall)).toEqual([
313 {
314 taskId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
315 status: "completed",
316 appendOutput: "",
317 },
318 ]);
319 });
320
321 it("keeps monitor start ACKs running and extracts task ids", () => {
322 const toolCall = {
323 toolCallId: "call-mon-1",
324 title: "monitor",
325 status: "completed" as const,
326 data: {
327 rawInput: {
328 command: "echo mon_line_1",
329 description: "Demo monitor",
330 },
331 rawOutput: {
332 type: "Text",
333 text: "Monitor started (task 019f44a5-87d1-7640-8e35-6a4667ffc873, timeout 36000000ms).\nYou will be notified on each event.",
334 },
335 },
336 };
337 expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress");
338 expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873");
339 });
340
341 it("completes Monitor variant tools from structured Bash exit codes", () => {
342 const toolCall = {
343 toolCallId: "call-mon-2",
344 title: "Tool",
345 status: "inProgress" as const,
346 data: {
347 rawInput: {
348 variant: "Monitor",
349 command: "echo MON_DONE",
350 description: "Stream mon lines",
351 },
352 rawOutput: {
353 type: "Bash",
354 output: Array.from(new TextEncoder().encode("mon_line_1\nMON_DONE\n")),
355 output_for_prompt: "mon_line_1\nMON_DONE\n",
356 exit_code: 0,
357 },
358 },
359 };
360 expect(isXAiMonitorTool(toolCall)).toBe(true);
361 expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("completed");
362 });
363
364 it("detects Monitor start ACKs from structured rawOutput when title is generic", () => {
365 const toolCall = {
366 toolCallId: "call-mon-generic-title",
367 title: "Tool",
368 status: "completed" as const,
369 data: {
370 rawInput: {
371 command: "echo mon_line",
372 description: "Demo monitor without variant",
373 },
374 rawOutput: {
375 type: "Monitor",
376 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
377 timeoutMs: 36000000,
378 persistent: false,
379 },
380 },
381 };
382 expect(isXAiMonitorTool(toolCall)).toBe(true);
383 expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress");
384 expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873");
385 });
386
387 it("completes wake re-reports of finished commands despite generic titles", () => {
388 // Post-settle wake replay: the finished monitor is re-reported with empty
389 // rawInput and a generic title, so monitor detection cannot match; the
390 // structured Bash result with exit_code must still terminalize it.
391 const toolCall = {
392 toolCallId: "call-wake-1",
393 title: "Tool",
394 status: "inProgress" as const,
395 data: {
396 rawInput: {},
397 rawOutput: {
398 type: "Bash",
399 output: Array.from(new TextEncoder().encode("STREAM_1\nSTREAM_DONE\n")),
400 output_for_prompt: "STREAM_1\nSTREAM_DONE\n",
401 exit_code: 0,
402 command: "for i in 1 2; do echo STREAM_$i; done; echo STREAM_DONE",
403 },
404 },
405 };
406 expect(isXAiMonitorTool(toolCall)).toBe(false);
407 const normalized = normalizeXAiAcpToolCallState(toolCall);
408 expect(normalized.status).toBe("completed");
409 expect(normalized.title).toBe("for i in 1 2; do echo STREAM_$i; done; echo STREAM_DONE");
410 expect(
411 normalizeXAiAcpToolCallState({
412 ...toolCall,
413 data: {
414 ...toolCall.data,
415 rawOutput: { ...toolCall.data.rawOutput, exit_code: 2 },
416 },
417 }).status,
418 ).toBe("failed");
419 });
420
421 it("keeps empty successful Bash updates running until native output or turn settlement", () => {
422 const normalized = normalizeXAiAcpToolCallState({
423 toolCallId: "call-bash-permission-ack",
424 title: "Ran command",
425 status: "completed",
426 data: {
427 rawInput: { command: "sleep 40" },
428 rawOutput: { type: "Bash", exit_code: 0 },
429 },
430 });
431 expect(normalized.status).toBe("inProgress");
432 });
433
434 it("keeps structured Monitor start ACKs running and extracts taskId", () => {
435 const toolCall = {
436 toolCallId: "call-mon-3",
437 title: "Start monitor: Wait 30s then list directory",
438 status: "completed" as const,
439 data: {
440 rawInput: {
441 variant: "Monitor",
442 command: "sleep 30 && ls",
443 description: "Wait 30s then list directory",
444 },
445 rawOutput: {
446 type: "Monitor",
447 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
448 timeoutMs: 36000000,
449 persistent: false,
450 },
451 },
452 };
453 expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress");
454 expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44b8-8e98-7c80-a40e-df1e26a5f9e3");
455 });
456
457 it("replaces generic ACP titles with description / Monitor labels", () => {
458 expect(isGenericAcpToolTitle("Tool")).toBe(true);
459 expect(isGenericAcpToolTitle("Read package.json")).toBe(false);
460 expect(
461 resolveXAiAcpToolTitle({
462 toolCallId: "call-mon-title",
463 title: "Tool",
464 status: "completed",
465 data: {
466 rawInput: {
467 variant: "Monitor",
468 command: "sleep 30 && ls",
469 description: "Wait 30s then list directory",
470 },
471 },
472 }),
473 ).toBe("Monitor: Wait 30s then list directory");
474 const normalized = normalizeXAiAcpToolCallState({
475 toolCallId: "call-mon-title",
476 title: "Tool",
477 status: "completed",
478 data: {
479 rawInput: {
480 variant: "Monitor",
481 command: "sleep 30 && ls",
482 description: "Wait 30s then list directory",
483 },
484 rawOutput: {
485 type: "Monitor",
486 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
487 timeoutMs: 36000000,
488 persistent: false,
489 },
490 },
491 });
492 expect(normalized.title).toBe("Monitor: Wait 30s then list directory");
493 expect(normalized.status).toBe("inProgress");
494 // Non-generic titles from the CLI are preserved.
495 expect(
496 resolveXAiAcpToolTitle({
497 toolCallId: "call-read",
498 title: "Read package.json",
499 status: "inProgress",
500 data: { rawInput: { path: "package.json" } },
501 }),
502 ).toBe("Read package.json");
503 });
504
505 it("hydrates monitor completion from TaskOutput get_command envelopes", () => {
506 expect(
507 extractXAiBackgroundTaskCompletion({
508 toolCallId: "call-get-mon",
509 title: "Wait 30s then list directory",
510 status: "completed",
511 data: {
512 rawInput: {
513 variant: "TaskOutput",
514 task_ids: ["019f44b8-8e98-7c80-a40e-df1e26a5f9e3"],
515 },
516 rawOutput: {
517 type: "TaskOutput",
518 Result: {
519 task_id: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
520 command: "[monitor] Wait 30s then list directory",
521 status: "completed",
522 exit_code: 0,
523 output: "agents\nAGENTS.md\nnotes\n",
524 },
525 },
526 },
527 }),
528 ).toEqual([
529 {
530 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
531 status: "completed",
532 appendOutput: "agents\nAGENTS.md\nnotes",
533 },
534 ]);
535 });
536
537 it("hydrates monitor completion from a standalone TaskOutput frame without rawInput", () => {
538 // Post-settle shape observed live (2026-07-12): the final
539 // get_command_or_subagent_output update carries only rawOutput; the frame
540 // is parsed in isolation (no in-turn merge), and this completion is the
541 // ONLY end signal when the agent consumed the output itself (no "Monitor
542 // ended" reminder follows).
543 expect(
544 extractXAiBackgroundTaskCompletion({
545 toolCallId: "call-c29e64dd-ce5d-4eac-a6ca-c20513fefac3-1",
546 title: "[monitor] Stream numbered markers every 3s (019f54a0)",
547 status: "completed",
548 data: {
549 rawOutput: {
550 type: "TaskOutput",
551 Result: {
552 task_id: "019f54a0-06a8-77f2-8214-e24937cad564",
553 command: "[monitor] Stream numbered markers every 3s",
554 status: "completed",
555 exit_code: 0,
556 output: "STREAM_1\nSTREAM_DONE\n",
557 },
558 },
559 },
560 }),
561 ).toMatchObject([
562 {
563 taskId: "019f54a0-06a8-77f2-8214-e24937cad564",
564 status: "completed",
565 },
566 ]);
567 });
568
569 it("hydrates every requested monitor when TaskOutput has no result task_id", () => {
570 expect(
571 extractXAiBackgroundTaskCompletion({
572 toolCallId: "call-get-multi-mon",
573 title: "get_command_or_subagent_output",
574 status: "completed",
575 data: {
576 rawInput: {
577 variant: "TaskOutput",
578 task_ids: [
579 "019f44a6-4820-7402-925d-bc862ee711dd",
580 "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
581 ],
582 },
583 rawOutput: {
584 type: "TaskOutput",
585 Result: {
586 status: "completed",
587 exit_code: 0,
588 output: "BOTH_DONE\n",
589 },
590 },
591 },
592 }),
593 ).toEqual([
594 {
595 taskId: "019f44a6-4820-7402-925d-bc862ee711dd",
596 status: "completed",
597 appendOutput: "BOTH_DONE",
598 },
599 {
600 taskId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3",
601 status: "completed",
602 appendOutput: "BOTH_DONE",
603 },
604 ]);
605 });
606
607 it("treats Exit Code: -1 text envelopes as failed", () => {
608 expect(
609 extractXAiBackgroundTaskCompletion({
610 toolCallId: "call-get-neg",
611 title: "get_command_or_subagent_output",
612 status: "completed",
613 data: {
614 rawInput: {
615 variant: "TaskOutput",
616 task_ids: ["019f44b8-8e98-7c80-a40e-df1e26a5f9e3"],
617 },
618 rawOutput: {
619 type: "Text",
620 text: ["=== Task 019f44b8-8e98-7c80-a40e-df1e26a5f9e3 ===", "Exit Code: -1"].join("\n"),
621 },
622 },
623 }),
624 ).toMatchObject([
625 {
626 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
627 status: "failed",
628 },
629 ]);
630 });
631
632 it("tombstones tasks the model kills via kill_command_or_subagent", () => {
633 expect(
634 extractXAiKilledBackgroundTasks({
635 toolCallId: "call-kill-1",
636 title: "kill_command_or_subagent",
637 status: "completed",
638 data: {
639 rawInput: {
640 variant: "Kill",
641 task_ids: ["call-bg-1", "call-bg-2", "call-bg-1"],
642 },
643 },
644 }),
645 ).toEqual([
646 { taskId: "call-bg-1", status: "completed", appendOutput: "" },
647 { taskId: "call-bg-2", status: "completed", appendOutput: "" },
648 ]);
649 // Singular task_id shape, matched by variant when the title is generic.
650 expect(
651 extractXAiKilledBackgroundTasks({
652 toolCallId: "call-kill-2",
653 title: "Tool",
654 status: "failed",
655 data: { rawInput: { variant: "kill", task_id: "call-bg-3" } },
656 }),
657 ).toEqual([{ taskId: "call-bg-3", status: "completed", appendOutput: "" }]);
658 // In-flight kill calls and unrelated tools contribute nothing.
659 expect(
660 extractXAiKilledBackgroundTasks({
661 toolCallId: "call-kill-3",
662 title: "kill_command_or_subagent",
663 status: "inProgress",
664 data: { rawInput: { task_ids: ["call-bg-4"] } },
665 }),
666 ).toEqual([]);
667 expect(
668 extractXAiKilledBackgroundTasks({
669 toolCallId: "call-get",
670 title: "get_command_or_subagent_output",
671 status: "completed",
672 data: { rawInput: { variant: "TaskOutput", task_ids: ["call-bg-5"] } },
673 }),
674 ).toEqual([]);
675 });
676
677 it("maps task lifecycle notifications to background mutations", () => {
678 // task_backgrounded carries the cancelled call's tool_call_id as task_id.
679 expect(
680 xAiBackgroundTaskLifecycleMutation(
681 {
682 sessionId: "session-1",
683 update: {
684 sessionUpdate: "task_backgrounded",
685 tool_call_id: "call-bg-1",
686 task_id: "call-bg-1",
687 },
688 },
689 "running",
690 ),
691 ).toEqual({ sessionId: "session-1", taskId: "call-bg-1", status: "running" });
692 // task_completed nests the id inside task_snapshot.
693 expect(
694 xAiBackgroundTaskLifecycleMutation(
695 {
696 sessionId: "session-1",
697 update: {
698 sessionUpdate: "task_completed",
699 task_snapshot: { task_id: "call-bg-1" },
700 },
701 },
702 "completed",
703 ),
704 ).toEqual({ sessionId: "session-1", taskId: "call-bg-1", status: "completed" });
705 expect(
706 xAiBackgroundTaskLifecycleMutation(
707 { sessionId: "session-1", update: { sessionUpdate: "task_completed" } },
708 "completed",
709 ),
710 ).toBeNull();
711 });
712
713 it("detects persistent Monitor start ACKs", () => {
714 expect(
715 isXAiPersistentMonitor({
716 toolCallId: "call-mon-persist",
717 title: "Monitor",
718 status: "completed",
719 data: {
720 rawInput: { variant: "Monitor" },
721 rawOutput: {
722 type: "Monitor",
723 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
724 timeoutMs: 36000000,
725 persistent: true,
726 },
727 },
728 }),
729 ).toBe(true);
730 expect(
731 isXAiPersistentMonitor({
732 toolCallId: "call-mon-ephemeral",
733 title: "Monitor",
734 status: "completed",
735 data: {
736 rawInput: { variant: "Monitor" },
737 rawOutput: {
738 type: "Monitor",
739 taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3",
740 timeoutMs: 36000000,
741 persistent: false,
742 },
743 },
744 }),
745 ).toBe(false);
746 });
747
748 it("keeps a standalone TaskOutput completion terminal through normalize", () => {
749 // Must remain offer evidence post-settle: normalize must not demote it to
750 // inProgress the way monitor start ACKs are.
751 expect(
752 normalizeXAiAcpToolCallState({
753 toolCallId: "call-c29e64dd-ce5d-4eac-a6ca-c20513fefac3-1",
754 title: "[monitor] Stream numbered markers every 3s (019f54a0)",
755 status: "completed",
756 data: {
757 rawOutput: {
758 type: "TaskOutput",
759 Result: {
760 task_id: "019f54a0-06a8-77f2-8214-e24937cad564",
761 status: "completed",
762 exit_code: 0,
763 output: "STREAM_DONE\n",
764 },
765 },
766 },
767 }).status,
768 ).toBe("completed");
769 });
770
771 it("parses monitor event lines and end reminders", () => {
772 expect(
773 extractXAiAcpBackgroundToolMutation(
774 '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">\n[Demo] mon_line_1\n</monitor-event>',
775 ),
776 ).toEqual([
777 {
778 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
779 status: "running",
780 appendOutput: "[Demo] mon_line_1\n",
781 },
782 ]);
783 expect(
784 extractXAiAcpBackgroundToolMutation(
785 [
786 "<system-reminder>",
787 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].',
788 "Description: Demo monitor",
789 "</system-reminder>",
790 ].join("\n"),
791 ),
792 ).toMatchObject([
793 {
794 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
795 status: "completed",
796 },
797 ]);
798 // Description/output may contain "error" without meaning the monitor failed.
799 expect(
800 extractXAiAcpBackgroundToolMutation(
801 [
802 "<system-reminder>",
803 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].',
804 "Description: watch error logs for failed deploys",
805 "error: nothing found",
806 "</system-reminder>",
807 ].join("\n"),
808 ),
809 ).toMatchObject([
810 {
811 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
812 status: "completed",
813 },
814 ]);
815 });
816
817 it("parses every monitor-event and trailing end notice in one chunk", () => {
818 const mutations = extractXAiAcpBackgroundToolMutation(
819 [
820 '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">',
821 "[Demo] mon_line_1",
822 "</monitor-event>",
823 '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">',
824 "[Demo] mon_line_2",
825 "</monitor-event>",
826 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].',
827 ].join("\n"),
828 );
829 expect(mutations).toHaveLength(3);
830 expect(mutations[0]).toEqual({
831 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
832 status: "running",
833 appendOutput: "[Demo] mon_line_1\n",
834 });
835 expect(mutations[1]).toEqual({
836 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
837 status: "running",
838 appendOutput: "[Demo] mon_line_2\n",
839 });
840 expect(mutations[2]).toMatchObject({
841 taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873",
842 status: "completed",
843 });
844 });
845
846 it("parses batched monitor event blocks as running mutations", () => {
847 expect(
848 extractXAiAcpBackgroundToolMutation(
849 [
850 "3 monitor events from 1 monitor (use get_command_or_subagent_output to identify each monitor):",
851 "",
852 '<monitor description="Stream STREAM_i every 3s" task_id="019f545d-4d39-7001-b1c8-c7744c448ec1">',
853 "[1] STREAM_7",
854 "[2] STREAM_8",
855 "[3] STREAM_9",
856 "</monitor>",
857 ].join("\n"),
858 ),
859 ).toEqual([
860 {
861 taskId: "019f545d-4d39-7001-b1c8-c7744c448ec1",
862 status: "running",
863 appendOutput: "[1] STREAM_7\n[2] STREAM_8\n[3] STREAM_9\n",
864 },
865 ]);
866 });
867
868 it("parses the subagent completed reminder as an end notice", () => {
869 expect(
870 extractXAiAcpSubagentEndNotice(
871 [
872 "<system-reminder>",
873 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") completed successfully.',
874 "Duration: 28.8s | Tool calls: 1 | Turns: 1",
875 'Use get_task_output("019f5470-bf92-7a90-afb3-5a6cea5b34a3") to see the full output.',
876 "</system-reminder>",
877 ].join("\n"),
878 ),
879 ).toEqual({
880 childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3",
881 status: "completed",
882 });
883 });
884
885 it("parses a failed subagent reminder as a failed end notice", () => {
886 expect(
887 extractXAiAcpSubagentEndNotice(
888 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") failed.',
889 ),
890 ).toEqual({
891 childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3",
892 status: "failed",
893 });
894 });
895
896 it("ignores prompt text that merely mentions subagents", () => {
897 expect(
898 extractXAiAcpSubagentEndNotice("Live-test post-settle subagent completion."),
899 ).toBeUndefined();
900 expect(
901 extractXAiAcpSubagentEndNotice(
902 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") is still running.',
903 ),
904 ).toBeUndefined();
905 });
906
907 it("does not treat nested parentheses in the title as the outcome verb", () => {
908 expect(
909 extractXAiAcpSubagentEndNotice(
910 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Check (backend) failed tests") is still running.',
911 ),
912 ).toBeUndefined();
913 expect(
914 extractXAiAcpSubagentEndNotice(
915 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Check (backend) failed tests") completed successfully.',
916 ),
917 ).toEqual({
918 childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3",
919 status: "completed",
98 });920 });
99 });921 });
100 922
@@ -335,58 +1157,265 @@ describe("XAiAcpExtension", () => { · 234 unmodified lines
335 });1157 });
336 });1158 });
337 1159
338 it.effect("resolves a hung standard prompt from xAI prompt completion", () =>1160 it.effect("settles a hung prompt from a root-session prompt_complete notification", () =>
339 Effect.gen(function* () {1161 Effect.gen(function* () {
340 const runtime = yield* makePromptCompletionRuntime({1162 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
341 T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1",1163 const hungPrompt = yield* Deferred.make<never>();
342 });1164 const baseRuntime = {
343 yield* runtime.start();1165 start: () =>
1166 Effect.succeed({
1167 sessionId: "root-session",
1168 initializeResult: {},
1169 sessionSetupResult: {},
1170 modelConfigId: undefined,
1171 }),
1172 prompt: () => Deferred.await(hungPrompt),
1173 cancel: Effect.void,
1174 handleExtNotification: (
1175 method: string,
1176 _schema: unknown,
1177 handler: (notification: unknown) => Effect.Effect<void>,
1178 ) => {
1179 handlers.set(method, handler);
1180 return Effect.void;
1181 },
1182 handleExtRequest: () => Effect.void,
1183 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
344 1184
345 const promptResult = yield* runtime.prompt({1185 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
346 prompt: [{ type: "text", text: "hi" }],1186 const promptFiber = yield* runtime
1187 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1188 .pipe(Effect.forkChild);
1189 yield* Effect.yieldNow;
1190 const promptCompleteHandler = handlers.get("_x.ai/session/prompt_complete");
1191 expect(promptCompleteHandler).toBeDefined();
1192 yield* promptCompleteHandler!({
1193 sessionId: "root-session",
1194 stopReason: "end_turn",
347 });1195 });
348 const promptId = promptResult._meta?.promptId;1196 const response = yield* Fiber.join(promptFiber);
1197 expect(response.stopReason).toBe("end_turn");
1198 }),
1199 );
349 1200
1201 it.effect("settles a hung prompt from _x.ai/session/update turn_completed", () =>
1202 Effect.gen(function* () {
1203 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1204 let capturedMeta: Record<string, unknown> | null | undefined;
1205 const hungPrompt = yield* Deferred.make<never>();
1206 const baseRuntime = {
1207 start: () =>
1208 Effect.succeed({
1209 sessionId: "root-session",
1210 initializeResult: {},
1211 sessionSetupResult: {},
1212 modelConfigId: undefined,
1213 }),
1214 prompt: (payload: { readonly _meta?: Record<string, unknown> | null }) => {
1215 capturedMeta = payload._meta ?? null;
1216 return Deferred.await(hungPrompt);
1217 },
1218 cancel: Effect.void,
1219 handleExtNotification: (
1220 method: string,
1221 _schema: unknown,
1222 handler: (notification: unknown) => Effect.Effect<void>,
1223 ) => {
1224 handlers.set(method, handler);
1225 return Effect.void;
1226 },
1227 handleExtRequest: () => Effect.void,
1228 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1229
1230 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1231 const promptFiber = yield* runtime
1232 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1233 .pipe(Effect.forkChild);
1234 yield* Effect.yieldNow;
1235 const promptId = capturedMeta?.promptId;
350 expect(typeof promptId).toBe("string");1236 expect(typeof promptId).toBe("string");
351 expect(promptResult).toMatchObject({1237 const sessionUpdateHandler = handlers.get("_x.ai/session/update");
352 stopReason: "end_turn",1238 expect(sessionUpdateHandler).toBeDefined();
353 _meta: {1239 yield* sessionUpdateHandler!({
354 sessionId: "mock-session-1",1240 sessionId: "root-session",
355 promptId,1241 update: {
356 requestId: promptId,1242 sessionUpdate: "turn_completed",
1243 prompt_id: promptId,
1244 stop_reason: "end_turn",
357 },1245 },
358 });1246 });
359 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),1247 const response = yield* Fiber.join(promptFiber);
1248 expect(response.stopReason).toBe("end_turn");
1249 }),
360 );1250 );
361 1251
362 it.effect("ignores stale xAI completion from an already settled prompt", () =>1252 it.effect("ignores turn_completed for non-pending prompt ids and task completions", () =>
363 Effect.gen(function* () {1253 Effect.gen(function* () {
364 const runtime = yield* makePromptCompletionRuntime({1254 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
365 T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG: "1",1255 const hungPrompt = yield* Deferred.make<never>();
366 });1256 const baseRuntime = {
367 yield* runtime.start();1257 start: () =>
1258 Effect.succeed({
1259 sessionId: "root-session",
1260 initializeResult: {},
1261 sessionSetupResult: {},
1262 modelConfigId: undefined,
1263 }),
1264 prompt: () => Deferred.await(hungPrompt),
1265 cancel: Effect.void,
1266 handleExtNotification: (
1267 method: string,
1268 _schema: unknown,
1269 handler: (notification: unknown) => Effect.Effect<void>,
1270 ) => {
1271 handlers.set(method, handler);
1272 return Effect.void;
1273 },
1274 handleExtRequest: () => Effect.void,
1275 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
368 1276
369 const firstPromptResult = yield* runtime.prompt({1277 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
370 prompt: [{ type: "text", text: "first" }],1278 const promptFiber = yield* runtime
1279 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1280 .pipe(Effect.forkChild);
1281 yield* Effect.yieldNow;
1282 const sessionUpdateHandler = handlers.get("_x.ai/session/update");
1283 expect(sessionUpdateHandler).toBeDefined();
1284 yield* sessionUpdateHandler!({
1285 sessionId: "root-session",
1286 update: {
1287 sessionUpdate: "turn_completed",
1288 prompt_id: "task-completed-call-abc",
1289 stop_reason: "end_turn",
1290 },
371 });1291 });
372 expect(firstPromptResult).toMatchObject({1292 yield* sessionUpdateHandler!({
373 stopReason: "end_turn",1293 sessionId: "root-session",
374 _meta: { promptId: "mock-stale-xai-prompt-1" },1294 update: {
1295 sessionUpdate: "turn_completed",
1296 prompt_id: "some-other-cli-turn",
1297 stop_reason: "end_turn",
1298 },
375 });1299 });
1300 yield* Effect.yieldNow;
1301 expect(promptFiber.pollUnsafe()).toBeUndefined();
1302 yield* Fiber.interrupt(promptFiber);
1303 }),
1304 );
1305
1306 it.effect("ignores prompt_complete notifications for foreign session ids", () =>
1307 Effect.gen(function* () {
1308 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1309 const hungPrompt = yield* Deferred.make<never>();
1310 const baseRuntime = {
1311 start: () =>
1312 Effect.succeed({
1313 sessionId: "root-session",
1314 initializeResult: {},
1315 sessionSetupResult: {},
1316 modelConfigId: undefined,
1317 }),
1318 prompt: () => Deferred.await(hungPrompt),
1319 cancel: Effect.void,
1320 handleExtNotification: (
1321 method: string,
1322 _schema: unknown,
1323 handler: (notification: unknown) => Effect.Effect<void>,
1324 ) => {
1325 handlers.set(method, handler);
1326 return Effect.void;
1327 },
1328 handleExtRequest: () => Effect.void,
1329 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
376 1330
377 const secondPromptResult = yield* runtime.prompt({1331 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
378 prompt: [{ type: "text", text: "second" }],1332 const promptFiber = yield* runtime
1333 .prompt({ prompt: [{ type: "text", text: "hi" }] })
1334 .pipe(Effect.forkChild);
1335 yield* Effect.yieldNow;
1336 const promptCompleteHandler = handlers.get("_x.ai/session/prompt_complete");
1337 expect(promptCompleteHandler).toBeDefined();
1338 yield* promptCompleteHandler!({
1339 sessionId: "child-session",
379 });1340 });
380 const secondPromptId = secondPromptResult._meta?.promptId;1341 yield* Effect.yieldNow;
381 expect(typeof secondPromptId).toBe("string");1342 expect(promptFiber.pollUnsafe()).toBeUndefined();
382 expect(secondPromptId).not.toBe("mock-stale-xai-prompt-1");1343 yield* Fiber.interrupt(promptFiber);
383 expect(secondPromptResult).toMatchObject({1344 }),
384 stopReason: "end_turn",1345 );
385 _meta: {1346
386 promptId: secondPromptId,1347 it.effect(
387 requestId: secondPromptId,1348 "ignores promptId-less prompt_complete when multiple prompts are pending on the same session",
1349 () =>
1350 Effect.gen(function* () {
1351 const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1352 const hungPrompt = yield* Deferred.make<never>();
1353 const baseRuntime = {
1354 start: () =>
1355 Effect.succeed({
1356 sessionId: "root-session",
1357 initializeResult: {},
1358 sessionSetupResult: {},
1359 modelConfigId: undefined,
1360 }),
1361 prompt: () => Deferred.await(hungPrompt),
1362 cancel: Effect.void,
1363 handleExtNotification: (
1364 method: string,
1365 _schema: unknown,
1366 handler: (notification: unknown) => Effect.Effect<void>,
1367 ) => {
1368 handlers.set(method, handler);
1369 return Effect.void;
1370 },
1371 handleExtRequest: () => Effect.void,
1372 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1373
1374 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1375 const firstPromptFiber = yield* runtime
1376 .prompt({ prompt: [{ type: "text", text: "first" }] })
1377 .pipe(Effect.forkChild);
1378 yield* Effect.yieldNow;
1379 const secondPromptFiber = yield* runtime
1380 .prompt({ prompt: [{ type: "text", text: "second" }] })
1381 .pipe(Effect.forkChild);
1382 yield* Effect.yieldNow;
1383 const promptCompleteHandler = handlers.get("_x.ai/session/prompt_complete");
1384 expect(promptCompleteHandler).toBeDefined();
1385 yield* promptCompleteHandler!({
1386 sessionId: "root-session",
1387 stopReason: "end_turn",
1388 });
1389 yield* Effect.yieldNow;
1390 expect(firstPromptFiber.pollUnsafe()).toBeUndefined();
1391 expect(secondPromptFiber.pollUnsafe()).toBeUndefined();
1392 yield* Fiber.interrupt(firstPromptFiber);
1393 yield* Fiber.interrupt(secondPromptFiber);
1394 }),
1395 );
1396
1397 it.effect("injects promptId and requestId into prompt _meta", () =>
1398 Effect.gen(function* () {
1399 let capturedMeta: Record<string, unknown> | null | undefined;
1400 const baseRuntime = {
1401 start: () => Effect.succeed({ sessionId: "session-1" }),
1402 prompt: (payload: { readonly _meta?: Record<string, unknown> | null }) => {
1403 capturedMeta = payload._meta ?? null;
1404 return Effect.succeed({ stopReason: "end_turn" as const });
388 },1405 },
1406 cancel: Effect.void,
1407 handleExtNotification: () => Effect.void,
1408 handleExtRequest: () => Effect.void,
1409 } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"];
1410
1411 const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
1412 yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }] });
1413
1414 expect(typeof capturedMeta?.promptId).toBe("string");
1415 expect(capturedMeta).toMatchObject({
1416 promptId: capturedMeta?.promptId,
1417 requestId: capturedMeta?.promptId,
389 });1418 });
390 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),1419 }),
391 );1420 );
392});1421});
apps/server/src/provider/acp/XAiAcpExtension.ts+875−398
@@ -1,16 +1,19 @@
1import * as NodeOS from "node:os";
2
3import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts";1import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
4import * as Deferred from "effect/Deferred";3import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Ref from "effect/Ref";6import * as Ref from "effect/Ref";
7import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
8import * as EffectAcpErrors from "effect-acp/errors";8import type * as EffectAcpErrors from "effect-acp/errors";
9import type * as EffectAcpSchema from "effect-acp/schema";9import type * as EffectAcpSchema from "effect-acp/schema";
10 10
11import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";11import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
12import type { AcpToolCallState } from "./AcpRuntimeModel.ts";12import type { AcpToolCallState } from "./AcpRuntimeModel.ts";
13 13
14const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing";
15const completedXAiPromptIdLimit = 128;
16
14const XAiPromptCompleteNotification = Schema.Struct({17const XAiPromptCompleteNotification = Schema.Struct({
15 sessionId: Schema.String,18 sessionId: Schema.String,
16 promptId: Schema.optional(Schema.String),19 promptId: Schema.optional(Schema.String),
@@ -20,19 +23,60 @@ const XAiPromptCompleteNotification = Schema.Struct({ · 3 unmodified lines
20 23
21type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type;24type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type;
22 25
26/**
27 * Live Grok CLI (0.2.x) emits root turn completion as an extension session
28 * update, not as top-level `_x.ai/session/prompt_complete`:
29 * `{ method: "_x.ai/session/update", params: { sessionId, update: {
30 * sessionUpdate: "turn_completed", prompt_id, stop_reason } } }`.
31 */
32const XAiSessionUpdateNotification = Schema.Struct({
33 sessionId: Schema.String,
34 update: Schema.Struct({
35 sessionUpdate: Schema.String,
36 prompt_id: Schema.optional(Schema.String),
37 promptId: Schema.optional(Schema.String),
38 stop_reason: Schema.optional(Schema.String),
39 stopReason: Schema.optional(Schema.String),
40 }),
41 _meta: Schema.optional(Schema.Unknown),
42});
43
44type XAiSessionUpdateNotification = typeof XAiSessionUpdateNotification.Type;
45
46const XAI_TASK_COMPLETED_PROMPT_ID_PREFIX = "task-completed-";
47
48/**
49 * Map a Grok `_x.ai/session/update` payload to a prompt-complete shape when it
50 * is a root `turn_completed` for a real prompt id. Returns null for hooks,
51 * background task completions, and other update kinds.
52 */
53export function xAiPromptCompleteFromSessionUpdate(
54 notification: XAiSessionUpdateNotification,
55): XAiPromptCompleteNotification | null {
56 const update = notification.update;
57 if (update.sessionUpdate !== "turn_completed") {
58 return null;
59 }
60 const promptId = nonEmptyString(update.prompt_id) ?? nonEmptyString(update.promptId);
61 // Require prompt id so we never session-fallback match a background task
62 // completion (`task-completed-*`) or an unrelated CLI turn.
63 if (promptId === undefined || promptId.startsWith(XAI_TASK_COMPLETED_PROMPT_ID_PREFIX)) {
64 return null;
65 }
66 const stopReason = nonEmptyString(update.stop_reason) ?? nonEmptyString(update.stopReason);
67 return {
68 sessionId: notification.sessionId,
69 promptId,
70 ...(stopReason === undefined ? {} : { stopReason }),
71 };
72}
73
23interface PendingXAiPromptCompletion {74interface PendingXAiPromptCompletion {
24 readonly sessionId: string;75 readonly sessionId: string;
25 readonly promptId: string;76 readonly promptId: string;
26 readonly deferred: Deferred.Deferred<77 readonly deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse>;
27 EffectAcpSchema.PromptResponse,
28 EffectAcpErrors.AcpRequestError
29 >;
30}78}
31 79
32const completedXAiPromptIdLimit = 128;
33const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing";
34const xAiRateLimitedErrorCode = -32003;
35
36export interface XAiAcpSubagentUpdate {80export interface XAiAcpSubagentUpdate {
37 readonly nativeTaskId: string;81 readonly nativeTaskId: string;
38 readonly prompt: string;82 readonly prompt: string;
@@ -41,8 +85,26 @@ export interface XAiAcpSubagentUpdate { · 2 unmodified lines
41 readonly status: "running" | "completed" | "failed";85 readonly status: "running" | "completed" | "failed";
42 readonly childSessionId: string | null;86 readonly childSessionId: string | null;
43 readonly result: string | null;87 readonly result: string | null;
88 /**
89 * When false, the ACP adapter also projects a normal tool turn item for this
90 * tool call (used for get_command_or_subagent_output hydration). Default true.
91 */
92 readonly suppressNormalTool?: boolean;
93}
94
95export interface XAiAcpBackgroundToolMutation {
96 readonly taskId: string;
97 readonly status: "running" | "completed" | "failed";
98 readonly appendOutput: string;
99}
100
101export interface XAiAcpSubagentEndNotice {
102 readonly childSessionId: string;
103 readonly status: "completed" | "failed";
44}104}
45 105
106const XAI_UUID_RE = "[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}";
107
46function unknownRecord(value: unknown): Record<string, unknown> | undefined {108function unknownRecord(value: unknown): Record<string, unknown> | undefined {
47 return typeof value === "object" && value !== null && !Array.isArray(value)109 return typeof value === "object" && value !== null && !Array.isArray(value)
48 ? (value as Record<string, unknown>)110 ? (value as Record<string, unknown>)
@@ -53,10 +115,42 @@ function nonEmptyString(value: unknown): string | undefined { · 4 unmodified lines
53 return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;115 return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
54}116}
55 117
56function xAiTaskOutputText(toolCall: AcpToolCallState): string | undefined {118function titleKey(toolCall: AcpToolCallState): string {
119 return (toolCall.title ?? "").trim().toLowerCase();
120}
121
122function decodeByteText(value: unknown): string | undefined {
123 if (typeof value === "string") return nonEmptyString(value);
124 if (!Array.isArray(value)) return undefined;
125 if (value.length === 0) return undefined;
126 if (!value.every((entry) => typeof entry === "number")) return undefined;
127 try {
128 return nonEmptyString(new TextDecoder().decode(Uint8Array.from(value as number[])));
129 } catch {
130 return undefined;
131 }
132}
133
134function xAiToolOutputText(toolCall: AcpToolCallState): string | undefined {
57 const rawOutput = unknownRecord(toolCall.data.rawOutput);135 const rawOutput = unknownRecord(toolCall.data.rawOutput);
58 const rawOutputText = nonEmptyString(rawOutput?.text);136 if (rawOutput !== undefined) {
59 if (rawOutputText !== undefined) return rawOutputText;137 const direct =
138 nonEmptyString(rawOutput.text) ??
139 nonEmptyString(rawOutput.output_for_prompt) ??
140 decodeByteText(rawOutput.output);
141 if (direct !== undefined) return direct;
142 const result = unknownRecord(rawOutput.Result) ?? unknownRecord(rawOutput.result);
143 if (result !== undefined) {
144 const nested =
145 nonEmptyString(result.output) ??
146 nonEmptyString(result.text) ??
147 decodeByteText(result.output);
148 if (nested !== undefined) return nested;
149 }
150 }
151 if (typeof toolCall.data.rawOutput === "string") {
152 return nonEmptyString(toolCall.data.rawOutput);
153 }
60 const content = toolCall.data.content;154 const content = toolCall.data.content;
61 if (!Array.isArray(content)) return undefined;155 if (!Array.isArray(content)) return undefined;
62 const text = content156 const text = content
@@ -70,41 +164,631 @@ function xAiTaskOutputText(toolCall: AcpToolCallState): string | undefined { · 7 unmodified lines
70 return text.length > 0 ? text : undefined;164 return text.length > 0 ? text : undefined;
71}165}
72 166
167function firstUuidMatch(text: string, pattern: RegExp): string | undefined {
168 const match = pattern.exec(text);
169 return match?.[1];
170}
171
172function extractXAiChildSessionId(output: string | undefined): string | null {
173 if (output === undefined) return null;
174 return (
175 firstUuidMatch(output, new RegExp(`(?:^|\\n)\\s*Agent ID:\\s*(${XAI_UUID_RE})\\b`, "i")) ??
176 firstUuidMatch(output, new RegExp(`(?:^|\\n)\\s*subagent_id:\\s*(${XAI_UUID_RE})\\b`, "i")) ??
177 firstUuidMatch(output, new RegExp(`===\\s*Task\\s+(${XAI_UUID_RE})\\s*===`, "i")) ??
178 null
179 );
180}
181
182function isXAiAsyncSpawnAck(output: string | undefined): boolean {
183 if (output === undefined) return false;
184 if (/subagent started in background/i.test(output)) return true;
185 return (
186 new RegExp(`subagent_id:\\s*${XAI_UUID_RE}`, "i").test(output) &&
187 /get_command_or_subagent_output/i.test(output)
188 );
189}
190
191function isXAiMonitorStartAck(output: string | undefined): boolean {
192 if (output === undefined) return false;
193 return /monitor started\s*\(\s*task\s+/i.test(output);
194}
195
196function isXAiSpawnOrTaskTool(
197 toolCall: AcpToolCallState,
198 rawInput: Record<string, unknown> | undefined,
199): boolean {
200 const title = titleKey(toolCall);
201 if (title === "task" || title === "spawn_subagent" || title.includes("spawn subagent")) {
202 return true;
203 }
204 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
205 if (variant === "cursortask" || variant === "task" || variant === "spawn_subagent") {
206 return true;
207 }
208 if (nonEmptyString(rawInput?.subagent_type) !== undefined) return true;
209 if (nonEmptyString(rawInput?.subagentType) !== undefined) return true;
210 return false;
211}
212
213function isXAiGetSubagentOutputTool(
214 toolCall: AcpToolCallState,
215 rawInput: Record<string, unknown> | undefined,
216): boolean {
217 const title = titleKey(toolCall);
218 if (
219 title === "get_command_or_subagent_output" ||
220 title.includes("get_command_or_subagent_output") ||
221 title === "wait_commands_or_subagents"
222 ) {
223 return true;
224 }
225 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
226 if (variant === "taskoutput" || variant === "task_output") return true;
227 const rawOutput = unknownRecord(toolCall.data.rawOutput);
228 const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase();
229 if (outputType === "taskoutput" || outputType === "task_output") return true;
230 // ACP often titles this tool with the finished subagent label.
231 if (title.startsWith("[subagent:") && Array.isArray(rawInput?.task_ids)) return true;
232 return false;
233}
234
235export function isXAiMonitorTool(toolCall: AcpToolCallState): boolean {
236 const title = titleKey(toolCall);
237 if (title === "monitor" || title.startsWith("monitor ")) return true;
238 const rawInput = unknownRecord(toolCall.data.rawInput);
239 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
240 if (variant === "monitor") return true;
241 // Live start ACKs often keep a generic title ("Tool") and only mark the
242 // structured envelope as Monitor; title rewrite to description must not
243 // prevent monitor registration / inProgress forcing.
244 const rawOutput = unknownRecord(toolCall.data.rawOutput);
245 const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase();
246 return outputType === "monitor";
247}
248
249export function extractXAiMonitorTaskId(toolCall: AcpToolCallState): string | undefined {
250 if (!isXAiMonitorTool(toolCall)) return undefined;
251 const rawOutput = unknownRecord(toolCall.data.rawOutput);
252 // Live ACP start ACK: { type: "Monitor", taskId, timeoutMs, persistent }
253 if (rawOutput !== undefined && nonEmptyString(rawOutput.type)?.toLowerCase() === "monitor") {
254 const structured = nonEmptyString(rawOutput.taskId) ?? nonEmptyString(rawOutput.task_id);
255 if (structured !== undefined) return structured;
256 }
257 const output = xAiToolOutputText(toolCall);
258 if (output !== undefined) {
259 const fromAck = firstUuidMatch(
260 output,
261 new RegExp(`monitor started\\s*\\(\\s*task\\s+(${XAI_UUID_RE})\\b`, "i"),
262 );
263 if (fromAck !== undefined) return fromAck;
264 }
265 const rawInput = unknownRecord(toolCall.data.rawInput);
266 return (
267 nonEmptyString(rawInput?.task_id) ??
268 nonEmptyString(rawInput?.taskId) ??
269 (Array.isArray(rawInput?.task_ids) ? nonEmptyString(rawInput.task_ids[0]) : undefined)
270 );
271}
272
273/**
274 * Live Monitor start ACK `{ type: "Monitor", persistent: true }` (or matching
275 * rawInput). Persistent monitors should not hold root-turn deferred finalize.
276 */
277export function isXAiPersistentMonitor(toolCall: AcpToolCallState): boolean {
278 if (!isXAiMonitorTool(toolCall)) return false;
279 const rawOutput = unknownRecord(toolCall.data.rawOutput);
280 if (rawOutput !== undefined && nonEmptyString(rawOutput.type)?.toLowerCase() === "monitor") {
281 return rawOutput.persistent === true;
282 }
283 const rawInput = unknownRecord(toolCall.data.rawInput);
284 return rawInput?.persistent === true;
285}
286
287/**
288 * When get_command_or_subagent_output / TaskOutput completes registered monitor
289 * task(s), return hydration for each completed background tool id.
290 */
291export function extractXAiBackgroundTaskCompletion(toolCall: AcpToolCallState): ReadonlyArray<{
292 readonly taskId: string;
293 readonly status: "running" | "completed" | "failed";
294 readonly appendOutput: string;
295}> {
296 const rawInput = unknownRecord(toolCall.data.rawInput);
297 if (!isXAiGetSubagentOutputTool(toolCall, rawInput)) return [];
298 // Caller matches taskId against registered background tools (monitors).
299 // Subagent hydration stays on extractXAiAcpSubagentUpdate.
300 const text = xAiToolOutputText(toolCall);
301 const status = statusFromGetOutputTool(toolCall, text);
302 const appendOutput = resultFromGetOutputTool(toolCall, text) ?? "";
303 // Prefer the completed identity from the result/header over the request list
304 // (one call can poll multiple task_ids).
305 const resultTaskId = resultTaskIdFromGetOutputTool(toolCall, text);
306 if (resultTaskId !== undefined) {
307 return [{ taskId: resultTaskId, status, appendOutput }];
308 }
309 const requestIds: string[] = [];
310 const push = (value: unknown) => {
311 const id = nonEmptyString(value);
312 if (id !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(id)) {
313 requestIds.push(id);
314 }
315 };
316 if (Array.isArray(rawInput?.task_ids)) {
317 for (const entry of rawInput.task_ids) push(entry);
318 }
319 push(rawInput?.task_id);
320 push(rawInput?.taskId);
321 const unique = [...new Set(requestIds)];
322 return unique.map((taskId) => ({ taskId, status, appendOutput }));
323}
324
325/**
326 * A finished kill_command_or_subagent call is the genuine end signal for every
327 * task id it names: the Grok CLI emits no `_x.ai/task_completed` for a killed
328 * task. A failed kill usually means the task was already gone, so it ends the
329 * id too (failing open to a continuation beats pinning the running set).
330 */
331export function extractXAiKilledBackgroundTasks(toolCall: AcpToolCallState): ReadonlyArray<{
332 readonly taskId: string;
333 readonly status: "running" | "completed" | "failed";
334 readonly appendOutput: string;
335}> {
336 const title = titleKey(toolCall);
337 const rawInput = unknownRecord(toolCall.data.rawInput);
338 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
339 if (!title.includes("kill_command_or_subagent") && variant !== "kill") return [];
340 if (toolCall.status !== "completed" && toolCall.status !== "failed") return [];
341 const taskIds: string[] = [];
342 const push = (value: unknown) => {
343 const id = nonEmptyString(value);
344 if (id !== undefined) taskIds.push(id);
345 };
346 if (Array.isArray(rawInput?.task_ids)) {
347 for (const entry of rawInput.task_ids) push(entry);
348 }
349 push(rawInput?.task_id);
350 push(rawInput?.taskId);
351 return [...new Set(taskIds)].map((taskId) => ({
352 taskId,
353 status: "completed" as const,
354 appendOutput: "",
355 }));
356}
357
358const XAiTaskLifecycleNotification = Schema.Struct({
359 sessionId: Schema.String,
360 update: Schema.Struct({
361 sessionUpdate: Schema.String,
362 task_id: Schema.optional(Schema.String),
363 tool_call_id: Schema.optional(Schema.String),
364 task_snapshot: Schema.optional(
365 Schema.Struct({
366 task_id: Schema.optional(Schema.String),
367 }),
368 ),
369 }),
370 _meta: Schema.optional(Schema.Unknown),
371});
372
373type XAiTaskLifecycleNotification = typeof XAiTaskLifecycleNotification.Type;
374
375export interface XAiBackgroundTaskLifecycleMutation {
376 readonly sessionId: string;
377 readonly taskId: string;
378 readonly status: "running" | "completed";
379}
380
381export function xAiBackgroundTaskLifecycleMutation(
382 notification: XAiTaskLifecycleNotification,
383 status: "running" | "completed",
384): XAiBackgroundTaskLifecycleMutation | null {
385 const update = notification.update;
386 const taskId =
387 nonEmptyString(update.task_snapshot?.task_id) ??
388 nonEmptyString(update.task_id) ??
389 nonEmptyString(update.tool_call_id);
390 if (taskId === undefined) return null;
391 return { sessionId: notification.sessionId, taskId, status };
392}
393
394/**
395 * Grok `session/cancel` is detach-and-continue: the CLI reaps the foreground
396 * tool shell, then re-runs the command as a background task
397 * (`_x.ai/task_backgrounded`) that executes to completion
398 * (`_x.ai/task_completed`). Soft interrupts keep the process alive, so those
399 * tasks must be tracked like monitors: residual frames buffer instead of
400 * waking synthetic continuations, and pending-background-work stays truthful
401 * until the task ends (or the model kills it via kill_command_or_subagent).
402 */
403export const registerXAiBackgroundTaskTracking = (
404 runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "handleExtNotification">,
405 apply: (mutation: XAiBackgroundTaskLifecycleMutation) => Effect.Effect<void>,
406): Effect.Effect<void, EffectAcpErrors.AcpError> =>
407 Effect.gen(function* () {
408 yield* runtime.handleExtNotification(
409 "_x.ai/task_backgrounded",
410 XAiTaskLifecycleNotification,
411 (notification) => {
412 const mutation = xAiBackgroundTaskLifecycleMutation(notification, "running");
413 return mutation === null ? Effect.void : apply(mutation);
414 },
415 );
416 yield* runtime.handleExtNotification(
417 "_x.ai/task_completed",
418 XAiTaskLifecycleNotification,
419 (notification) => {
420 const mutation = xAiBackgroundTaskLifecycleMutation(notification, "completed");
421 return mutation === null ? Effect.void : apply(mutation);
422 },
423 );
424 });
425
426/**
427 * Grok ACP often sends `title: "Tool"` even when rawInput has a useful
428 * description or variant (especially Monitor). Prefer description/variant so
429 * the timeline matches Claude-style tool names rather than a generic label.
430 */
431export function isGenericAcpToolTitle(title: string | undefined): boolean {
432 const key = (title ?? "").trim().toLowerCase();
433 return key.length === 0 || key === "tool" || key === "tool call" || key === "terminal";
434}
435
436export function resolveXAiAcpToolTitle(toolCall: AcpToolCallState): string | undefined {
437 if (!isGenericAcpToolTitle(toolCall.title)) {
438 return nonEmptyString(toolCall.title);
439 }
440 const rawInput = unknownRecord(toolCall.data.rawInput);
441 const description = nonEmptyString(rawInput?.description);
442 const variant = nonEmptyString(rawInput?.variant)?.toLowerCase();
443 if (description !== undefined) {
444 if (variant === "monitor" && !description.toLowerCase().startsWith("monitor")) {
445 return `Monitor: ${description}`;
446 }
447 return description;
448 }
449 if (variant === "monitor") return "Monitor";
450 if (variant === "task" || variant === "cursortask" || variant === "spawn_subagent") {
451 return "Task";
452 }
453 if (variant === "taskoutput" || variant === "task_output") return "Task output";
454 if (variant !== undefined) {
455 return variant;
456 }
457 const command =
458 nonEmptyString(rawInput?.command) ??
459 nonEmptyString(unknownRecord(toolCall.data.rawOutput)?.command);
460 if (command !== undefined) {
461 return command.length > 80 ? `${command.slice(0, 77)}...` : command;
462 }
463 return nonEmptyString(toolCall.title);
464}
465
466/**
467 * Normalize Grok ACP tool presentation and monitor lifecycle:
468 * - replace generic titles ("Tool") with description / variant labels
469 * - structured Monitor start ACK stays running
470 * - text start ACK stays running
471 * - structured Bash results with exit_code are terminal (any tool: post-settle
472 * wake re-reports of a finished monitor arrive with empty rawInput and a
473 * generic title, so monitor detection cannot match; without this they replay
474 * as running and the timeline row spins forever)
475 */
476export function normalizeXAiAcpToolCallState(toolCall: AcpToolCallState): AcpToolCallState {
477 const resolvedTitle = resolveXAiAcpToolTitle(toolCall);
478 const withTitle =
479 resolvedTitle !== undefined && resolvedTitle !== toolCall.title
480 ? { ...toolCall, title: resolvedTitle }
481 : toolCall;
482
483 const rawOutput = unknownRecord(withTitle.data.rawOutput);
484 const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase();
485 if (outputType === "bash") {
486 // Same key set as the adapter's command projection (commandExitCode).
487 const exitCode = ["exit_code", "exitCode", "code"]
488 .map((key) => rawOutput?.[key])
489 .find((value) => typeof value === "number" && Number.isInteger(value));
490 if (typeof exitCode === "number") {
491 if (exitCode === 0 && (xAiToolOutputText(withTitle)?.trim().length ?? 0) === 0) {
492 return { ...withTitle, status: "inProgress" };
493 }
494 return {
495 ...withTitle,
496 status: exitCode === 0 ? "completed" : "failed",
497 };
498 }
499 }
500 if (!isXAiMonitorTool(withTitle)) {
501 return withTitle;
502 }
503 if (outputType === "monitor") {
504 // Start registration only; process still running in the background.
505 return { ...withTitle, status: "inProgress" };
506 }
507 const output = xAiToolOutputText(withTitle);
508 if (withTitle.status === "completed" && isXAiMonitorStartAck(output)) {
509 return { ...withTitle, status: "inProgress" };
510 }
511 return withTitle;
512}
513
514/**
515 * Parse Grok synthetic monitor traffic that arrives as root text chunks
516 * (`<monitor-event>` lines, batched `<monitor>` event blocks, and
517 * "Monitor ... ended" reminders). Coalesced chunks may contain several events;
518 * return every match so later progress / end notices are not dropped.
519 */
520export function extractXAiAcpBackgroundToolMutation(
521 text: string,
522): ReadonlyArray<XAiAcpBackgroundToolMutation> {
523 const mutations: XAiAcpBackgroundToolMutation[] = [];
524
525 const eventRe = new RegExp(
526 `<monitor-event\\s+task_id=["']?(${XAI_UUID_RE})["']?\\s*>\\s*([\\s\\S]*?)\\s*</monitor-event>`,
527 "gi",
528 );
529 for (const eventMatch of text.matchAll(eventRe)) {
530 if (eventMatch[1] === undefined) continue;
531 const line = (eventMatch[2] ?? "").trim();
532 mutations.push({
533 taskId: eventMatch[1],
534 status: "running",
535 appendOutput: line.length > 0 ? `${line}\n` : "",
536 });
537 }
538
539 // Batched form: "N monitor events from 1 monitor ...:\n<monitor
540 // description="..." task_id="...">\n[1] line\n</monitor>". Still a running
541 // monitor; must track and filter like single-event chatter.
542 const batchedRe = new RegExp(
543 `<monitor\\s[^>]*task_id=["']?(${XAI_UUID_RE})["']?[^>]*>\\s*([\\s\\S]*?)\\s*</monitor>`,
544 "gi",
545 );
546 for (const batchedMatch of text.matchAll(batchedRe)) {
547 if (batchedMatch[1] === undefined) continue;
548 const lines = (batchedMatch[2] ?? "").trim();
549 mutations.push({
550 taskId: batchedMatch[1],
551 status: "running",
552 appendOutput: lines.length > 0 ? `${lines}\n` : "",
553 });
554 }
555
556 const endedRe = new RegExp(`Monitor\\s+["']?(${XAI_UUID_RE})["']?\\s+ended`, "gi");
557 for (const endedMatch of text.matchAll(endedRe)) {
558 if (endedMatch[1] === undefined) continue;
559 // Only the "Monitor … ended …" clause is outcome text. Description/output
560 // after the header can contain words like "error" without meaning failure.
561 const outcomeClause = (endedMatch[0] ?? "").trim();
562 const afterHeader = text.slice((endedMatch.index ?? 0) + endedMatch[0].length);
563 const outcomeTail = afterHeader.split(/\n/)[0] ?? "";
564 const outcome = `${outcomeClause}${outcomeTail}`;
565 const summary = text.replace(/<\/?system-reminder>/gi, "").trim();
566 mutations.push({
567 taskId: endedMatch[1],
568 status:
569 /exited\s*\(\s*code\s*0\s*\)/i.test(outcome) || /ended cleanly/i.test(outcome)
570 ? "completed"
571 : /exited|failed|error|signal/i.test(outcome)
572 ? "failed"
573 : "completed",
574 appendOutput: summary.length > 0 ? `${summary}\n` : "Monitor ended.\n",
575 });
576 }
577
578 return mutations;
579}
580
581/**
582 * Parse Grok's root-session subagent end reminder ("Background subagent
583 * "<uuid>" (general-purpose: "...") completed successfully."). The agent is
584 * free to skip get_command_or_subagent_output hydration, so this reminder can
585 * be the only terminal signal for the subagent row; a row stuck on running
586 * holds deferred finalize (and the whole run) open until harness timeout.
587 */
588function stripLeadingBalancedParenGroup(text: string): string {
589 const trimmed = text.replace(/^\s+/, "");
590 if (!trimmed.startsWith("(")) return trimmed;
591 let depth = 0;
592 for (let index = 0; index < trimmed.length; index += 1) {
593 const char = trimmed[index];
594 if (char === "(") depth += 1;
595 else if (char === ")") {
596 depth -= 1;
597 if (depth === 0) return trimmed.slice(index + 1).replace(/^\s+/, "");
598 }
599 }
600 return trimmed;
601}
602
603export function extractXAiAcpSubagentEndNotice(text: string): XAiAcpSubagentEndNotice | undefined {
604 const match = text.match(new RegExp(`Background subagent\\s+["']?(${XAI_UUID_RE})["']?`, "i"));
605 if (match?.[1] === undefined) return undefined;
606 // Skip the parenthesized descriptor (subagent type and title) so its words
607 // cannot masquerade as the outcome verb. Balance parens so nested titles
608 // like `(general-purpose: "Check (backend) failed")` do not leak.
609 const tail = stripLeadingBalancedParenGroup(text.slice((match.index ?? 0) + match[0].length));
610 if (/completed successfully/i.test(tail)) {
611 return { childSessionId: match[1], status: "completed" };
612 }
613 if (/\b(failed|errored|error|crashed|cancelled|canceled|interrupted|timed out)\b/i.test(tail)) {
614 return { childSessionId: match[1], status: "failed" };
615 }
616 if (/\b(completed|finished|ended)\b/i.test(tail)) {
617 return { childSessionId: match[1], status: "completed" };
618 }
619 return undefined;
620}
621
622function taskIdsFromGetOutputTool(
623 toolCall: AcpToolCallState,
624 rawInput: Record<string, unknown> | undefined,
625): ReadonlyArray<string> {
626 const ids: string[] = [];
627 const push = (value: unknown) => {
628 const text = nonEmptyString(value);
629 if (text !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(text)) {
630 ids.push(text);
631 }
632 };
633 if (Array.isArray(rawInput?.task_ids)) {
634 for (const entry of rawInput.task_ids) push(entry);
635 }
636 push(rawInput?.task_id);
637 push(rawInput?.taskId);
638 const resultId = resultTaskIdFromGetOutputTool(toolCall, xAiToolOutputText(toolCall));
639 if (resultId !== undefined) ids.push(resultId);
640 return [...new Set(ids)];
641}
642
643/** Identity of the task the get_output envelope actually reports (not the request list). */
644function resultTaskIdFromGetOutputTool(
645 toolCall: AcpToolCallState,
646 output: string | undefined,
647): string | undefined {
648 const rawOutput = unknownRecord(toolCall.data.rawOutput);
649 const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result);
650 const structured =
651 nonEmptyString(result?.task_id) ??
652 nonEmptyString(result?.taskId) ??
653 nonEmptyString(rawOutput?.task_id) ??
654 nonEmptyString(rawOutput?.taskId);
655 if (structured !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(structured)) {
656 return structured;
657 }
658 if (output === undefined) return undefined;
659 const taskHeader = firstUuidMatch(
660 output,
661 new RegExp(`===\\s*Task\\s+(${XAI_UUID_RE})\\s*===`, "i"),
662 );
663 if (taskHeader !== undefined) return taskHeader;
664 return firstUuidMatch(output, new RegExp(`subagent_id:\\s*(${XAI_UUID_RE})\\b`, "i"));
665}
666
667function resultFromGetOutputTool(
668 toolCall: AcpToolCallState,
669 output: string | undefined,
670): string | null {
671 const rawOutput = unknownRecord(toolCall.data.rawOutput);
672 const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result);
673 const structured =
674 nonEmptyString(result?.output) ??
675 nonEmptyString(result?.text) ??
676 decodeByteText(result?.output);
677 if (structured !== undefined) {
678 // Drop trailing machine meta blocks when present.
679 const cleaned = structured
680 .replace(/<subagent_meta>[\s\S]*?<\/subagent_meta>/gi, "")
681 .replace(/<subagent_result>[\s\S]*?<\/subagent_result>/gi, "")
682 .trim();
683 if (cleaned.length > 0) return cleaned;
684 return null;
685 }
686 if (output === undefined) return null;
687 const marker = output.match(/=== Output ===\s*([\s\S]*)$/i);
688 const body = (marker?.[1] ?? output).trim();
689 return body.length > 0 ? body : null;
690}
691
692function statusFromGetOutputTool(
693 toolCall: AcpToolCallState,
694 output: string | undefined,
695): "running" | "completed" | "failed" {
696 if (toolCall.status === "failed") return "failed";
697 const rawOutput = unknownRecord(toolCall.data.rawOutput);
698 const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result);
699 const structuredStatus = nonEmptyString(result?.status)?.toLowerCase();
700 if (structuredStatus === "completed" || structuredStatus === "success") return "completed";
701 if (structuredStatus === "failed" || structuredStatus === "error") return "failed";
702 if (structuredStatus === "running" || structuredStatus === "pending") return "running";
703 if (typeof result?.exit_code === "number") {
704 return result.exit_code === 0 ? "completed" : "failed";
705 }
706 if (output !== undefined) {
707 if (/Status:\s*failed/i.test(output) || /Exit Code:\s*-?(?!0\b)\d+/i.test(output)) {
708 return "failed";
709 }
710 if (/Status:\s*completed/i.test(output) || /Status:\s*success/i.test(output)) {
711 return "completed";
712 }
713 if (/Status:\s*running/i.test(output) || /Status:\s*pending/i.test(output)) {
714 return "running";
715 }
716 }
717 return toolCall.status === "completed" ? "completed" : "running";
718}
719
73/**720/**
74 * Recognizes Grok's private `Task` tool envelope without teaching the generic721 * Recognizes Grok Task / spawn_subagent envelopes and get_command hydration
75 * ACP adapter about xAI tool names or result formatting.722 * without teaching the generic ACP adapter about xAI tool names.
723 *
724 * Current Grok CLI returns spawn_subagent immediately with a background ACK
725 * (`subagent_id: ...`). That must stay `running` until child work finishes or
726 * get_command_or_subagent_output reports a terminal status.
76 */727 */
77export function extractXAiAcpSubagentUpdate(728export function extractXAiAcpSubagentUpdate(
78 toolCall: AcpToolCallState,729 toolCall: AcpToolCallState,
79): XAiAcpSubagentUpdate | undefined {730): XAiAcpSubagentUpdate | undefined {
80 const rawInput = unknownRecord(toolCall.data.rawInput);731 const rawInput = unknownRecord(toolCall.data.rawInput);
81 const isTask =732 const output = xAiToolOutputText(toolCall);
82 toolCall.title === "Task" ||733
83 rawInput?.variant === "CursorTask" ||734 if (isXAiGetSubagentOutputTool(toolCall, rawInput)) {
84 nonEmptyString(rawInput?.subagent_type) !== undefined;735 const taskIds = taskIdsFromGetOutputTool(toolCall, rawInput);
85 if (!isTask || rawInput === undefined) return undefined;736 // Prefer the completed task identity from the result/header over the first
86 737 // requested task_ids entry (one call can poll multiple task_ids).
87 const output = xAiTaskOutputText(toolCall);738 const childSessionId =
88 const childSessionId =739 resultTaskIdFromGetOutputTool(toolCall, output) ??
89 output?.match(/(?:^|\n)Agent ID:\s*([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\b/i)?.[1] ??740 taskIds[0] ??
90 null;741 extractXAiChildSessionId(output);
91 const result =742 if (childSessionId === null) return undefined;
92 output?.replace(/(?:^|\n)Agent ID:\s*[0-9a-f-]+[^\n]*(?:\n|$)/gi, "\n").trim() || null;743 // Prefer the durable subagent row as the completion surface (Claude/Codex
93 const status =744 // style). Suppress the noisy TaskOutput tool card; monitor hydration is
94 toolCall.status === "failed"745 // handled separately via extractBackgroundTaskCompletion.
95 ? "failed"746 return {
96 : toolCall.status === "completed"747 nativeTaskId: toolCall.toolCallId,
97 ? "completed"748 prompt: "",
98 : "running";749 title: null,
750 model: null,
751 status: statusFromGetOutputTool(toolCall, output),
752 childSessionId,
753 result: resultFromGetOutputTool(toolCall, output),
754 suppressNormalTool: true,
755 };
756 }
757
758 if (!isXAiSpawnOrTaskTool(toolCall, rawInput)) return undefined;
759
760 const childSessionId = extractXAiChildSessionId(output);
761 const asyncSpawnAck = isXAiAsyncSpawnAck(output);
762 const legacyResult =
763 output
764 ?.replace(new RegExp(`(?:^|\\n)\\s*Agent ID:\\s*${XAI_UUID_RE}[^\\n]*(?:\\n|$)`, "gi"), "\n")
765 .replace(
766 new RegExp(`(?:^|\\n)\\s*subagent_id:\\s*${XAI_UUID_RE}[^\\n]*(?:\\n|$)`, "gi"),
767 "\n",
768 )
769 .trim() || null;
770
771 let status: "running" | "completed" | "failed";
772 if (toolCall.status === "failed") {
773 status = "failed";
774 } else if (asyncSpawnAck) {
775 // Spawn RPC completed, but the child is still running.
776 status = "running";
777 } else if (toolCall.status === "completed") {
778 status = "completed";
779 } else {
780 status = "running";
781 }
99 782
100 return {783 return {
101 nativeTaskId: toolCall.toolCallId,784 nativeTaskId: toolCall.toolCallId,
102 prompt: nonEmptyString(rawInput.prompt) ?? "",785 prompt: nonEmptyString(rawInput?.prompt) ?? "",
103 title: nonEmptyString(rawInput.description) ?? null,786 title: nonEmptyString(rawInput?.description) ?? null,
104 model: nonEmptyString(rawInput.model) ?? null,787 model: nonEmptyString(rawInput?.model) ?? null,
105 status,788 status,
106 childSessionId,789 childSessionId,
107 result,790 result: asyncSpawnAck ? null : legacyResult,
791 suppressNormalTool: true,
108 };792 };
109}793}
110 794
@@ -290,301 +974,57 @@ export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCan · 179 unmodified lines
290 return { outcome: "cancelled" };974 return { outcome: "cancelled" };
291}975}
292 976
293// ---------------------------------------------------------------------------977function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined {
294// x.ai/exit_plan_mode — plan approval gate (mirrors Grok Build TUI plan window)978 const meta = response._meta;
295// ---------------------------------------------------------------------------979 if (meta === null || typeof meta !== "object") {
296 980 return undefined;
297const XAiExitPlanModeParams = Schema.Struct({
298 sessionId: Schema.String,
299 toolCallId: Schema.String,
300 planContent: Schema.optional(Schema.NullOr(Schema.String)),
301});
302
303const XAiWrappedExitPlanModeParams = Schema.Struct({
304 method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]),
305 params: XAiExitPlanModeParams,
306});
307
308export const XAiExitPlanModeRequest = Schema.Union([
309 XAiExitPlanModeParams,
310 XAiWrappedExitPlanModeParams,
311]);
312
313type XAiExitPlanModeRequestParams = typeof XAiExitPlanModeParams.Type;
314type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type;
315
316function unwrapExitPlanModeParams(params: XAiExitPlanModeRequest): XAiExitPlanModeRequestParams {
317 return "params" in params ? params.params : params;
318}
319
320/** Empty-state copy when Grok exits plan mode without a plan file. */
321export const XAI_EMPTY_PLAN_MARKDOWN =
322 "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)";
323
324export function extractXAiExitPlanMarkdown(
325 params: XAiExitPlanModeRequest,
326 fallback?: string | null,
327): string {
328 const content = unwrapExitPlanModeParams(params).planContent;
329 const fromRequest = typeof content === "string" ? trimmed(content) : undefined;
330 if (fromRequest) {
331 return fromRequest;
332 }
333 const fromFallback = fallback?.trim();
334 if (fromFallback && fromFallback.length > 0) {
335 return fromFallback;
336 }981 }
337 return XAI_EMPTY_PLAN_MARKDOWN;982 const promptId = meta.promptId ?? meta.requestId;
338}983 return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined;
339
340export type XAiExitPlanModeOutcome = "approved" | "abandoned" | "request_changes";
341
342export interface XAiExitPlanModeResponse {
343 readonly outcome: XAiExitPlanModeOutcome;
344 readonly feedback?: string;
345}
346
347/**
348 * Client captured the plan for T3's proposed-plan card. Abandon the native
349 * Grok plan-approval gate so the turn unblocks; the user implements via T3 UI.
350 */
351export function makeXAiExitPlanModeCapturedResponse(feedback?: string): XAiExitPlanModeResponse {
352 return {
353 outcome: "abandoned",
354 feedback:
355 feedback ??
356 "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.",
357 };
358}
359
360function normalizeFsPath(value: string): string {
361 return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
362}
363
364function pathHasTraversalSegment(normalized: string): boolean {
365 return normalized.split("/").includes("..");
366}984}
367 985
368function addGrokSessionPrefix(986function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.StopReason {
369 prefixes: Set<string>,987 switch (value) {
370 homeOrRoot: string,988 case "cancelled":
371 nestedGrokDir: boolean,989 case "end_turn":
372): void {990 case "max_tokens":
373 const root = normalizeFsPath(homeOrRoot);991 case "max_turn_requests":
374 if (!root) {992 case "refusal":
375 return;993 return value;
994 default:
995 return "end_turn";
376 }996 }
377 prefixes.add(nestedGrokDir ? `${root}/.grok/sessions/` : `${root}/sessions/`);
378}
379
380/** Injected host bits so these helpers stay off `process.platform` / `process.env`. */
381export interface GrokPlanPathHost {
382 readonly platform: NodeJS.Platform;
383 readonly environment: NodeJS.ProcessEnv;
384}
385
386function grokPlanSessionPrefixes(environment: NodeJS.ProcessEnv): ReadonlySet<string> {
387 const prefixes = new Set<string>();
388 addGrokSessionPrefix(prefixes, NodeOS.homedir(), true);
389 addGrokSessionPrefix(prefixes, "~", true);
390 addGrokSessionPrefix(prefixes, environment.HOME ?? "", true);
391 addGrokSessionPrefix(prefixes, environment.USERPROFILE ?? "", true);
392 // ACP mock and isolated Grok spawns use a HOME that is not the server process home.
393 addGrokSessionPrefix(prefixes, "/tmp/mock-home", true);
394 const grokHome = environment.GROK_HOME ?? "";
395 addGrokSessionPrefix(prefixes, grokHome, false);
396 addGrokSessionPrefix(prefixes, grokHome, true);
397 return prefixes;
398}997}
399 998
400const CANONICAL_HOME_GROK_SESSION_PATH =999function promptResponseFromXAi(
401 /^(?:\/home\/[^/]+|\/Users\/[^/]+|[a-zA-Z]:\/Users\/[^/]+)\/\.grok\/sessions\/(?:[^/]+\/)+plan\.md$/;1000 notification: XAiPromptCompleteNotification,
402const CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH = new RegExp(1001): EffectAcpSchema.PromptResponse {
403 CANONICAL_HOME_GROK_SESSION_PATH.source,1002 const stopReason = normalizeXAiStopReason(notification.stopReason);
404 "i",1003 const meta: Record<string, unknown> = {
405);1004 sessionId: notification.sessionId,
406 1005 };
407/**1006 if (notification.stopReason === undefined) {
408 * True when a path is Grok's session plan file under a Grok home1007 meta[xAiStopReasonMissingMetaKey] = true;
409 * (`~/.grok/sessions/.../plan.md`, `$HOME/.grok/sessions/...`, or `$GROK_HOME/sessions/...`).
410 * Deliberately does not match workspace files named `plan.md` (e.g. docs/plan.md
411 * or a repo-local `.grok/sessions/.../plan.md`).
412 */
413export function isGrokPlanMarkdownPath(
414 path: string | undefined | null,
415 host: GrokPlanPathHost,
416): boolean {
417 if (typeof path !== "string") {
418 return false;
419 }
420 const normalized = path.trim().replace(/\\/g, "/");
421 const win32 = host.platform === "win32";
422 const haystack = win32 ? normalized.toLowerCase() : normalized;
423 if (
424 normalized.length === 0 ||
425 !haystack.endsWith("/plan.md") ||
426 pathHasTraversalSegment(normalized)
427 ) {
428 return false;
429 }1008 }
430 for (const prefix of grokPlanSessionPrefixes(host.environment)) {1009 if (notification.promptId !== undefined) {
431 const needle = win32 ? prefix.toLowerCase() : prefix;1010 meta.promptId = notification.promptId;
432 if (!haystack.startsWith(needle)) {1011 meta.requestId = notification.promptId;
433 continue;
434 }
435 const rest = haystack.slice(needle.length);
436 // Session layout: <home>/.grok/sessions/<encoded-cwd>/<session-id>/plan.md
437 if (rest !== "plan.md" && rest.endsWith("plan.md")) {
438 return true;
439 }
440 }1012 }
441 return (1013 if (notification.agentResult !== undefined) {
442 win32 ? CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH : CANONICAL_HOME_GROK_SESSION_PATH1014 meta.agentResult = notification.agentResult;
443 ).test(haystack);
444}
445
446/**
447 * Extract plan markdown from a Grok write/edit tool call targeting plan.md.
448 * Used so T3 can show the plan while plan mode is still active (before exit).
449 */
450export function extractGrokPlanMarkdownFromToolCallData(
451 data: Record<string, unknown> | undefined,
452 host: GrokPlanPathHost,
453): string | undefined {
454 if (!data) {
455 return undefined;
456 }1015 }
457 1016 return {
458 let sawPlanWrite = false;1017 stopReason,
459 const takePlanText = (1018 _meta: meta,
460 value: string | undefined,
461 filePath: string | undefined,
462 ): string | undefined => {
463 if (!isGrokPlanMarkdownPath(filePath, host) || value === undefined) {
464 return undefined;
465 }
466 sawPlanWrite = true;
467 const trimmed = value.trim();
468 return trimmed.length > 0 ? trimmed : undefined;
469 };1019 };
470
471 const rawInput = data.rawInput;
472 if (isRecord(rawInput)) {
473 const filePath =
474 (typeof rawInput.file_path === "string" ? rawInput.file_path : undefined) ??
475 (typeof rawInput.path === "string" ? rawInput.path : undefined);
476 const content = typeof rawInput.content === "string" ? rawInput.content : undefined;
477 const fromRaw = takePlanText(content, filePath);
478 if (fromRaw !== undefined) {
479 return fromRaw;
480 }
481 }
482
483 const content = data.content;
484 if (Array.isArray(content)) {
485 for (const block of content) {
486 if (!isRecord(block) || block.type !== "diff") {
487 continue;
488 }
489 const path = typeof block.path === "string" ? block.path : undefined;
490 const newText = typeof block.newText === "string" ? block.newText : undefined;
491 const fromDiff = takePlanText(newText, path);
492 if (fromDiff !== undefined) {
493 return fromDiff;
494 }
495 }
496 }
497
498 return sawPlanWrite ? "" : undefined;
499}
500
501function isRecord(value: unknown): value is Record<string, unknown> {
502 return typeof value === "object" && value !== null && !Array.isArray(value);
503}1020}
504 1021
505/**
506 * Adds Grok's private prompt-completion fallback around a standards-only ACP runtime.
507 * The underlying runtime remains unaware of xAI methods and metadata.
508 */
509export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletionRuntime")(
510 function* (runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]) {
511 const activeSessionIdRef = yield* Ref.make<string | undefined>(undefined);
512 const pendingRef = yield* Ref.make<ReadonlyArray<PendingXAiPromptCompletion>>([]);
513 const completedPromptIdsRef = yield* Ref.make<ReadonlyArray<string>>([]);
514 let nextPromptFallbackId = 0;
515 const allocatePromptFallbackId = Effect.sync(() => {
516 nextPromptFallbackId += 1;
517 return `t3-xai-prompt-${nextPromptFallbackId}`;
518 });
519
520 yield* runtime.handleExtNotification(
521 "_x.ai/session/prompt_complete",
522 XAiPromptCompleteNotification,
523 (notification) =>
524 resolveXAiPromptCompletionFallback({
525 pendingRef,
526 completedPromptIdsRef,
527 notification,
528 }),
529 );
530
531 return {
532 ...runtime,
533 start: () =>
534 runtime
535 .start()
536 .pipe(Effect.tap((started) => Ref.set(activeSessionIdRef, started.sessionId))),
537 prompt: (payload, promptOptions?) =>
538 Effect.gen(function* () {
539 const sessionId = yield* Ref.get(activeSessionIdRef);
540 if (sessionId === undefined) {
541 return yield* runtime.prompt(payload, promptOptions);
542 }
543
544 const promptId = yield* allocatePromptFallbackId;
545 const fallback = yield* registerXAiPromptCompletionFallback(
546 pendingRef,
547 sessionId,
548 promptId,
549 );
550 const requestPayload = {
551 ...payload,
552 _meta: {
553 ...payload._meta,
554 promptId: fallback.promptId,
555 requestId: fallback.promptId,
556 },
557 } satisfies Omit<EffectAcpSchema.PromptRequest, "sessionId">;
558
559 return yield* Effect.raceFirst(
560 runtime.prompt(requestPayload, promptOptions),
561 Deferred.await(fallback.deferred),
562 ).pipe(
563 Effect.tap((response) =>
564 rememberCompletedXAiPromptId(completedPromptIdsRef, response, fallback.promptId),
565 ),
566 Effect.ensuring(unregisterXAiPromptCompletionFallback(pendingRef, fallback.deferred)),
567 );
568 }),
569 cancel: Ref.get(activeSessionIdRef).pipe(
570 Effect.flatMap((sessionId) =>
571 sessionId === undefined
572 ? runtime.cancel
573 : abortPendingPromptCompletions(pendingRef, sessionId).pipe(
574 Effect.andThen(runtime.cancel),
575 ),
576 ),
577 ),
578 } satisfies AcpSessionRuntime.AcpSessionRuntime["Service"];
579 },
580);
581
582const registerXAiPromptCompletionFallback = (1022const registerXAiPromptCompletionFallback = (
583 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,1023 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,
584 sessionId: string,1024 sessionId: string,
585 promptId: string,1025 promptId: string,
586) =>1026) =>
587 Deferred.make<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>().pipe(1027 Deferred.make<EffectAcpSchema.PromptResponse>().pipe(
588 Effect.tap((deferred) =>1028 Effect.tap((deferred) =>
589 Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]),1029 Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]),
590 ),1030 ),
@@ -593,7 +1033,7 @@ const registerXAiPromptCompletionFallback = ( · 2 unmodified lines
593 1033
594const unregisterXAiPromptCompletionFallback = (1034const unregisterXAiPromptCompletionFallback = (
595 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,1035 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,
596 deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>,1036 deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse>,
597) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred));1037) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred));
598 1038
599const abortPendingPromptCompletions = (1039const abortPendingPromptCompletions = (
@@ -655,7 +1095,15 @@ const resolveXAiPromptCompletionFallback = ({ · 55 unmodified lines
655 entry.sessionId === notification.sessionId &&1095 entry.sessionId === notification.sessionId &&
656 entry.promptId === notification.promptId,1096 entry.promptId === notification.promptId,
657 )1097 )
658 : pending.findIndex((entry) => entry.sessionId === notification.sessionId);1098 : (() => {
1099 const sessionPendingIndexes = pending.flatMap((entry, entryIndex) =>
1100 entry.sessionId === notification.sessionId ? [entryIndex] : [],
1101 );
1102 if (sessionPendingIndexes.length !== 1) {
1103 return -1;
1104 }
1105 return sessionPendingIndexes[0] ?? -1;
1106 })();
659 if (index < 0) {1107 if (index < 0) {
660 return [Effect.void, pending] as const;1108 return [Effect.void, pending] as const;
661 }1109 }
@@ -664,54 +1112,22 @@ const resolveXAiPromptCompletionFallback = ({ · 2 unmodified lines
664 return [Effect.void, pending] as const;1112 return [Effect.void, pending] as const;
665 }1113 }
666 return [1114 return [
667 settleXAiPromptCompletion(entry.deferred, notification),1115 Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid),
668 [...pending.slice(0, index), ...pending.slice(index + 1)],1116 [...pending.slice(0, index), ...pending.slice(index + 1)],
669 ] as const;1117 ] as const;
670 }).pipe(Effect.flatten);1118 }).pipe(Effect.flatten);
671 }),1119 }),
672 );1120 );
673 1121
674const settleXAiPromptCompletion = (
675 deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>,
676 notification: XAiPromptCompleteNotification,
677) => {
678 if (notification.stopReason === "rate_limit") {
679 return Deferred.fail(
680 deferred,
681 new EffectAcpErrors.AcpRequestError({
682 code: xAiRateLimitedErrorCode,
683 errorMessage: "Grok usage limit reached. Try again later.",
684 }),
685 ).pipe(Effect.asVoid);
686 }
687 if (notification.stopReason === "error") {
688 return Deferred.fail(
689 deferred,
690 EffectAcpErrors.AcpRequestError.internalError(
691 xAiAgentResultMessage(notification.agentResult) ?? "Grok prompt failed.",
692 ),
693 ).pipe(Effect.asVoid);
694 }
695 return Deferred.succeed(deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid);
696};
697
698function xAiAgentResultMessage(value: unknown): string | undefined {
699 if (typeof value === "string") {
700 return trimmed(value);
701 }
702 if (value === null || typeof value !== "object") {
703 return undefined;
704 }
705 const message = "message" in value ? value.message : undefined;
706 return typeof message === "string" ? trimmed(message) : undefined;
707}
708
709const rememberCompletedXAiPromptId = (1122const rememberCompletedXAiPromptId = (
710 completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>,1123 completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>,
711 response: EffectAcpSchema.PromptResponse,1124 response: EffectAcpSchema.PromptResponse,
712 fallbackPromptId: string,1125 fallbackPromptId: string,
713) => {1126) => {
714 const promptId = promptIdFromResponse(response) ?? fallbackPromptId;1127 const promptId = promptIdFromResponse(response) ?? fallbackPromptId;
1128 if (promptId.length === 0) {
1129 return Effect.void;
1130 }
715 return Ref.update(completedPromptIdsRef, (completedPromptIds) => {1131 return Ref.update(completedPromptIdsRef, (completedPromptIds) => {
716 if (completedPromptIds.includes(promptId)) {1132 if (completedPromptIds.includes(promptId)) {
717 return completedPromptIds;1133 return completedPromptIds;
@@ -720,14 +1136,111 @@ const rememberCompletedXAiPromptId = ( · 2 unmodified lines
720 });1136 });
721};1137};
722 1138
723function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined {1139/**
724 const meta = response._meta;1140 * Grok-specific ACP runtime wrapper. Races `session/prompt` against root-matched
725 if (meta === null || typeof meta !== "object") {1141 * terminal notifications:
726 return undefined;1142 * - `_x.ai/session/update` + `turn_completed` + matching `prompt_id` (current CLI)
727 }1143 * - `_x.ai/session/prompt_complete` (legacy / alternate builds)
728 const promptId = meta.promptId ?? meta.requestId;1144 *
729 return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined;1145 * Pending entries are keyed by root sessionId + T3-injected promptId, so
730}1146 * foreign/child sessions and `task-completed-*` ids do not settle the root turn.
1147 */
1148export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletionRuntime")(
1149 function* (runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]) {
1150 let nextPromptFallbackId = 0;
1151 const allocatePromptFallbackId = Effect.sync(() => {
1152 nextPromptFallbackId += 1;
1153 return `t3-xai-prompt-${nextPromptFallbackId}`;
1154 });
1155 const pendingXAiPromptCompletionsRef = yield* Ref.make<
1156 ReadonlyArray<PendingXAiPromptCompletion>
1157 >([]);
1158 const completedXAiPromptIdsRef = yield* Ref.make<ReadonlyArray<string>>([]);
1159
1160 const settleFromPromptComplete = (notification: XAiPromptCompleteNotification) =>
1161 resolveXAiPromptCompletionFallback({
1162 pendingRef: pendingXAiPromptCompletionsRef,
1163 completedPromptIdsRef: completedXAiPromptIdsRef,
1164 notification,
1165 }).pipe(Effect.catch(() => Effect.void));
1166
1167 yield* runtime.handleExtNotification(
1168 "_x.ai/session/prompt_complete",
1169 XAiPromptCompleteNotification,
1170 settleFromPromptComplete,
1171 );
1172
1173 yield* runtime.handleExtNotification(
1174 "_x.ai/session/update",
1175 XAiSessionUpdateNotification,
1176 (notification) => {
1177 const complete = xAiPromptCompleteFromSessionUpdate(notification);
1178 if (complete === null) {
1179 return Effect.void;
1180 }
1181 return settleFromPromptComplete(complete);
1182 },
1183 );
1184
1185 return {
1186 ...runtime,
1187 prompt: (payload) =>
1188 Effect.gen(function* () {
1189 const started = yield* runtime.start();
1190 const promptId = yield* allocatePromptFallbackId;
1191 const fallback = yield* registerXAiPromptCompletionFallback(
1192 pendingXAiPromptCompletionsRef,
1193 started.sessionId,
1194 promptId,
1195 );
1196 const cancelledResponse = promptResponseFromXAi({
1197 sessionId: started.sessionId,
1198 promptId: fallback.promptId,
1199 stopReason: "cancelled",
1200 agentResult: null,
1201 });
1202 const promptRpcFiber = yield* runtime
1203 .prompt({
1204 ...payload,
1205 _meta: {
1206 ...payload._meta,
1207 promptId: fallback.promptId,
1208 requestId: fallback.promptId,
1209 },
1210 })
1211 .pipe(Effect.forkChild);
1212 return yield* Effect.raceFirst(
1213 Fiber.join(promptRpcFiber).pipe(
1214 Effect.catchCause((cause) =>
1215 Cause.hasInterruptsOnly(cause)
1216 ? Effect.succeed(cancelledResponse)
1217 : Effect.failCause(cause),
1218 ),
1219 ),
1220 Deferred.await(fallback.deferred),
1221 ).pipe(
1222 Effect.tap((response) =>
1223 rememberCompletedXAiPromptId(completedXAiPromptIdsRef, response, fallback.promptId),
1224 ),
1225 Effect.ensuring(
1226 Effect.gen(function* () {
1227 yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore);
1228 yield* unregisterXAiPromptCompletionFallback(
1229 pendingXAiPromptCompletionsRef,
1230 fallback.deferred,
1231 );
1232 }),
1233 ),
1234 );
1235 }),
1236 cancel: Effect.gen(function* () {
1237 const started = yield* runtime.start();
1238 yield* abortPendingPromptCompletions(pendingXAiPromptCompletionsRef, started.sessionId);
1239 yield* runtime.cancel;
1240 }),
1241 } satisfies AcpSessionRuntime.AcpSessionRuntime["Service"];
1242 },
1243);
731 1244
732export function promptResponseHasMissingXAiStopReason(1245export function promptResponseHasMissingXAiStopReason(
733 response: EffectAcpSchema.PromptResponse,1246 response: EffectAcpSchema.PromptResponse,
@@ -735,39 +1248,3 @@ export function promptResponseHasMissingXAiStopReason( · 1 unmodified line
735 const meta = response._meta;1248 const meta = response._meta;
736 return meta !== null && typeof meta === "object" && meta[xAiStopReasonMissingMetaKey] === true;1249 return meta !== null && typeof meta === "object" && meta[xAiStopReasonMissingMetaKey] === true;
737}1250}
738
739function promptResponseFromXAi(
740 notification: XAiPromptCompleteNotification,
741): EffectAcpSchema.PromptResponse {
742 const stopReason = normalizeXAiStopReason(notification.stopReason);
743 const meta: Record<string, unknown> = {
744 sessionId: notification.sessionId,
745 };
746 if (notification.stopReason === undefined) {
747 meta[xAiStopReasonMissingMetaKey] = true;
748 }
749 if (notification.promptId !== undefined) {
750 meta.promptId = notification.promptId;
751 meta.requestId = notification.promptId;
752 }
753 if (notification.agentResult !== undefined) {
754 meta.agentResult = notification.agentResult;
755 }
756 return {
757 stopReason,
758 _meta: meta,
759 };
760}
761
762function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.StopReason {
763 switch (value) {
764 case "cancelled":
765 case "end_turn":
766 case "max_tokens":
767 case "max_turn_requests":
768 case "refusal":
769 return value;
770 default:
771 return "end_turn";
772 }
773}
apps/web/src/components/ChatView.logic.test.ts+112−230
@@ -1,4 +1,14 @@
1import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, RunId } from "@t3tools/contracts";1import {
2 EnvironmentId,
3 MessageId,
4 ProjectId,
5 ProviderInstanceId,
6 ThreadId,
7 RunId,
8 TurnItemId,
9 type OrchestrationV2ProjectedTurnItem,
10} from "@t3tools/contracts";
11import * as DateTime from "effect/DateTime";
2import { describe, expect, it } from "vite-plus/test";12import { describe, expect, it } from "vite-plus/test";
3 13
4import type { Thread } from "../types";14import type { Thread } from "../types";
@@ -11,27 +21,17 @@ import { · 6 unmodified lines
11 buildLoadingThreadFromShell,21 buildLoadingThreadFromShell,
12 buildThreadTurnInterruptInput,22 buildThreadTurnInterruptInput,
13 createLocalDispatchSnapshot,23 createLocalDispatchSnapshot,
24 deriveCommittedServerUserMessageIds,
14 deriveComposerSendState,25 deriveComposerSendState,
15 dismissBranchMismatchForSession,26 dismissBranchMismatchForSession,
16 ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
17 getStartedThreadModelChangeBlockReason,27 getStartedThreadModelChangeBlockReason,
18 isVideoPreviewRequestCurrent,
19 hasEnvironmentReconnectWarningGraceElapsed,
20 hasServerAcknowledgedLocalDispatch,28 hasServerAcknowledgedLocalDispatch,
21 isBranchMismatchDismissedForSession,29 isBranchMismatchDismissedForSession,
22 reconcileMountedTerminalThreadIds,30 reconcileMountedTerminalThreadIds,
23 reconcileRetainedMountedThreadIds,31 reconcileRetainedMountedThreadIds,
24 resolveBackgroundDraftWorkspaceOptions,
25 resolveDraftPromotionNavigationTarget,
26 resolveProactiveTurnDiffAction,
27 resolveThreadMetadataUpdateForNextTurn,32 resolveThreadMetadataUpdateForNextTurn,
28 resolveSendEnvMode,33 resolveSendEnvMode,
29 resolveDraftHeroState,
30 scheduleEnvironmentReconnectWarning,
31 startNewThreadForProject,34 startNewThreadForProject,
32 codexArtifactTemplatePromptToAppend,
33 shouldDockDraftHeroForSubmission,
34 shouldReleaseTimelineAnchorForToolActivity,
35 shouldShowBranchMismatchBanner,35 shouldShowBranchMismatchBanner,
36 shouldShowPlanFollowUpPrompt,36 shouldShowPlanFollowUpPrompt,
37 shouldWriteThreadErrorToCurrentServerThread,37 shouldWriteThreadErrorToCurrentServerThread,
@@ -259,184 +259,6 @@ const helloWorldTemplate: CodexArtifactTemplate = { · 221 unmodified lines
259 skillName: "artifact-template-hello-world",259 skillName: "artifact-template-hello-world",
260};260};
261 261
262describe("draft hero submission transition", () => {
263 it("does not dock the composer before a background submission", () => {
264 expect(
265 shouldDockDraftHeroForSubmission({
266 isDraftHeroState: true,
267 activeThreadKey: "environment-local:thread-1",
268 submissionIntent: "background",
269 }),
270 ).toBe(false);
271 });
272
273 it("keeps the composer in the hero layout until navigation after server promotion", () => {
274 expect(
275 resolveDraftHeroState({
276 isLocalDraftThread: false,
277 hasTimelineEntries: true,
278 isWorking: true,
279 draftHeroDockRequested: false,
280 backgroundSubmissionPending: true,
281 }),
282 ).toBe(true);
283 });
284
285 it("does not auto-navigate a background submission after server promotion", () => {
286 expect(
287 resolveDraftPromotionNavigationTarget({
288 serverThreadRef: { environmentId, threadId },
289 serverThread: makeThread({ latestTurn: completedTurn }),
290 backgroundSubmissionPending: true,
291 }),
292 ).toBeNull();
293 });
294});
295
296describe("shouldReleaseTimelineAnchorForToolActivity", () => {
297 const activeTurnId = TurnId.make("active-turn");
298 const anchorMessageId = MessageId.make("anchored-message");
299 const activeToolEntry = {
300 id: "tool-entry",
301 kind: "work" as const,
302 createdAt: now,
303 entry: {
304 id: "active-tool",
305 createdAt: now,
306 turnId: activeTurnId,
307 label: "Run command",
308 tone: "tool" as const,
309 command: "git status",
310 },
311 };
312
313 it("releases the send anchor for tool activity in the active turn", () => {
314 expect(
315 shouldReleaseTimelineAnchorForToolActivity({
316 anchorMessageId,
317 liveFollowEnabled: true,
318 runningTurnId: activeTurnId,
319 timelineEntries: [activeToolEntry],
320 }),
321 ).toBe(true);
322 });
323
324 it("keeps the anchor while the user reads history", () => {
325 expect(
326 shouldReleaseTimelineAnchorForToolActivity({
327 anchorMessageId,
328 liveFollowEnabled: false,
329 runningTurnId: activeTurnId,
330 timelineEntries: [activeToolEntry],
331 }),
332 ).toBe(false);
333 });
334
335 it("ignores tool activity from earlier turns", () => {
336 expect(
337 shouldReleaseTimelineAnchorForToolActivity({
338 anchorMessageId,
339 liveFollowEnabled: true,
340 runningTurnId: activeTurnId,
341 timelineEntries: [
342 {
343 ...activeToolEntry,
344 entry: {
345 ...activeToolEntry.entry,
346 turnId: TurnId.make("previous-turn"),
347 },
348 },
349 ],
350 }),
351 ).toBe(false);
352 });
353
354 it("ignores thinking and error rows without tool activity", () => {
355 expect(
356 shouldReleaseTimelineAnchorForToolActivity({
357 anchorMessageId,
358 liveFollowEnabled: true,
359 runningTurnId: activeTurnId,
360 timelineEntries: [
361 {
362 ...activeToolEntry,
363 entry: {
364 id: "thinking-entry",
365 createdAt: now,
366 turnId: activeTurnId,
367 label: "Thinking",
368 tone: "thinking",
369 },
370 },
371 {
372 ...activeToolEntry,
373 id: "error-entry",
374 entry: {
375 id: "error-entry",
376 createdAt: now,
377 turnId: activeTurnId,
378 label: "Provider error",
379 tone: "error",
380 },
381 },
382 ],
383 }),
384 ).toBe(false);
385 });
386
387 it("does nothing without an anchor or running turn", () => {
388 const input = {
389 anchorMessageId,
390 liveFollowEnabled: true,
391 runningTurnId: activeTurnId,
392 timelineEntries: [activeToolEntry],
393 };
394
395 expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe(
396 false,
397 );
398 expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe(
399 false,
400 );
401 });
402});
403
404describe("environment reconnect warning grace", () => {
405 afterEach(() => vi.useRealTimers());
406
407 it("shows a persistent reconnect after the grace period", () => {
408 vi.useFakeTimers();
409 const showWarning = vi.fn();
410
411 scheduleEnvironmentReconnectWarning(showWarning);
412 vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1);
413 expect(showWarning).not.toHaveBeenCalled();
414
415 vi.advanceTimersByTime(1);
416 expect(showWarning).toHaveBeenCalledOnce();
417 });
418
419 it("cancels the warning when the connection recovers during the grace period", () => {
420 vi.useFakeTimers();
421 const showWarning = vi.fn();
422
423 const cancel = scheduleEnvironmentReconnectWarning(showWarning);
424 cancel();
425 vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS);
426
427 expect(showWarning).not.toHaveBeenCalled();
428 });
429
430 it("does not reuse elapsed grace from another environment", () => {
431 const anotherEnvironmentId = EnvironmentId.make("environment-remote");
432
433 expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true);
434 expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe(
435 false,
436 );
437 });
438});
439
440function makeThread(overrides: Partial<Thread> = {}): Thread {262function makeThread(overrides: Partial<Thread> = {}): Thread {
441 return makeThreadFixture({263 return makeThreadFixture({
442 id: threadId,264 id: threadId,
@@ -800,23 +622,6 @@ describe("resolveSendEnvMode", () => { · 357 unmodified lines
800 });622 });
801});623});
802 624
803describe("resolveBackgroundDraftWorkspaceOptions", () => {
804 it("keeps New worktree selected without reusing the launched worktree", () => {
805 expect(
806 resolveBackgroundDraftWorkspaceOptions({
807 envMode: "worktree",
808 branch: "main",
809 startFromOrigin: true,
810 }),
811 ).toEqual({
812 envMode: "worktree",
813 branch: "main",
814 worktreePath: null,
815 startFromOrigin: true,
816 });
817 });
818});
819
820describe("branchMismatchKey", () => {625describe("branchMismatchKey", () => {
821 it("builds a key from thread id and both branches", () => {626 it("builds a key from thread id and both branches", () => {
822 expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe(627 expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe(
@@ -1042,29 +847,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 219 unmodified lines
1042 ).toBe(false);847 ).toBe(false);
1043 });848 });
1044 849
1045 it("keeps a follow-up active while its provider session is starting", () => {
1046 const localDispatch = createLocalDispatchSnapshot(
1047 makeThread({ latestTurn: completedTurn, session: readySession }),
1048 );
1049
1050 expect(
1051 hasServerAcknowledgedLocalDispatch({
1052 localDispatch,
1053 phase: "connecting",
1054 latestTurn: completedTurn,
1055 latestUserMessageId: MessageId.make("message-followup"),
1056 session: {
1057 ...readySession,
1058 status: "starting",
1059 updatedAt: "2026-03-29T00:01:00.000Z",
1060 },
1061 hasPendingApproval: false,
1062 hasPendingUserInput: false,
1063 threadError: null,
1064 }),
1065 ).toBe(false);
1066 });
1067
1068 it("acknowledges a settled newer turn", () => {850 it("acknowledges a settled newer turn", () => {
1069 const localDispatch = createLocalDispatchSnapshot(851 const localDispatch = createLocalDispatchSnapshot(
1070 makeThread({ latestRun: completedTurn, runtime: readySession }),852 makeThread({ latestRun: completedTurn, runtime: readySession }),
@@ -1195,3 +977,103 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 124 unmodified lines
1195 expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true);977 expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true);
1196 });978 });
1197});979});
980
981describe("deriveCommittedServerUserMessageIds", () => {
982 it("tracks only committed user turn items, not assistant rows or projection-only messages", () => {
983 const turnStartId = MessageId.make("message-turn-start");
984 const steerId = MessageId.make("message-steer");
985 const assistantId = MessageId.make("message-assistant");
986 const committedAt = DateTime.makeUnsafe("2026-06-26T17:50:15.180Z");
987 const runId = RunId.make("run:thread:thread-1:ordinal:1");
988 const visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = [
989 {
990 position: 0,
991 visibility: "local",
992 sourceThreadId: threadId,
993 sourceItemId: TurnItemId.make("turn-item:message-turn-start"),
994 item: {
995 id: TurnItemId.make("turn-item:message-turn-start"),
996 threadId,
997 runId,
998 nodeId: null,
999 providerThreadId: null,
1000 providerTurnId: null,
1001 nativeItemRef: null,
1002 parentItemId: null,
1003 ordinal: 1,
1004 status: "completed",
1005 title: null,
1006 startedAt: committedAt,
1007 completedAt: committedAt,
1008 updatedAt: committedAt,
1009 createdBy: "user",
1010 creationSource: "web",
1011 type: "user_message",
1012 messageId: turnStartId,
1013 inputIntent: "turn_start",
1014 text: "start",
1015 attachments: [],
1016 },
1017 },
1018 {
1019 position: 1,
1020 visibility: "local",
1021 sourceThreadId: threadId,
1022 sourceItemId: TurnItemId.make("turn-item:message-assistant"),
1023 item: {
1024 id: TurnItemId.make("turn-item:message-assistant"),
1025 threadId,
1026 runId,
1027 nodeId: null,
1028 providerThreadId: null,
1029 providerTurnId: null,
1030 nativeItemRef: null,
1031 parentItemId: null,
1032 ordinal: 2,
1033 status: "completed",
1034 title: null,
1035 startedAt: committedAt,
1036 completedAt: committedAt,
1037 updatedAt: committedAt,
1038 type: "assistant_message",
1039 messageId: assistantId,
1040 text: "working",
1041 streaming: false,
1042 },
1043 },
1044 {
1045 position: 2,
1046 visibility: "local",
1047 sourceThreadId: threadId,
1048 sourceItemId: TurnItemId.make("turn-item:message-steer"),
1049 item: {
1050 id: TurnItemId.make("turn-item:message-steer"),
1051 threadId,
1052 runId,
1053 nodeId: null,
1054 providerThreadId: null,
1055 providerTurnId: null,
1056 nativeItemRef: null,
1057 parentItemId: null,
1058 ordinal: 3,
1059 status: "completed",
1060 title: null,
1061 startedAt: committedAt,
1062 completedAt: committedAt,
1063 updatedAt: committedAt,
1064 createdBy: "user",
1065 creationSource: "web",
1066 type: "user_message",
1067 messageId: steerId,
1068 inputIntent: "steer",
1069 text: "continue",
1070 attachments: [],
1071 },
1072 },
1073 ];
1074
1075 expect(deriveCommittedServerUserMessageIds(visibleTurnItems)).toEqual(
1076 new Set([turnStartId, steerId]),
1077 );
1078 });
1079});
apps/web/src/components/ChatView.logic.ts+17−1
@@ -6,7 +6,7 @@ import { · 5 unmodified lines
6 isProviderDriverKind,6 isProviderDriverKind,
7 ProjectId,7 ProjectId,
8 type ModelSelection,8 type ModelSelection,
9 type ProviderInteractionMode,9 type OrchestrationV2ProjectedTurnItem,
10 type ProviderDriverKind,10 type ProviderDriverKind,
11 type ServerProvider,11 type ServerProvider,
12 type ScopedProjectRef,12 type ScopedProjectRef,
@@ -560,6 +560,22 @@ export function createLocalDispatchSnapshot( · 547 unmodified lines
560 };560 };
561}561}
562 562
563/**
564 * The timeline renders committed user rows from `visibleTurnItems`, but
565 * `message.updated` can land in `projection.messages` one event earlier than
566 * the matching `turn-item.updated`. Basing optimistic eviction on visible user
567 * turn items avoids dropping steer rows in that gap.
568 */
569export function deriveCommittedServerUserMessageIds(
570 visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>,
571): ReadonlySet<ChatMessage["id"]> {
572 return new Set(
573 visibleTurnItems.flatMap((row) =>
574 row.item.type === "user_message" ? [row.item.messageId] : [],
575 ),
576 );
577}
578
563export function hasServerAcknowledgedLocalDispatch(input: {579export function hasServerAcknowledgedLocalDispatch(input: {
564 localDispatch: LocalDispatchSnapshot | null;580 localDispatch: LocalDispatchSnapshot | null;
565 phase: SessionPhase;581 phase: SessionPhase;
apps/web/src/components/ChatView.tsx+13−276
@@ -140,7 +140,6 @@ import { · 139 unmodified lines
140 selectThreadPanelOpen,140 selectThreadPanelOpen,
141 selectThreadRightPanelState,141 selectThreadRightPanelState,
142 type RightPanelSurface,142 type RightPanelSurface,
143 updatePullRequestTabStatus,
144 useRightPanelStore,143 useRightPanelStore,
145} from "../rightPanelStore";144} from "../rightPanelStore";
146import {145import {
@@ -157,11 +156,7 @@ import { · 10 unmodified lines
157 selectThreadPreviewMiniPlayer,156 selectThreadPreviewMiniPlayer,
158 usePreviewMiniPlayerStore,157 usePreviewMiniPlayerStore,
159} from "../previewMiniPlayerStore";158} from "../previewMiniPlayerStore";
160import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic";159import { RightPanelTabs } from "./RightPanelTabs";
161import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel";
162import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts";
163import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState";
164import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs";
165import { AgentsPanel } from "./AgentsPanel";160import { AgentsPanel } from "./AgentsPanel";
166import {161import {
167 deriveAgentPanelModel,162 deriveAgentPanelModel,
@@ -183,14 +178,9 @@ import { · 15 unmodified lines
183 projectScriptIdFromCommand,178 projectScriptIdFromCommand,
184} from "~/projectScripts";179} from "~/projectScripts";
185import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";180import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
186import { useBrowserHistoryStore } from "~/browserHistoryStore";
187import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";181import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
188import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";182import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
189import {183import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings";
190 useClientSettings,
191 useClientSettingsHydrated,
192 useEnvironmentSettings,
193} from "../hooks/useSettings";
194import { useNowMinute } from "../hooks/useNowMinute";184import { useNowMinute } from "../hooks/useNowMinute";
195import { useNewThreadHandler } from "../hooks/useHandleNewThread";185import { useNewThreadHandler } from "../hooks/useHandleNewThread";
196import { resolveAppModelSelectionForInstance } from "../modelSelection";186import { resolveAppModelSelectionForInstance } from "../modelSelection";
@@ -198,7 +188,6 @@ import { getTerminalFocusOwner } from "../lib/terminalFocus"; · 1 unmodified line
198import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut";188import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
199import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";189import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
200import {190import {
201 derivePhysicalProjectKey,
202 deriveLogicalProjectKeyFromSettings,191 deriveLogicalProjectKeyFromSettings,
203 selectProjectGroupingSettings,192 selectProjectGroupingSettings,
204} from "../logicalProject";193} from "../logicalProject";
@@ -311,10 +300,9 @@ import { · 106 unmodified lines
311 buildThreadTurnInterruptInput,300 buildThreadTurnInterruptInput,
312 collectUserMessageBlobPreviewUrls,301 collectUserMessageBlobPreviewUrls,
313 createLocalDispatchSnapshot,302 createLocalDispatchSnapshot,
303 deriveCommittedServerUserMessageIds,
314 deriveComposerSendState,304 deriveComposerSendState,
315 dismissBranchMismatchForSession,305 dismissBranchMismatchForSession,
316 hasEnvironmentReconnectWarningGraceElapsed,
317 scheduleEnvironmentReconnectWarning,
318 hasServerAcknowledgedLocalDispatch,306 hasServerAcknowledgedLocalDispatch,
319 isBranchMismatchDismissedForSession,307 isBranchMismatchDismissedForSession,
320 shouldShowBranchMismatchBanner,308 shouldShowBranchMismatchBanner,
@@ -519,14 +507,6 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { · 198 unmodified lines
519 if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false;507 if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false;
520 if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false;508 if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false;
521 509
522 // The right-panel surface launcher claims its shortcut letters while it is
523 // visible (data attribute set in RightPanelTabs); those keys open surfaces
524 // instead of typing into the composer.
525 const launcherKeys = document
526 .querySelector("[data-surface-launcher-keys]")
527 ?.getAttribute("data-surface-launcher-keys");
528 if (launcherKeys && launcherKeys.toLowerCase().includes(event.key.toLowerCase())) return false;
529
530 return true;510 return true;
531}511}
532 512
@@ -1297,8 +1277,8 @@ function ChatViewContent(props: ChatViewProps) { · 764 unmodified lines
1297 routeKind === "server" ? routeThreadRef : null,1277 routeKind === "server" ? routeThreadRef : null,
1298 );1278 );
1299 const committedServerMessageIds = useMemo(1279 const committedServerMessageIds = useMemo(
1300 () => new Set(serverProjection?.messages.map((message) => message.id) ?? []),1280 () => deriveCommittedServerUserMessageIds(serverVisibleTurnItems),
1301 [serverProjection],1281 [serverVisibleTurnItems],
1302 );1282 );
1303 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);1283 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
1304 const activeThreadLastVisitedAt = useUiStateStore(1284 const activeThreadLastVisitedAt = useUiStateStore(
@@ -1636,7 +1616,11 @@ function ChatViewContent(props: ChatViewProps) { · 331 unmodified lines
1636 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;1616 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
1637 const canCheckoutPullRequestIntoThread = isLocalDraftThread;1617 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
1638 const activeThreadId = activeThread?.id ?? null;1618 const activeThreadId = activeThread?.id ?? null;
1639 const activeMessageCount = isServerThread ? committedServerMessageIds.size : 0;1619 // Prefer the larger of turn-item-committed ids and projection messages so
1620 // env lock does not unlock while turn items lag projection hydration.
1621 const activeMessageCount = isServerThread
1622 ? Math.max(committedServerMessageIds.size, serverProjection?.messages.length ?? 0)
1623 : 0;
1640 const runningTerminalIds = useThreadRunningTerminalIds({1624 const runningTerminalIds = useThreadRunningTerminalIds({
1641 environmentId: activeThread?.environmentId ?? null,1625 environmentId: activeThread?.environmentId ?? null,
1642 threadId: activeThreadId,1626 threadId: activeThreadId,
@@ -1672,11 +1656,8 @@ function ChatViewContent(props: ChatViewProps) { · 29 unmodified lines
1672 return labels;1656 return labels;
1673 }, [activeThreadKnownSessions]);1657 }, [activeThreadKnownSessions]);
1674 const activeThreadRef = useMemo(1658 const activeThreadRef = useMemo(
1675 () =>1659 () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
1676 activeThreadEnvironmentId && activeThreadId1660 [activeThread],
1677 ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId)
1678 : null,
1679 [activeThreadEnvironmentId, activeThreadId],
1680 );1661 );
1681 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;1662 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
1682 const [timelineAnchor, setTimelineAnchor] = useState<{1663 const [timelineAnchor, setTimelineAnchor] = useState<{
@@ -1697,23 +1678,6 @@ function ChatViewContent(props: ChatViewProps) { · 14 unmodified lines
1697 const activeRightPanelSurface = useRightPanelStore((state) =>1678 const activeRightPanelSurface = useRightPanelStore((state) =>
1698 selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef),1679 selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef),
1699 );1680 );
1700 const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState<
1701 Record<string, PullRequestTabStatus>
1702 >({});
1703 // Keyed by the surface the panel is showing rather than by a key rebuilt from the status, so
1704 // the tab is found again whether or not that surface was opened with an environment on it.
1705 const activePullRequestSurfaceId =
1706 activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined;
1707 const updatePullRequestTabStatusFromPanel = useCallback(
1708 (status: PullRequestTabStatus) => {
1709 const id = activePullRequestSurfaceId;
1710 if (id === undefined) return;
1711 setPullRequestTabStatuses((current) => updatePullRequestTabStatus(current, id, status));
1712 },
1713 [activePullRequestSurfaceId],
1714 );
1715 const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });
1716 const sidebarPrRefreshKeyRef = useRef<string | null>(null);
1717 const activeFileSurface =1681 const activeFileSurface =
1718 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;1682 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;
1719 const activePreviewState = useThreadPreviewState(activeThreadRef);1683 const activePreviewState = useThreadPreviewState(activeThreadRef);
@@ -1847,7 +1811,6 @@ function ChatViewContent(props: ChatViewProps) { · 127 unmodified lines
1847 const activeProjectKey = activeProject1811 const activeProjectKey = activeProject
1848 ? `${activeProject.environmentId}:${activeProject.workspaceRoot}`1812 ? `${activeProject.environmentId}:${activeProject.workspaceRoot}`
1849 : null;1813 : null;
1850 const clientSettingsHydrated = useClientSettingsHydrated();
1851 const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState<1814 const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState<
1852 ReadonlyMap<string, ReadonlySet<string>>1815 ReadonlyMap<string, ReadonlySet<string>>
1853 >(() => new Map());1816 >(() => new Map());
@@ -1886,54 +1849,11 @@ function ChatViewContent(props: ChatViewProps) { · 32 unmodified lines
1886 // drive the environment picker in BranchToolbar.1849 // drive the environment picker in BranchToolbar.
1887 const allProjects = useProjects();1850 const allProjects = useProjects();
1888 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;1851 const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
1889 useEffect(() => {
1890 if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return;
1891 // Reuse the sidebar's grouping so history follows the project rows the user
1892 // sees. Deriving the key from the active project alone would miss the
1893 // identity a duplicate row borrows from its siblings.
1894 const logicalKeyByPhysicalKey = buildPhysicalToLogicalProjectKeyMap({
1895 projects: allProjects,
1896 settings: projectGroupingSettings,
1897 primaryEnvironmentId,
1898 });
1899 useBrowserHistoryStore
1900 .getState()
1901 .registerThreadProject(
1902 activeThreadRef,
1903 logicalKeyByPhysicalKey.get(derivePhysicalProjectKey(activeProject)) ??
1904 deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings),
1905 );
1906 }, [
1907 activeProject,
1908 activeThreadRef,
1909 allProjects,
1910 clientSettingsHydrated,
1911 primaryEnvironmentId,
1912 projectGroupingSettings,
1913 ]);
1914 const activeEnvironment =1852 const activeEnvironment =
1915 activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null);1853 activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null);
1916 const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available";1854 const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available";
1917 const activeEnvironmentUnavailable =1855 const activeEnvironmentUnavailable =
1918 activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected";1856 activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected";
1919 const activeReconnectingEnvironmentId =
1920 activeEnvironmentConnectionPhase === "connecting" ||
1921 activeEnvironmentConnectionPhase === "reconnecting"
1922 ? (activeEnvironment?.environmentId ?? null)
1923 : null;
1924 const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] =
1925 useState<EnvironmentId | null>(null);
1926 const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed(
1927 activeReconnectingEnvironmentId,
1928 reconnectWarningGraceElapsedEnvironmentId,
1929 );
1930 useEffect(() => {
1931 setReconnectWarningGraceElapsedEnvironmentId(null);
1932 if (activeReconnectingEnvironmentId === null) return;
1933 return scheduleEnvironmentReconnectWarning(() =>
1934 setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId),
1935 );
1936 }, [activeReconnectingEnvironmentId]);
1937 const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null;1857 const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null;
1938 const activeEnvironmentUnavailableState = useMemo<EnvironmentUnavailableState | null>(() => {1858 const activeEnvironmentUnavailableState = useMemo<EnvironmentUnavailableState | null>(() => {
1939 if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) {1859 if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) {
@@ -1962,6 +1882,7 @@ function ChatViewContent(props: ChatViewProps) { · 22 unmodified lines
1962 },1882 },
1963 [retryEnvironment],1883 [retryEnvironment],
1964 );1884 );
1885 const projectGroupingSettings = selectProjectGroupingSettings(settings);
1965 const logicalProjectEnvironments = useMemo(() => {1886 const logicalProjectEnvironments = useMemo(() => {
1966 if (!activeProject) return [];1887 if (!activeProject) return [];
1967 const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings);1888 const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings);
@@ -2115,8 +2036,6 @@ function ChatViewContent(props: ChatViewProps) { · 147 unmodified lines
2115 const serverConfig = activeThread2036 const serverConfig = activeThread
2116 ? (activeEnvironment?.serverConfig ?? null)2037 ? (activeEnvironment?.serverConfig ?? null)
2117 : (primaryEnvironment?.serverConfig ?? null);2038 : (primaryEnvironment?.serverConfig ?? null);
2118 const pullRequestsCapabilityKnown = serverConfig !== null;
2119 const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true;
2120 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);2039 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);
2121 const versionMismatchDismissKey =2040 const versionMismatchDismissKey =
2122 versionMismatch && activeThread2041 versionMismatch && activeThread
@@ -3266,27 +3185,6 @@ function ChatViewContent(props: ChatViewProps) { · 1143 unmodified lines
3266 },3185 },
3267 [activeProject, activeThreadRef],3186 [activeProject, activeThreadRef],
3268 );3187 );
3269 // The thread's own change request, placed against the project it belongs to. Without a
3270 // project there is nothing to resolve it against, so the caller falls back to the browser.
3271 const threadRepository = activeProject?.repositoryIdentity?.displayName ?? null;
3272 const openThreadPullRequest = useCallback(
3273 (number: number) => {
3274 if (
3275 !supportsPullRequests ||
3276 !activeThreadRef ||
3277 !activeProject ||
3278 threadRepository === null
3279 ) {
3280 return;
3281 }
3282 useRightPanelStore.getState().openPullRequest(activeThreadRef, {
3283 projectId: activeProject.id,
3284 repository: threadRepository,
3285 number,
3286 });
3287 },
3288 [activeProject, activeThreadRef, supportsPullRequests, threadRepository],
3289 );
3290 const togglePreviewPanel = useCallback(() => {3188 const togglePreviewPanel = useCallback(() => {
3291 if (!activeThreadRef || !isPreviewSupportedInRuntime()) return;3189 if (!activeThreadRef || !isPreviewSupportedInRuntime()) return;
3292 if (previewPanelOpen) {3190 if (previewPanelOpen) {
@@ -4136,113 +4034,6 @@ function ChatViewContent(props: ChatViewProps) { · 843 unmodified lines
4136 threadBranch: activeThread?.branch ?? null,4034 threadBranch: activeThread?.branch ?? null,
4137 gitStatus: gitStatusQuery.data ?? null,4035 gitStatus: gitStatusQuery.data ?? null,
4138 });4036 });
4139 const handlePullRequestTabStatusChange = useCallback(
4140 (status: PullRequestTabStatus) => {
4141 updatePullRequestTabStatusFromPanel(status);
4142 const source = threadPullRequestRefreshSource({
4143 panel: status,
4144 thread: {
4145 repository: threadRepository,
4146 number: linkedThreadPullRequest?.number ?? activeThreadPr?.number ?? null,
4147 state: activeThreadPr?.state ?? null,
4148 linked: linkedThreadPullRequest !== null,
4149 },
4150 });
4151 if (source === null) {
4152 sidebarPrRefreshKeyRef.current = null;
4153 return;
4154 }
4155 const refreshKey = `${activeThreadKey}:${source}:${status.repository}#${status.number}:${status.state}`;
4156 if (sidebarPrRefreshKeyRef.current === refreshKey) return;
4157 sidebarPrRefreshKeyRef.current = refreshKey;
4158
4159 if (source === "linked-detail" && activeThreadRef && linkedThreadPullRequest) {
4160 appAtomRegistry.refresh(
4161 linkedPullRequestDetailAtom({
4162 environmentId: activeThreadRef.environmentId,
4163 input: {
4164 projectId: linkedThreadPullRequest.projectId,
4165 repository: linkedThreadPullRequest.repository,
4166 number: linkedThreadPullRequest.number,
4167 },
4168 }),
4169 );
4170 return;
4171 }
4172 if (source === "vcs" && activeThreadRef && gitCwd !== null) {
4173 void refreshVcsStatus({
4174 environmentId: activeThreadRef.environmentId,
4175 input: { cwd: gitCwd },
4176 }).then(() => {
4177 if (sidebarPrRefreshKeyRef.current === refreshKey) {
4178 sidebarPrRefreshKeyRef.current = null;
4179 }
4180 });
4181 }
4182 },
4183 [
4184 activeThreadKey,
4185 activeThreadPr?.number,
4186 activeThreadPr?.state,
4187 activeThreadRef,
4188 gitCwd,
4189 linkedThreadPullRequest,
4190 refreshVcsStatus,
4191 threadRepository,
4192 updatePullRequestTabStatusFromPanel,
4193 ],
4194 );
4195 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef);
4196 const activeThreadReferenceCopyTarget = useMemo(
4197 () =>
4198 activeThreadId === null || !isServerThread
4199 ? null
4200 : resolveThreadReferenceCopyTarget({
4201 threadId: activeThreadId,
4202 openPanelPullRequestUrl,
4203 linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null,
4204 detectedPullRequestUrl: activeThreadPr?.url ?? null,
4205 }),
4206 [
4207 activeThreadId,
4208 activeThreadPr?.url,
4209 isServerThread,
4210 linkedThreadPullRequest?.url,
4211 openPanelPullRequestUrl,
4212 ],
4213 );
4214 const copyActiveThreadReference = useCallback(() => {
4215 const target = activeThreadReferenceCopyTarget;
4216 if (target === null) return;
4217 void writeTextToClipboard(target.value, target.clipboardTarget).then(
4218 (didCopy) => {
4219 if (!didCopy) return;
4220 toastManager.add({
4221 type: "success",
4222 title: target.successTitle,
4223 description: target.value,
4224 });
4225 },
4226 (error) => {
4227 console.error(error);
4228 toastManager.add(
4229 stackedThreadToast({
4230 type: "error",
4231 title: target.failureTitle,
4232 description: error instanceof Error ? error.message : "An error occurred.",
4233 }),
4234 );
4235 },
4236 );
4237 }, [activeThreadReferenceCopyTarget]);
4238 // The right panel offers the thread's own change request, so it can only offer it once the
4239 // branch has one; until then the picker says so rather than opening an empty panel.
4240 const addPullRequestSurface = useCallback(() => {
4241 if (activeThreadPr === null) return;
4242 openThreadPullRequest(activeThreadPr.number);
4243 }, [activeThreadPr, openThreadPullRequest]);
4244 const pullRequestSurfaceAvailable =
4245 supportsPullRequests && activeThreadPr !== null && threadRepository !== null;
4246 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;4037 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
4247 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;4038 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
4248 const nowMinute = useNowMinute();4039 const nowMinute = useNowMinute();
@@ -4965,7 +4756,6 @@ function ChatViewContent(props: ChatViewProps) { · 716 unmodified lines
4965 "This will discard newer messages and turn diffs in this thread.",4756 "This will discard newer messages and turn diffs in this thread.",
4966 "This action cannot be undone.",4757 "This action cannot be undone.",
4967 ].join("\n"),4758 ].join("\n"),
4968 { variant: "destructive" },
4969 );4759 );
4970 if (!confirmed) {4760 if (!confirmed) {
4971 return;4761 return;
@@ -6313,47 +6103,6 @@ function ChatViewContent(props: ChatViewProps) { · 1341 unmodified lines
6313 workspaceMutationId={workspaceMutationId}6103 workspaceMutationId={workspaceMutationId}
6314 />6104 />
6315 </Suspense>6105 </Suspense>
6316 ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? (
6317 <PullRequestDetailGhost />
6318 ) : activeRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? (
6319 <PullRequestsUnavailableState
6320 title="Pull requests unavailable"
6321 error="Update this environment's T3 Code server to browse pull requests."
6322 />
6323 ) : activeRightPanelSurface?.kind === "pull-request" ? (
6324 // No onClose: the surface tab's own X owns closing here, and a second X in the header
6325 // would be the same action twice. The thread context also drops the checkout button, so it
6326 // is only right for the thread's own pull request, whose branch is already under the
6327 // reader's feet. A link the agent wrote can open any other one here, and that one has to be
6328 // checkable out like it is anywhere else.
6329 <PullRequestDetailPanel
6330 key={`${activeRightPanelSurface.repository}#${activeRightPanelSurface.number}`}
6331 environmentId={activeThread.environmentId}
6332 reference={{
6333 projectId: activeRightPanelSurface.projectId as ProjectId,
6334 repository: activeRightPanelSurface.repository,
6335 number: activeRightPanelSurface.number,
6336 }}
6337 context={
6338 isThreadOwnPullRequest(
6339 {
6340 projectId: activeProject?.id ?? null,
6341 repository: threadRepository,
6342 number: activeThreadPr?.number ?? null,
6343 },
6344 {
6345 projectId: activeRightPanelSurface.projectId,
6346 repository: activeRightPanelSurface.repository,
6347 number: activeRightPanelSurface.number,
6348 },
6349 )
6350 ? "thread"
6351 : "page"
6352 }
6353 chromeVariant="collapse"
6354 composerDraftTarget={composerDraftTarget}
6355 onStateChange={handlePullRequestTabStatusChange}
6356 />
6357 ) : activeRightPanelSurface?.kind === "agents" ? (6106 ) : activeRightPanelSurface?.kind === "agents" ? (
6358 <AgentsPanel6107 <AgentsPanel
6359 model={agentPanelModel}6108 model={agentPanelModel}
@@ -6838,16 +6587,10 @@ function ChatViewContent(props: ChatViewProps) { · 478 unmodified lines
6838 onAddTerminal={addTerminalSurface}6587 onAddTerminal={addTerminalSurface}
6839 onAddDiff={addDiffSurface}6588 onAddDiff={addDiffSurface}
6840 onAddFiles={addFilesSurface}6589 onAddFiles={addFilesSurface}
6841 onAddPullRequest={addPullRequestSurface}
6842 onAddAgents={addAgentsSurface}6590 onAddAgents={addAgentsSurface}
6843 browserAvailable={isPreviewSupportedInRuntime()}6591 browserAvailable={isPreviewSupportedInRuntime()}
6844 terminalAvailable={activeProject !== null}
6845 diffAvailable={isServerThread && isGitRepo}6592 diffAvailable={isServerThread && isGitRepo}
6846 filesAvailable={activeProject !== null}6593 filesAvailable={activeProject !== null}
6847 pullRequestAvailable={pullRequestSurfaceAvailable}
6848 agentsAvailable
6849 pullRequestStatuses={pullRequestTabStatuses}
6850 liveAgentCount={agentPanelModel.liveCount}
6851 >6594 >
6852 {rightPanelContent}6595 {rightPanelContent}
6853 </RightPanelTabs>6596 </RightPanelTabs>
@@ -6873,16 +6616,10 @@ function ChatViewContent(props: ChatViewProps) { · 19 unmodified lines
6873 onAddTerminal={addTerminalSurface}6616 onAddTerminal={addTerminalSurface}
6874 onAddDiff={addDiffSurface}6617 onAddDiff={addDiffSurface}
6875 onAddFiles={addFilesSurface}6618 onAddFiles={addFilesSurface}
6876 onAddPullRequest={addPullRequestSurface}
6877 onAddAgents={addAgentsSurface}6619 onAddAgents={addAgentsSurface}
6878 browserAvailable={isPreviewSupportedInRuntime()}6620 browserAvailable={isPreviewSupportedInRuntime()}
6879 terminalAvailable={activeProject !== null}
6880 diffAvailable={isServerThread && isGitRepo}6621 diffAvailable={isServerThread && isGitRepo}
6881 filesAvailable={activeProject !== null}6622 filesAvailable={activeProject !== null}
6882 pullRequestAvailable={pullRequestSurfaceAvailable}
6883 agentsAvailable
6884 pullRequestStatuses={pullRequestTabStatuses}
6885 liveAgentCount={agentPanelModel.liveCount}
6886 >6623 >
6887 {rightPanelContent}6624 {rightPanelContent}
6888 </RightPanelTabs>6625 </RightPanelTabs>
apps/web/src/components/chat/MessagesTimeline.tsx+20−14
@@ -1369,10 +1369,6 @@ function ProposedPlanTimelineRow({ · 1368 unmodified lines
1369 1369
1370type V2EventTone = "muted" | "warning" | "danger" | "success";1370type V2EventTone = "muted" | "warning" | "danger" | "success";
1371 1371
1372function subagentDisplayTitle(title: string): string {
1373 return title.replace(/^Subagent:\s*/i, "");
1374}
1375
1376function v2EventPresentation(item: OrchestrationV2TurnItem): {1372function v2EventPresentation(item: OrchestrationV2TurnItem): {
1377 readonly label: string;1373 readonly label: string;
1378 readonly detail: string | null;1374 readonly detail: string | null;
@@ -1429,14 +1425,6 @@ function v2EventPresentation(item: OrchestrationV2TurnItem): { · 50 unmodified lines
1429 icon: MinusIcon,1425 icon: MinusIcon,
1430 };1426 };
1431 }1427 }
1432 case "subagent":
1433 return {
1434 label: subagentDisplayTitle(item.title ?? "Subagent"),
1435 detail: item.result ?? item.progress ?? item.prompt,
1436 tone:
1437 item.status === "failed" ? "danger" : item.status === "completed" ? "success" : "muted",
1438 icon: BotIcon,
1439 };
1440 case "approval_request":1428 case "approval_request":
1441 return {1429 return {
1442 label: "Approval requested",1430 label: "Approval requested",
@@ -1511,7 +1499,14 @@ function V2EventTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "event" · 68 unmodified lines
1511 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">1499 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
1512 <span className="text-xs font-medium text-foreground/90">{presentation.label}</span>1500 <span className="text-xs font-medium text-foreground/90">{presentation.label}</span>
1513 {item.status !== "completed" ? (1501 {item.status !== "completed" ? (
1514 <span className="rounded-full border border-border/70 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">1502 <span
1503 className={cn(
1504 "rounded-full border px-1.5 py-0.5 font-mono text-[10px]",
1505 item.status === "failed"
1506 ? "border-destructive/40 text-destructive"
1507 : "border-border/70 text-muted-foreground",
1508 )}
1509 >
1515 {item.status}1510 {item.status}
1516 </span>1511 </span>
1517 ) : null}1512 ) : null}
@@ -2303,8 +2298,19 @@ function workEntryPreview( · 785 unmodified lines
2303 workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">,2298 workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">,
2304 workspaceRoot: string | undefined,2299 workspaceRoot: string | undefined,
2305) {2300) {
2301 // Prefer stdout/detail so completed shell/monitor results are visible collapsed
2302 // (command alone hid ls listings behind expand-only inspector JSON).
2303 if (workEntry.detail?.trim()) {
2304 const lines = workEntry.detail
2305 .trim()
2306 .split(/\r?\n/)
2307 .map((line) => line.trim())
2308 .filter((line) => line.length > 0);
2309 if (lines.length > 0) {
2310 return lines.slice(0, 3).join(" · ");
2311 }
2312 }
2306 if (workEntry.command) return workEntry.command;2313 if (workEntry.command) return workEntry.command;
2307 if (workEntry.detail) return workEntry.detail;
2308 if ((workEntry.changedFiles?.length ?? 0) === 0) return null;2314 if ((workEntry.changedFiles?.length ?? 0) === 0) return null;
2309 const [firstPath] = workEntry.changedFiles ?? [];2315 const [firstPath] = workEntry.changedFiles ?? [];
2310 if (!firstPath) return null;2316 if (!firstPath) return null;
apps/web/src/keybindings.test.ts+7−0
@@ -594,6 +594,13 @@ describe("chat/editor shortcuts", () => { · 593 unmodified lines
594 platform: "Linux",594 platform: "Linux",
595 }),595 }),
596 );596 );
597 assert.isFalse(
598 isOpenFavoriteEditorShortcut(
599 event({ key: "o", metaKey: true, repeat: true }),
600 DEFAULT_BINDINGS,
601 { platform: "MacIntel" },
602 ),
603 );
597 });604 });
598 605
599 it("matches commandPalette.toggle shortcut outside terminal focus", () => {606 it("matches commandPalette.toggle shortcut outside terminal focus", () => {
apps/web/src/keybindings.ts+5−1
@@ -14,6 +14,7 @@ export interface ShortcutEventLike { · 13 unmodified lines
14 type?: string;14 type?: string;
15 code?: string;15 code?: string;
16 key: string;16 key: string;
17 repeat?: boolean;
17 metaKey: boolean;18 metaKey: boolean;
18 ctrlKey: boolean;19 ctrlKey: boolean;
19 shiftKey: boolean;20 shiftKey: boolean;
@@ -440,7 +441,10 @@ export function isOpenFavoriteEditorShortcut( · 420 unmodified lines
440 keybindings: ResolvedKeybindingsConfig,441 keybindings: ResolvedKeybindingsConfig,
441 options?: ShortcutMatchOptions,442 options?: ShortcutMatchOptions,
442): boolean {443): boolean {
443 return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options);444 return (
445 event.repeat !== true &&
446 matchesCommandShortcut(event, keybindings, "editor.openFavorite", options)
447 );
444}448}
445 449
446export function isTerminalClearShortcut(450export function isTerminalClearShortcut(
packages/client-runtime/src/state/orchestrationV2Projection.ts+1−0
@@ -39,6 +39,7 @@ function shouldShowLocalTurnItem( · 38 unmodified lines
39 item,39 item,
40 runs: projection.runs,40 runs: projection.runs,
41 attempts: projection.attempts,41 attempts: projection.attempts,
42 items: projection.turnItems,
42 });43 });
43}44}
44 45
packages/effect-acp/src/client.ts+14−22
@@ -1,5 +1,6 @@
1import * as Context from "effect/Context";1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Stdio from "effect/Stdio";
3import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
4import * as Schema from "effect/Schema";5import * as Schema from "effect/Schema";
5import * as Scope from "effect/Scope";6import * as Scope from "effect/Scope";
@@ -26,16 +27,10 @@ export interface AcpClientOptions { · 20 unmodified lines
26 readonly logIncoming?: boolean;27 readonly logIncoming?: boolean;
27 readonly logOutgoing?: boolean;28 readonly logOutgoing?: boolean;
28 readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>;29 readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>;
29 /** Transforms child output before protocol logging and parsing. */30 readonly onIncomingRequest?: AcpProtocol.AcpPatchedProtocolOptions["onIncomingRequest"];
30 readonly transformStdout?: (31 readonly onTermination?: AcpProtocol.AcpPatchedProtocolOptions["onTermination"];
31 stdout: ChildProcessSpawner.ChildProcessHandle["stdout"],32 readonly onOutgoingResponseFailure?: AcpProtocol.AcpPatchedProtocolOptions["onOutgoingResponseFailure"];
32 ) => AcpProtocol.AcpStdio["stdin"];33 readonly onOutgoingResponse?: AcpProtocol.AcpPatchedProtocolOptions["onOutgoingResponse"];
33 /** Transforms decoded session updates before buffering or delivery. */
34 readonly transformSessionUpdate?: (
35 notification: AcpSchema.SessionNotification,
36 ) => AcpSchema.SessionNotification;
37 /** Reports input failures and process exits, even between requests. */
38 readonly onTermination?: (error: AcpError.AcpError) => Effect.Effect<void, never, never>;
39}34}
40 35
41type AcpClientRaw = {36type AcpClientRaw = {
@@ -316,7 +311,7 @@ interface BufferedNotificationHandler<A> { · 274 unmodified lines
316}311}
317 312
318export const make = Effect.fn("effect-acp/AcpClient.make")(function* (313export const make = Effect.fn("effect-acp/AcpClient.make")(function* (
319 stdio: AcpProtocol.AcpStdio,314 stdio: Stdio.Stdio,
320 options: AcpClientOptions = {},315 options: AcpClientOptions = {},
321 terminationError?: Effect.Effect<AcpError.AcpError>,316 terminationError?: Effect.Effect<AcpError.AcpError>,
322): Effect.fn.Return<AcpClient["Service"], never, Scope.Scope> {317): Effect.fn.Return<AcpClient["Service"], never, Scope.Scope> {
@@ -413,10 +408,12 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( · 90 unmodified lines
413 ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}),408 ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}),
414 ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}),409 ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}),
415 ...(options.logger ? { logger: options.logger } : {}),410 ...(options.logger ? { logger: options.logger } : {}),
416 ...(options.transformSessionUpdate411 ...(options.onIncomingRequest ? { onIncomingRequest: options.onIncomingRequest } : {}),
417 ? { transformSessionUpdate: options.transformSessionUpdate }
418 : {}),
419 ...(options.onTermination ? { onTermination: options.onTermination } : {}),412 ...(options.onTermination ? { onTermination: options.onTermination } : {}),
413 ...(options.onOutgoingResponseFailure
414 ? { onOutgoingResponseFailure: options.onOutgoingResponseFailure }
415 : {}),
416 ...(options.onOutgoingResponse ? { onOutgoingResponse: options.onOutgoingResponse } : {}),
420 onNotification: dispatchNotification,417 onNotification: dispatchNotification,
421 onExtRequest: dispatchExtRequest,418 onExtRequest: dispatchExtRequest,
422 });419 });
@@ -585,19 +582,14 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( · 162 unmodified lines
585 });582 });
586});583});
587 584
588export const layer = (585export const layer = (stdio: Stdio.Stdio, options: AcpClientOptions = {}): Layer.Layer<AcpClient> =>
589 stdio: AcpProtocol.AcpStdio,586 Layer.effect(AcpClient, make(stdio, options));
590 options: AcpClientOptions = {},
591): Layer.Layer<AcpClient> => Layer.effect(AcpClient, make(stdio, options));
592 587
593export const layerChildProcess = (588export const layerChildProcess = (
594 handle: ChildProcessSpawner.ChildProcessHandle,589 handle: ChildProcessSpawner.ChildProcessHandle,
595 options: AcpClientOptions = {},590 options: AcpClientOptions = {},
596): Layer.Layer<AcpClient> => {591): Layer.Layer<AcpClient> => {
597 const stdio = {592 const stdio = makeChildStdio(handle);
598 ...makeChildStdio(handle),
599 stdin: options.transformStdout?.(handle.stdout) ?? handle.stdout,
600 };
601 const terminationError = makeTerminationError(handle);593 const terminationError = makeTerminationError(handle);
602 return Layer.effect(AcpClient, make(stdio, options, terminationError));594 return Layer.effect(AcpClient, make(stdio, options, terminationError));
603};595};
packages/effect-acp/src/protocol.test.ts+200−0
@@ -3,8 +3,12 @@ import * as AcpError from "./errors.ts"; · 2 unmodified lines
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Deferred from "effect/Deferred";4import * as Deferred from "effect/Deferred";
5import * as Fiber from "effect/Fiber";5import * as Fiber from "effect/Fiber";
6import * as Exit from "effect/Exit";
6import * as Queue from "effect/Queue";7import * as Queue from "effect/Queue";
7import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import * as Scope from "effect/Scope";
10import * as Sink from "effect/Sink";
11import * as Stdio from "effect/Stdio";
8import * as Stream from "effect/Stream";12import * as Stream from "effect/Stream";
9import * as Ref from "effect/Ref";13import * as Ref from "effect/Ref";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";14import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
@@ -533,6 +537,177 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { · 522 unmodified lines
533 }),537 }),
534 );538 );
535 539
540 it.effect("acknowledges outgoing responses and reports encoding failures by request id", () =>
541 Effect.gen(function* () {
542 const { stdio } = yield* makeInMemoryStdio();
543 const acknowledged = yield* Deferred.make<string>();
544 const failed = yield* Deferred.make<readonly [string, AcpError.AcpError]>();
545 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
546 stdio,
547 serverRequestMethods: new Set(),
548 onOutgoingResponse: (requestId) =>
549 Deferred.succeed(acknowledged, requestId).pipe(Effect.asVoid),
550 onOutgoingResponseFailure: (requestId, error) =>
551 Deferred.succeed(failed, [requestId, error]).pipe(Effect.asVoid),
552 });
553
554 yield* transport.serverProtocol.send(0, {
555 _tag: "Exit",
556 requestId: "success-1",
557 exit: { _tag: "Success", value: { ok: true } },
558 });
559 assert.equal(yield* Deferred.await(acknowledged), "success-1");
560
561 const circular: { self?: unknown } = {};
562 circular.self = circular;
563 yield* transport.serverProtocol
564 .send(0, {
565 _tag: "Exit",
566 requestId: "failure-1",
567 exit: { _tag: "Success", value: circular },
568 })
569 .pipe(Effect.exit);
570 const [requestId, error] = yield* Deferred.await(failed);
571 assert.equal(requestId, "failure-1");
572 assert.instanceOf(error, AcpError.AcpProtocolParseError);
573 }),
574 );
575
576 it.effect("acknowledges a response only after the stdout write completes", () =>
577 Effect.gen(function* () {
578 const writeStarted = yield* Deferred.make<void>();
579 const releaseWrite = yield* Deferred.make<void>();
580 const acknowledged = yield* Deferred.make<string>();
581 const stdio = Stdio.make({
582 args: Effect.succeed([]),
583 stdin: Stream.never,
584 stdout: () =>
585 Sink.forEach(() =>
586 Deferred.succeed(writeStarted, undefined).pipe(
587 Effect.andThen(Deferred.await(releaseWrite)),
588 ),
589 ),
590 stderr: () => Sink.drain,
591 });
592 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
593 stdio,
594 serverRequestMethods: new Set(),
595 onOutgoingResponse: (requestId) =>
596 Deferred.succeed(acknowledged, requestId).pipe(Effect.asVoid),
597 });
598 const sendFiber = yield* transport.serverProtocol
599 .send(0, {
600 _tag: "Exit",
601 requestId: "held-write",
602 exit: { _tag: "Success", value: { ok: true } },
603 })
604 .pipe(Effect.forkScoped);
605
606 yield* Deferred.await(writeStarted);
607 assert.isUndefined(sendFiber.pollUnsafe());
608 assert.isFalse(yield* Deferred.isDone(acknowledged));
609 yield* Deferred.succeed(releaseWrite, undefined);
610 yield* Fiber.join(sendFiber);
611 assert.equal(yield* Deferred.await(acknowledged), "held-write");
612 }),
613 );
614
615 it.effect("fails current, queued, and future responses when the stdout writer fails", () =>
616 Effect.gen(function* () {
617 const writeStarted = yield* Deferred.make<void>();
618 const releaseFailure = yield* Deferred.make<void>();
619 const failures = yield* Queue.unbounded<string>();
620 const terminated = yield* Deferred.make<AcpError.AcpError>();
621 const stdio = Stdio.make({
622 args: Effect.succeed([]),
623 stdin: Stream.never,
624 stdout: () =>
625 Sink.forEach(() =>
626 Deferred.succeed(writeStarted, undefined).pipe(
627 Effect.andThen(Deferred.await(releaseFailure)),
628 Effect.andThen(Effect.die("stdout failed")),
629 ),
630 ),
631 stderr: () => Sink.drain,
632 });
633 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
634 stdio,
635 serverRequestMethods: new Set(),
636 onOutgoingResponseFailure: (requestId) => Queue.offer(failures, requestId),
637 onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid),
638 });
639 const send = (requestId: string) =>
640 transport.serverProtocol
641 .send(0, {
642 _tag: "Exit",
643 requestId,
644 exit: { _tag: "Success", value: { ok: true } },
645 })
646 .pipe(Effect.exit);
647 const current = yield* send("current").pipe(Effect.forkScoped);
648 yield* Deferred.await(writeStarted);
649 const queued = yield* send("queued").pipe(Effect.forkScoped);
650 yield* Effect.yieldNow;
651 assert.isUndefined(current.pollUnsafe());
652 assert.isUndefined(queued.pollUnsafe());
653 yield* Deferred.succeed(releaseFailure, undefined);
654 assert.instanceOf(yield* Deferred.await(terminated), AcpError.AcpTransportError);
655 yield* Fiber.join(current);
656 yield* Fiber.join(queued);
657 yield* send("future");
658
659 assert.deepEqual(
660 new Set(yield* Queue.takeN(failures, 3)),
661 new Set(["current", "queued", "future"]),
662 );
663 }),
664 );
665
666 it.effect(
667 "atomically rejects an admitted response when the writer closes before queue offer",
668 () =>
669 Effect.gen(function* () {
670 const admitted = yield* Deferred.make<void>();
671 const releaseAdmission = yield* Deferred.make<void>();
672 const failures = yield* Queue.unbounded<string>();
673 const protocolScope = yield* Scope.make();
674 const stdio = Stdio.make({
675 args: Effect.succeed([]),
676 stdin: Stream.never,
677 stdout: () => Sink.drain,
678 stderr: () => Sink.drain,
679 });
680 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
681 stdio,
682 serverRequestMethods: new Set(),
683 onOutgoingResponseFailure: (requestId) => Queue.offer(failures, requestId),
684 testHooks: {
685 onOutgoingWriteAdmitted: () =>
686 Deferred.succeed(admitted, undefined).pipe(
687 Effect.andThen(Deferred.await(releaseAdmission)),
688 ),
689 },
690 }).pipe(Effect.provideService(Scope.Scope, protocolScope));
691 const send = (requestId: string) =>
692 transport.serverProtocol
693 .send(0, {
694 _tag: "Exit",
695 requestId,
696 exit: { _tag: "Success", value: { ok: true } },
697 })
698 .pipe(Effect.exit);
699 const admittedSend = yield* send("admitted").pipe(Effect.forkScoped);
700
701 yield* Deferred.await(admitted);
702 yield* Scope.close(protocolScope, Exit.void);
703 yield* Deferred.succeed(releaseAdmission, undefined);
704 yield* Fiber.join(admittedSend);
705 yield* send("future");
706
707 assert.deepEqual(yield* Queue.takeN(failures, 2), ["admitted", "future"]);
708 }),
709 );
710
536 it.effect("cleans up interrupted extension requests before a late response arrives", () =>711 it.effect("cleans up interrupted extension requests before a late response arrives", () =>
537 Effect.gen(function* () {712 Effect.gen(function* () {
538 const { stdio, input, output } = yield* makeInMemoryStdio();713 const { stdio, input, output } = yield* makeInMemoryStdio();
@@ -638,6 +813,31 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { · 99 unmodified lines
638 }),813 }),
639 );814 );
640 815
816 it.effect("does not treat intentional serverProtocol.end as a transport failure", () =>
817 Effect.gen(function* () {
818 const { stdio, input } = yield* makeInMemoryStdio();
819 const terminations = yield* Ref.make(0);
820 const writerExit = yield* Deferred.make<{ readonly intentionalEnd: boolean }>();
821 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
822 stdio,
823 serverRequestMethods: new Set(),
824 onTermination: () => Ref.update(terminations, (count) => count + 1),
825 testHooks: {
826 onOutgoingWriterExit: (detail) =>
827 Deferred.succeed(writerExit, detail).pipe(Effect.asVoid),
828 },
829 });
830
831 yield* transport.serverProtocol.end(0);
832 const exitDetail = yield* Deferred.await(writerExit);
833 assert.isTrue(exitDetail.intentionalEnd);
834 assert.equal(yield* Ref.get(terminations), 0);
835
836 // Tear down the forever input reader so the scoped test can finish.
837 yield* Queue.end(input);
838 }),
839 );
840
641 it.effect("does not emit a second process-exit error after a decode failure", () =>841 it.effect("does not emit a second process-exit error after a decode failure", () =>
642 Effect.gen(function* () {842 Effect.gen(function* () {
643 const handle = yield* makeHandle({843 const handle = yield* makeHandle({
packages/effect-acp/src/protocol.ts+224−31
@@ -4,7 +4,6 @@ import * as Deferred from "effect/Deferred"; · 3 unmodified lines
4import * as Exit from "effect/Exit";4import * as Exit from "effect/Exit";
5import * as Queue from "effect/Queue";5import * as Queue from "effect/Queue";
6import * as Ref from "effect/Ref";6import * as Ref from "effect/Ref";
7import type * as PlatformError from "effect/PlatformError";
8import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
9import * as Scope from "effect/Scope";8import * as Scope from "effect/Scope";
10import * as Stream from "effect/Stream";9import * as Stream from "effect/Stream";
@@ -43,21 +42,18 @@ export type AcpIncomingNotification = · 32 unmodified lines
43 readonly params: unknown;42 readonly params: unknown;
44 };43 };
45 44
46/** Standard I/O whose input can report provider-specific ACP failures. */
47export interface AcpStdio extends Omit<Stdio.Stdio, "stdin"> {
48 readonly stdin: Stream.Stream<Uint8Array, PlatformError.PlatformError | AcpError.AcpError>;
49}
50
51export interface AcpPatchedProtocolOptions {45export interface AcpPatchedProtocolOptions {
52 readonly stdio: AcpStdio;46 readonly stdio: Stdio.Stdio;
53 readonly terminationError?: Effect.Effect<AcpError.AcpError>;47 readonly terminationError?: Effect.Effect<AcpError.AcpError>;
54 readonly serverRequestMethods: ReadonlySet<string>;48 readonly serverRequestMethods: ReadonlySet<string>;
55 readonly logIncoming?: boolean;49 readonly logIncoming?: boolean;
56 readonly logOutgoing?: boolean;50 readonly logOutgoing?: boolean;
57 readonly logger?: (event: AcpProtocolLogEvent) => Effect.Effect<void, never>;51 readonly logger?: (event: AcpProtocolLogEvent) => Effect.Effect<void, never>;
58 readonly transformSessionUpdate?: (52 readonly onIncomingRequest?: (
59 notification: AcpSchema.SessionNotification,53 requestId: string,
60 ) => AcpSchema.SessionNotification;54 method: string,
55 payload: unknown,
56 ) => Effect.Effect<void, never>;
61 readonly onNotification?: (57 readonly onNotification?: (
62 notification: AcpIncomingNotification,58 notification: AcpIncomingNotification,
63 ) => Effect.Effect<void, AcpError.AcpError, never>;59 ) => Effect.Effect<void, AcpError.AcpError, never>;
@@ -66,6 +62,18 @@ export interface AcpPatchedProtocolOptions { · 2 unmodified lines
66 params: unknown,62 params: unknown,
67 ) => Effect.Effect<unknown, AcpError.AcpError, never>;63 ) => Effect.Effect<unknown, AcpError.AcpError, never>;
68 readonly onTermination?: (error: AcpError.AcpError) => Effect.Effect<void, never, never>;64 readonly onTermination?: (error: AcpError.AcpError) => Effect.Effect<void, never, never>;
65 readonly onOutgoingResponseFailure?: (
66 requestId: string,
67 error: AcpError.AcpError,
68 ) => Effect.Effect<void, never>;
69 readonly onOutgoingResponse?: (requestId: string) => Effect.Effect<void, never>;
70 readonly testHooks?: {
71 readonly onOutgoingWriteAdmitted?: () => Effect.Effect<void>;
72 /** Invoked from the stdout writer exit path (after intentional/unexpected handling). */
73 readonly onOutgoingWriterExit?: (input: {
74 readonly intentionalEnd: boolean;
75 }) => Effect.Effect<void>;
76 };
69}77}
70 78
71export interface AcpPatchedProtocol {79export interface AcpPatchedProtocol {
@@ -81,6 +89,17 @@ interface AcpPendingRequest { · 9 unmodified lines
81 readonly method: string;89 readonly method: string;
82}90}
83 91
92interface AcpOutgoingWrite {
93 readonly acknowledgement?: Deferred.Deferred<void, AcpError.AcpError>;
94 readonly payload: string | Uint8Array;
95}
96
97interface AcpOutgoingWriterState {
98 readonly outstandingAcknowledgements: ReadonlySet<Deferred.Deferred<void, AcpError.AcpError>>;
99 readonly intentionalEnd: boolean;
100 readonly terminalError?: AcpError.AcpError;
101}
102
84const decodeSessionUpdate = Schema.decodeUnknownEffect(AcpSchema.SessionNotification);103const decodeSessionUpdate = Schema.decodeUnknownEffect(AcpSchema.SessionNotification);
85const decodeElicitationComplete = Schema.decodeUnknownEffect(104const decodeElicitationComplete = Schema.decodeUnknownEffect(
86 AcpSchema.ElicitationCompleteNotification,105 AcpSchema.ElicitationCompleteNotification,
@@ -108,16 +127,15 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 21 unmodified lines
108 MAX_BUFFERED_RAW_NOTIFICATIONS,127 MAX_BUFFERED_RAW_NOTIFICATIONS,
109 );128 );
110 const disconnects = yield* Queue.unbounded<number>();129 const disconnects = yield* Queue.unbounded<number>();
111 const outgoing = yield* Queue.unbounded<string | Uint8Array, Cause.Done<void>>();130 const outgoing = yield* Queue.unbounded<AcpOutgoingWrite, AcpError.AcpError | Cause.Done<void>>();
112 const nextRequestId = yield* Ref.make(1);131 const outgoingWriterState = yield* Ref.make<AcpOutgoingWriterState>({
132 intentionalEnd: false,
133 outstandingAcknowledgements: new Set(),
134 });
135 const nextRequestId = yield* Ref.make(1n);
113 const terminationHandled = yield* Ref.make(false);136 const terminationHandled = yield* Ref.make(false);
114 const terminationFailure = yield* Deferred.make<never, AcpError.AcpError>();
115 const extPending = yield* Ref.make(new Map<string, AcpPendingRequest>());137 const extPending = yield* Ref.make(new Map<string, AcpPendingRequest>());
116 138
117 const ensureActive = Ref.get(terminationHandled).pipe(
118 Effect.flatMap((terminated) => (terminated ? Deferred.await(terminationFailure) : Effect.void)),
119 );
120
121 const logProtocol = (event: AcpProtocolLogEvent) => {139 const logProtocol = (event: AcpProtocolLogEvent) => {
122 if (event.direction === "incoming" && !options.logIncoming) {140 if (event.direction === "incoming" && !options.logIncoming) {
123 return Effect.void;141 return Effect.void;
@@ -139,7 +157,6 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 15 unmodified lines
139 if (message._tag === "Interrupt") {157 if (message._tag === "Interrupt") {
140 return;158 return;
141 }159 }
142 yield* ensureActive;
143 yield* logProtocol({160 yield* logProtocol({
144 direction: "outgoing",161 direction: "outgoing",
145 stage: "decoded",162 stage: "decoded",
@@ -166,8 +183,66 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 20 unmodified lines
166 payload: typeof encoded === "string" ? encoded : new TextDecoder().decode(encoded),183 payload: typeof encoded === "string" ? encoded : new TextDecoder().decode(encoded),
167 });184 });
168 185
169 yield* ensureActive;186 const acknowledgement =
170 yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid);187 message._tag === "Exit" ? yield* Deferred.make<void, AcpError.AcpError>() : undefined;
188 const admissionError = yield* Ref.modify(outgoingWriterState, (state) => {
189 if (state.terminalError !== undefined) {
190 return [state.terminalError, state] as const;
191 }
192 if (acknowledgement === undefined) {
193 return [undefined, state] as const;
194 }
195 return [
196 undefined,
197 {
198 ...state,
199 outstandingAcknowledgements: new Set(state.outstandingAcknowledgements).add(
200 acknowledgement,
201 ),
202 },
203 ] as const;
204 });
205 if (admissionError !== undefined) {
206 return yield* admissionError;
207 }
208 if (options.testHooks?.onOutgoingWriteAdmitted !== undefined) {
209 yield* options.testHooks.onOutgoingWriteAdmitted();
210 }
211 yield* Queue.offer(outgoing, {
212 payload: encoded,
213 ...(acknowledgement === undefined ? {} : { acknowledgement }),
214 }).pipe(
215 Effect.mapError(
216 (cause) =>
217 new AcpError.AcpTransportError({
218 detail: "Failed to queue an outgoing ACP message",
219 cause,
220 }),
221 ),
222 Effect.filterOrFail(
223 (accepted) => accepted,
224 () =>
225 new AcpError.AcpTransportError({
226 detail: "The ACP output queue was closed before accepting a message",
227 cause: "Output queue closed",
228 }),
229 ),
230 Effect.tapError((error) =>
231 acknowledgement === undefined
232 ? Effect.void
233 : Ref.update(outgoingWriterState, (state) => {
234 const updated = new Set(state.outstandingAcknowledgements);
235 updated.delete(acknowledgement);
236 return { ...state, outstandingAcknowledgements: updated };
237 }).pipe(Effect.andThen(Deferred.fail(acknowledgement, error)), Effect.asVoid),
238 ),
239 );
240 if (acknowledgement !== undefined) {
241 yield* Deferred.await(acknowledgement);
242 }
243 if (message._tag === "Exit" && options.onOutgoingResponse !== undefined) {
244 yield* options.onOutgoingResponse(message.requestId);
245 }
171 }246 }
172 });247 });
173 248
@@ -233,7 +308,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 59 unmodified lines
233 }),308 }),
234 }).pipe(Effect.asVoid);309 }).pipe(Effect.asVoid);
235 310
236 const handleTermination = (classify: () => Effect.Effect<AcpError.AcpError>) =>311 const handleTermination = (classify: () => Effect.Effect<AcpError.AcpError | undefined>) =>
237 Ref.modify(terminationHandled, (handled) => {312 Ref.modify(terminationHandled, (handled) => {
238 if (handled) {313 if (handled) {
239 return [Effect.void, true] as const;314 return [Effect.void, true] as const;
@@ -242,7 +317,9 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 2 unmodified lines
242 Effect.gen(function* () {317 Effect.gen(function* () {
243 yield* Queue.offer(disconnects, 0);318 yield* Queue.offer(disconnects, 0);
244 const error = yield* classify();319 const error = yield* classify();
245 yield* Deferred.fail(terminationFailure, error);320 if (!error) {
321 return;
322 }
246 yield* failAllExtPending(error);323 yield* failAllExtPending(error);
247 yield* emitClientProtocolError(error);324 yield* emitClientProtocolError(error);
248 if (options.onTermination) {325 if (options.onTermination) {
@@ -303,7 +380,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 54 unmodified lines
303 ({380 ({
304 _tag: "SessionUpdate",381 _tag: "SessionUpdate",
305 method: CLIENT_METHODS.session_update,382 method: CLIENT_METHODS.session_update,
306 params: options.transformSessionUpdate?.(params) ?? params,383 params,
307 }) satisfies AcpIncomingNotification,384 }) satisfies AcpIncomingNotification,
308 ),385 ),
309 Effect.mapError((cause) =>386 Effect.mapError((cause) =>
@@ -343,8 +420,11 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 33 unmodified lines
343 });420 });
344 }421 }
345 422
423 const observeIncoming =
424 options.onIncomingRequest?.(message.id, message.tag, message.payload) ?? Effect.void;
425
346 if (!options.serverRequestMethods.has(message.tag)) {426 if (!options.serverRequestMethods.has(message.tag)) {
347 return handleExtRequest(message).pipe(427 return observeIncoming.pipe(Effect.andThen(handleExtRequest(message))).pipe(
348 Effect.catchTags({428 Effect.catchTags({
349 AcpProtocolParseError: (error) =>429 AcpProtocolParseError: (error) =>
350 Effect.logWarning(error).pipe(430 Effect.logWarning(error).pipe(
@@ -369,7 +449,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 18 unmodified lines
369 );449 );
370 }450 }
371 451
372 return Queue.offer(serverQueue, message).pipe(Effect.asVoid);452 return observeIncoming.pipe(Effect.andThen(Queue.offer(serverQueue, message)), Effect.asVoid);
373 };453 };
374 454
375 const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) =>455 const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) =>
@@ -511,7 +591,112 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 135 unmodified lines
511 Effect.forkScoped,591 Effect.forkScoped,
512 );592 );
513 593
514 yield* Stream.fromQueue(outgoing).pipe(Stream.run(options.stdio.stdout()), Effect.forkScoped);594 const failOutgoingWrites = (error: AcpError.AcpError) =>
595 Ref.modify(outgoingWriterState, (state) => {
596 const terminalError = state.terminalError ?? error;
597 return [
598 [terminalError, state.outstandingAcknowledgements] as const,
599 {
600 ...state,
601 terminalError,
602 outstandingAcknowledgements: new Set<Deferred.Deferred<void, AcpError.AcpError>>(),
603 },
604 ] as const;
605 }).pipe(
606 Effect.flatMap((acknowledgements) =>
607 Effect.forEach(
608 acknowledgements[1],
609 (acknowledgement) => Deferred.fail(acknowledgement, acknowledgements[0]),
610 { concurrency: "unbounded", discard: true },
611 ),
612 ),
613 Effect.andThen(Queue.fail(outgoing, error)),
614 Effect.asVoid,
615 );
616 const completeOutgoingWrites = () =>
617 Ref.modify(outgoingWriterState, (state) => {
618 return [
619 state.outstandingAcknowledgements,
620 {
621 ...state,
622 outstandingAcknowledgements: new Set<Deferred.Deferred<void, AcpError.AcpError>>(),
623 },
624 ] as const;
625 }).pipe(
626 Effect.flatMap((acknowledgements) =>
627 Effect.forEach(
628 acknowledgements,
629 (acknowledgement) => Deferred.succeed(acknowledgement, undefined),
630 { concurrency: "unbounded", discard: true },
631 ),
632 ),
633 Effect.asVoid,
634 );
635 const acknowledgeOutgoingWrite = (acknowledgement: Deferred.Deferred<void, AcpError.AcpError>) =>
636 Ref.update(outgoingWriterState, (state) => {
637 const updated = new Set(state.outstandingAcknowledgements);
638 updated.delete(acknowledgement);
639 return { ...state, outstandingAcknowledgements: updated };
640 }).pipe(Effect.andThen(Deferred.succeed(acknowledgement, undefined)), Effect.asVoid);
641 yield* Stream.fromQueue(outgoing).pipe(
642 Stream.flatMap((write) => {
643 const acknowledgement = write.acknowledgement;
644 const completion =
645 acknowledgement === undefined
646 ? Stream.empty
647 : Stream.fromEffect(acknowledgeOutgoingWrite(acknowledgement)).pipe(Stream.drain);
648 return Stream.make(write.payload).pipe(Stream.concat(completion));
649 }),
650 Stream.run(options.stdio.stdout()),
651 Effect.onExit((exit) =>
652 Exit.match(exit, {
653 onFailure: (cause) => {
654 const error = new AcpError.AcpTransportError({
655 detail: Cause.hasInterruptsOnly(cause)
656 ? "The ACP output writer was interrupted while closing"
657 : "Failed to write an outgoing ACP message",
658 cause,
659 });
660 return failOutgoingWrites(error).pipe(
661 Effect.andThen(
662 Cause.hasInterruptsOnly(cause)
663 ? Effect.void
664 : handleTermination(() => Effect.succeed(error)),
665 ),
666 );
667 },
668 onSuccess: () =>
669 Ref.get(outgoingWriterState).pipe(
670 Effect.flatMap((state) => {
671 // Intentional Queue.end (serverProtocol.end) completes the writer
672 // successfully. Do not invent a transport failure or client protocol
673 // error for a clean RPC shutdown.
674 const handled = state.intentionalEnd
675 ? completeOutgoingWrites()
676 : (() => {
677 const error = new AcpError.AcpTransportError({
678 detail: "ACP output writer ended before the protocol closed",
679 cause: "Output writer ended",
680 });
681 return failOutgoingWrites(error).pipe(
682 Effect.andThen(handleTermination(() => Effect.succeed(error))),
683 );
684 })();
685 return handled.pipe(
686 Effect.andThen(
687 options.testHooks?.onOutgoingWriterExit === undefined
688 ? Effect.void
689 : options.testHooks.onOutgoingWriterExit({
690 intentionalEnd: state.intentionalEnd,
691 }),
692 ),
693 );
694 }),
695 ),
696 }),
697 ),
698 Effect.forkScoped,
699 );
515 700
516 const clientProtocol = RpcClient.Protocol.of({701 const clientProtocol = RpcClient.Protocol.of({
517 run: (_clientId, f) =>702 run: (_clientId, f) =>
@@ -542,8 +727,19 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 24 unmodified lines
542 Effect.forever,727 Effect.forever,
543 ),728 ),
544 disconnects,729 disconnects,
545 send: (_clientId, response) => offerOutgoing(response).pipe(Effect.orDie),730 send: (_clientId, response) =>
546 end: (_clientId) => Queue.end(outgoing),731 offerOutgoing(response).pipe(
732 Effect.tapError((error) =>
733 response._tag === "Exit" && options.onOutgoingResponseFailure !== undefined
734 ? options.onOutgoingResponseFailure(response.requestId, error)
735 : Effect.void,
736 ),
737 Effect.orDie,
738 ),
739 end: (_clientId) =>
740 Ref.update(outgoingWriterState, (state) => ({ ...state, intentionalEnd: true })).pipe(
741 Effect.andThen(Queue.end(outgoing)),
742 ),
547 clientIds: Effect.succeed(new Set([0])),743 clientIds: Effect.succeed(new Set([0])),
548 initialMessage: Effect.succeedNone,744 initialMessage: Effect.succeedNone,
549 supportsAck: true,745 supportsAck: true,
@@ -558,7 +754,6 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 8 unmodified lines
558 method: string,754 method: string,
559 payload: unknown,755 payload: unknown,
560 ) {756 ) {
561 yield* ensureActive;
562 yield* logProtocol({757 yield* logProtocol({
563 direction: "outgoing",758 direction: "outgoing",
564 stage: "decoded",759 stage: "decoded",
@@ -574,12 +769,10 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 9 unmodified lines
574 }769 }
575 const encoded = `${exit.value}\n`;770 const encoded = `${exit.value}\n`;
576 yield* logProtocol({ direction: "outgoing", stage: "raw", payload: encoded });771 yield* logProtocol({ direction: "outgoing", stage: "raw", payload: encoded });
577 yield* ensureActive;
578 yield* Queue.offer(outgoing, encoded);772 yield* Queue.offer(outgoing, encoded);
579 });773 });
580 774
581 const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) {775 const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) {
582 yield* ensureActive;
583 const requestId = yield* Ref.modify(776 const requestId = yield* Ref.modify(
584 nextRequestId,777 nextRequestId,
585 (current) => [current, current + 1] as const,778 (current) => [current, current + 1] as const,
packages/shared/src/agentAwareness.test.ts+16−0
@@ -56,4 +56,20 @@ describe("projectThreadAwarenessV2", () => { · 55 unmodified lines
56 }),56 }),
57 ).toMatchObject({ phase: "waiting_for_input", headline: "Waiting for input" });57 ).toMatchObject({ phase: "waiting_for_input", headline: "Waiting for input" });
58 });58 });
59
60 it("does not present authentication refreshes as user approvals", () => {
61 expect(
62 projectThreadAwarenessV2({
63 environmentId: "env-1" as EnvironmentId,
64 project,
65 thread: v2Thread({
66 pendingRuntimeRequest: {
67 id: RuntimeRequestId.make("request-auth-refresh"),
68 kind: "auth_refresh",
69 createdAt: updatedAt,
70 },
71 }),
72 }),
73 ).toMatchObject({ phase: "running", headline: "Agent is working" });
74 });
59});75});
packages/shared/src/agentAwareness.ts+4−1
@@ -87,7 +87,10 @@ function resolveThreadAwarenessPhaseV2( · 86 unmodified lines
87 if (thread.pendingRuntimeRequest?.kind === "user_input") {87 if (thread.pendingRuntimeRequest?.kind === "user_input") {
88 return "waiting_for_input";88 return "waiting_for_input";
89 }89 }
90 if (thread.pendingRuntimeRequest !== null) {90 if (
91 thread.pendingRuntimeRequest !== null &&
92 thread.pendingRuntimeRequest.kind !== "auth_refresh"
93 ) {
91 return "waiting_for_approval";94 return "waiting_for_approval";
92 }95 }
93 switch (thread.status) {96 switch (thread.status) {
packages/shared/src/orchestrationV2Timeline.test.ts+33−2
@@ -7,22 +7,52 @@ const runId = RunId.make("run:timeline-visibility"); · 6 unmodified lines
7const nodeId = NodeId.make("node:timeline-visibility");7const nodeId = NodeId.make("node:timeline-visibility");
8 8
9describe("isOrchestrationV2TurnItemVisible", () => {9describe("isOrchestrationV2TurnItemVisible", () => {
10 it("hides interruption results from superseded attempts", () => {10 it("hides unpaired interruption results from superseded attempts", () => {
11 expect(11 expect(
12 isOrchestrationV2TurnItemVisible({12 isOrchestrationV2TurnItemVisible({
13 item: { type: "run_interrupt_result", runId, nodeId },13 item: { type: "run_interrupt_result", runId, nodeId },
14 runs: [{ id: runId, status: "running" }],14 runs: [{ id: runId, status: "running" }],
15 attempts: [{ runId, rootNodeId: nodeId, status: "superseded" }],15 attempts: [{ runId, rootNodeId: nodeId, status: "superseded" }],
16 items: [{ type: "run_interrupt_result", runId, nodeId }],
16 }),17 }),
17 ).toBe(false);18 ).toBe(false);
18 });19 });
19 20
20 it("keeps interruption results from terminal attempts", () => {21 it("keeps paired interruption results from superseded attempts", () => {
22 expect(
23 isOrchestrationV2TurnItemVisible({
24 item: { type: "run_interrupt_result", runId, nodeId },
25 runs: [{ id: runId, status: "running" }],
26 attempts: [{ runId, rootNodeId: nodeId, status: "superseded" }],
27 items: [
28 { type: "run_interrupt_request", runId, nodeId },
29 { type: "run_interrupt_result", runId, nodeId },
30 ],
31 }),
32 ).toBe(true);
33 });
34
35 it("keeps interruption results from terminal attempts without a request", () => {
21 expect(36 expect(
22 isOrchestrationV2TurnItemVisible({37 isOrchestrationV2TurnItemVisible({
23 item: { type: "run_interrupt_result", runId, nodeId },38 item: { type: "run_interrupt_result", runId, nodeId },
24 runs: [{ id: runId, status: "interrupted" }],39 runs: [{ id: runId, status: "interrupted" }],
25 attempts: [{ runId, rootNodeId: nodeId, status: "interrupted" }],40 attempts: [{ runId, rootNodeId: nodeId, status: "interrupted" }],
41 items: [{ type: "run_interrupt_result", runId, nodeId }],
42 }),
43 ).toBe(true);
44 });
45
46 it("keeps interruption results from terminal attempts with a request", () => {
47 expect(
48 isOrchestrationV2TurnItemVisible({
49 item: { type: "run_interrupt_result", runId, nodeId },
50 runs: [{ id: runId, status: "interrupted" }],
51 attempts: [{ runId, rootNodeId: nodeId, status: "interrupted" }],
52 items: [
53 { type: "run_interrupt_request", runId, nodeId },
54 { type: "run_interrupt_result", runId, nodeId },
55 ],
26 }),56 }),
27 ).toBe(true);57 ).toBe(true);
28 });58 });
@@ -40,6 +70,7 @@ describe("isOrchestrationV2TurnItemVisible", () => { · 11 unmodified lines
40 },70 },
41 { runId, rootNodeId: nodeId, status: "interrupted" },71 { runId, rootNodeId: nodeId, status: "interrupted" },
42 ],72 ],
73 items: [{ type: "run_interrupt_result", runId, nodeId }],
43 }),74 }),
44 ).toBe(true);75 ).toBe(true);
45 });76 });
packages/shared/src/orchestrationV2Timeline.ts+18−2
@@ -11,24 +11,36 @@ type TimelineTurnItem = Pick<OrchestrationV2TurnItem, "type" | "runId" | "nodeId · 10 unmodified lines
11export function isOrchestrationV2SupersededInterrupt(input: {11export function isOrchestrationV2SupersededInterrupt(input: {
12 readonly item: TimelineTurnItem;12 readonly item: TimelineTurnItem;
13 readonly attempts: ReadonlyArray<TimelineRunAttempt>;13 readonly attempts: ReadonlyArray<TimelineRunAttempt>;
14 readonly items: ReadonlyArray<TimelineTurnItem>;
14}): boolean {15}): boolean {
15 const { item } = input;16 const { item } = input;
16 if (item.type !== "run_interrupt_result" || item.runId === null || item.nodeId === null) {17 if (item.type !== "run_interrupt_result" || item.runId === null || item.nodeId === null) {
17 return false;18 return false;
18 }19 }
19 20
20 return input.attempts.some(21 const isSuperseded = input.attempts.some(
21 (attempt) =>22 (attempt) =>
22 attempt.runId === item.runId &&23 attempt.runId === item.runId &&
23 attempt.rootNodeId === item.nodeId &&24 attempt.rootNodeId === item.nodeId &&
24 attempt.status === "superseded",25 attempt.status === "superseded",
25 );26 );
27 if (!isSuperseded) {
28 return false;
29 }
30
31 // Paired stop-then-steer results have a matching request on the same run and
32 // must stay visible. Legacy plain-steer results have no request and stay hidden.
33 const hasMatchingRequest = input.items.some(
34 (candidate) => candidate.type === "run_interrupt_request" && candidate.runId === item.runId,
35 );
36 return !hasMatchingRequest;
26}37}
27 38
28export function isOrchestrationV2TurnItemVisible(input: {39export function isOrchestrationV2TurnItemVisible(input: {
29 readonly item: TimelineTurnItem;40 readonly item: TimelineTurnItem;
30 readonly runs: ReadonlyArray<TimelineRun>;41 readonly runs: ReadonlyArray<TimelineRun>;
31 readonly attempts: ReadonlyArray<TimelineRunAttempt>;42 readonly attempts: ReadonlyArray<TimelineRunAttempt>;
43 readonly items: ReadonlyArray<TimelineTurnItem>;
32}): boolean {44}): boolean {
33 const { item } = input;45 const { item } = input;
34 if (46 if (
@@ -38,5 +50,9 @@ export function isOrchestrationV2TurnItemVisible(input: { · 3 unmodified lines
38 return false;50 return false;
39 }51 }
40 52
41 return !isOrchestrationV2SupersededInterrupt({ item, attempts: input.attempts });53 return !isOrchestrationV2SupersededInterrupt({
54 item,
55 attempts: input.attempts,
56 items: input.items,
57 });
42}58}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t