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
8282 !/.repos/
8383 sparse-checkout-cone-mode: false
8484
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+
8595 - name: Setup Vite+
8696 uses: voidzero-dev/setup-vp@v1
8797 with:
apps/mobile/src/features/threads/ThreadActivityInspector.tsx+3−2
@@ -16,6 +16,7 @@ import { useV2ItemSupport } from "../../state/v2-item-support"; · 15 unmodified lines
1616
1717 export function ThreadActivityInspector(props: {
1818 readonly activity: ThreadFeedActivity;
19+ readonly currentThreadId: ThreadId;
1920 readonly environmentId: EnvironmentId;
2021 readonly iconColor: ColorValue;
2122 readonly workspaceRoot?: string | null;
@@ -28,8 +29,8 @@ export function ThreadActivityInspector(props: { · 6 unmodified lines
2829 sourceItemId: row.sourceItemId,
2930 });
3031 const model = useMemo(
31− () => buildThreadActivityInspector(props.activity, support),
32− [props.activity, support],
32+ () => buildThreadActivityInspector(props.activity, support, props.currentThreadId),
33+ [props.activity, props.currentThreadId, support],
3334 );
3435 const revertCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, {
3536 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
10551055
10561056 function renderFeedEntry(
10571057 info: { item: ThreadFeedEntry; index: number },
1058− props: Pick<ThreadFeedProps, "environmentId" | "skills" | "workspaceRoot"> & {
1058+ props: Pick<ThreadFeedProps, "environmentId" | "skills" | "threadId" | "workspaceRoot"> & {
10591059 readonly copiedRowId: string | null;
10601060 readonly expandedWorkRows: Record<string, boolean>;
10611061 readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>;
@@ -1298,6 +1298,7 @@ function renderFeedEntry( · 236 unmodified lines
12981298 activities={entry.activities}
12991299 anchorKey={entry.id}
13001300 copiedRowId={props.copiedRowId}
1301+ currentThreadId={props.threadId}
13011302 environmentId={props.environmentId}
13021303 expanded={props.expandedWorkGroups[entry.id] ?? false}
13031304 expandedRows={props.expandedWorkRows}
@@ -2117,6 +2118,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 813 unmodified lines
21172118 (info: { item: ThreadFeedEntry; index: number }) =>
21182119 renderFeedEntry(info, {
21192120 environmentId: props.environmentId,
2121+ threadId: props.threadId,
21202122 copiedRowId,
21212123 expandedWorkRows,
21222124 terminalAssistantMessageIds,
@@ -2158,6 +2160,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 35 unmodified lines
21582160 onToggleWorkGroup,
21592161 onToggleWorkRow,
21602162 props.environmentId,
2163+ props.threadId,
21612164 props.threadTitle,
21622165 props.skills,
21632166 props.workspaceRoot,
apps/mobile/src/lib/threadActivityInspector.test.ts+47−18
@@ -93,7 +93,7 @@ describe("buildThreadActivityInspector", () => { · 92 unmodified lines
9393 } as never,
9494 };
9595
96− const model = buildThreadActivityInspector(activityFor(item), support);
96+ const model = buildThreadActivityInspector(activityFor(item), support, sourceThreadId);
9797 expect(model.fields).toEqual(
9898 expect.arrayContaining([
9999 { label: "Duration", value: "2.0s" },
@@ -119,7 +119,11 @@ describe("buildThreadActivityInspector", () => { · 19 unmodified lines
119119 pattern: "resolveSession",
120120 results: [{ fileName: "src/session.ts", line: 42, preview: "function resolveSession()" }],
121121 };
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+ );
123127 expect(fileModel.fileLinks).toEqual([
124128 {
125129 label: "src/session.ts — function resolveSession()",
@@ -136,7 +140,11 @@ describe("buildThreadActivityInspector", () => { · 10 unmodified lines
136140 { title: "Schema", url: "https://effect.website/docs/schema", snippet: "Typed schemas" },
137141 ],
138142 };
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+ );
140148 expect(webModel.webLinks).toEqual([
141149 { label: "Schema", url: "https://effect.website/docs/schema" },
142150 ]);
@@ -156,6 +164,7 @@ describe("buildThreadActivityInspector", () => { · 13 unmodified lines
156164 const dynamicModel = buildThreadActivityInspector(
157165 activityFor(dynamicTool),
158166 EMPTY_V2_ITEM_SUPPORT,
167+ sourceThreadId,
159168 );
160169 expect(dynamicModel.blocks).toEqual(
161170 expect.arrayContaining([
@@ -177,11 +186,15 @@ describe("buildThreadActivityInspector", () => { · 15 unmodified lines
177186 files: [{ path: "src/main.ts", kind: "modified", additions: 2, deletions: 1 }],
178187 };
179188 const checkpoint = { id: checkpointId, scopeId, status: "ready" } as never;
180− const model = buildThreadActivityInspector(activityFor(item), {
181− ...EMPTY_V2_ITEM_SUPPORT,
182− item,
183− checkpoint,
184− });
189+ const model = buildThreadActivityInspector(
190+ activityFor(item),
191+ {
192+ ...EMPTY_V2_ITEM_SUPPORT,
193+ item,
194+ checkpoint,
195+ },
196+ sourceThreadId,
197+ );
185198
186199 expect(model.canRollback).toBe(true);
187200 expect(model.rollbackTarget).toEqual({
@@ -189,6 +202,18 @@ describe("buildThreadActivityInspector", () => { · 1 unmodified line
189202 checkpointId,
190203 scopeId,
191204 });
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();
192217 });
193218
194219 it("prefers live subagent progress from supporting state", () => {
@@ -204,16 +229,20 @@ describe("buildThreadActivityInspector", () => { · 9 unmodified lines
204229 progress: "Starting",
205230 result: null,
206231 };
207− const model = buildThreadActivityInspector(activityFor(item), {
208− ...EMPTY_V2_ITEM_SUPPORT,
209− item,
210− subagent: {
211− origin: "provider_native",
212− status: "running",
213− progress: "Reading projection tests",
214− result: null,
215− } as never,
216− });
232+ const model = buildThreadActivityInspector(
233+ activityFor(item),
234+ {
235+ ...EMPTY_V2_ITEM_SUPPORT,
236+ item,
237+ subagent: {
238+ origin: "provider_native",
239+ status: "running",
240+ progress: "Reading projection tests",
241+ result: null,
242+ } as never,
243+ },
244+ sourceThreadId,
245+ );
217246
218247 expect(model.fields).toContainEqual({
219248 label: "Delegated task",
apps/mobile/src/lib/threadActivityInspector.ts+5−1
@@ -73,6 +73,7 @@ function addBlock( · 72 unmodified lines
7373 export function buildThreadActivityInspector(
7474 activity: ThreadFeedActivity,
7575 support: V2ItemSupport,
76+ currentThreadId: ThreadId,
7677 ): ThreadActivityInspectorModel {
7778 const row = activity.projectedItem;
7879 const item = row.item;
@@ -279,7 +280,10 @@ export function buildThreadActivityInspector( · 200 unmodified lines
279280 break;
280281 }
281282
282− const checkpoint = item.type === "checkpoint" ? support.checkpoint : null;
283+ const checkpoint =
284+ item.type === "checkpoint" && row.sourceThreadId === currentThreadId
285+ ? support.checkpoint
286+ : null;
283287 return {
284288 fields,
285289 blocks,
apps/server/scripts/acp-mock-agent.ts+454−195
@@ -1,5 +1,6 @@
11 #!/usr/bin/env node
22 // @effect-diagnostics nodeBuiltinImport:off
3+import * as NodeChildProcess from "node:child_process";
34 import * as NodeFS from "node:fs";
45
56 import * as Effect from "effect/Effect";
@@ -17,21 +18,36 @@ const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; · 11 unmodified lines
1718 const emitInterleavedAssistantToolCalls =
1819 process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
1920 const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
21+const emitPostSettleMonitorFlow = process.env.T3_ACP_EMIT_POST_SETTLE_MONITOR_FLOW === "1";
22+const emitInTurnTaskOutputThenLateDuplicate =
23+ process.env.T3_ACP_EMIT_IN_TURN_TASKOUTPUT_THEN_LATE_DUPLICATE === "1";
24+const injectedReportTriggerPath = process.env.T3_ACP_INJECTED_REPORT_TRIGGER_PATH;
2025 const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
26+const emitElicitation = process.env.T3_ACP_EMIT_ELICITATION === "1";
27+const emitUrlElicitation = process.env.T3_ACP_EMIT_URL_ELICITATION === "1";
2128 const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";
22−const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1";
23−const emitXAiPlanMdWrite = process.env.T3_ACP_EMIT_XAI_PLAN_MD_WRITE === "1";
2429 const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1";
25−const emitXAiRateLimitThenHang = process.env.T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG === "1";
26−const emitXAiAskUserQuestionThenHang =
27− process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG === "1";
28−const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1";
29−const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1";
30−const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1";
3130 const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1";
3231 const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1";
32+const hangAfterPermission = process.env.T3_ACP_HANG_AFTER_PERMISSION === "1";
3333 const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1";
3434 const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1";
35+const emitTaskBackgroundedAfterCancel =
36+ process.env.T3_ACP_EMIT_TASK_BACKGROUNDED_AFTER_CANCEL === "1";
37+const residualCallbackResponseLogPath = process.env.T3_ACP_RESIDUAL_CALLBACK_RESPONSE_LOG_PATH;
38+const residualCallbackTriggerPath = process.env.T3_ACP_RESIDUAL_CALLBACK_TRIGGER_PATH;
39+const exitAfterResidualCallbacks = process.env.T3_ACP_EXIT_AFTER_RESIDUAL_CALLBACKS === "1";
40+const emitRunningCommandThenHang = process.env.T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG === "1";
41+const emitRunningCommandThenHangOnFirstPrompt =
42+ process.env.T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG_FIRST_PROMPT === "1";
43+const emitEmptySuccessfulBash = process.env.T3_ACP_EMIT_EMPTY_SUCCESSFUL_BASH === "1";
44+const emitEmptySuccessfulBashThenHang =
45+ process.env.T3_ACP_EMIT_EMPTY_SUCCESSFUL_BASH_THEN_HANG === "1";
46+const exitOnCancel = process.env.T3_ACP_EXIT_ON_CANCEL === "1";
47+const runningCommandIgnoresTerm = process.env.T3_ACP_RUNNING_COMMAND_IGNORE_TERM === "1";
48+const runningCommandPidPath = process.env.T3_ACP_RUNNING_COMMAND_PID_PATH;
49+const runningCommandSeparateSession = process.env.T3_ACP_RUNNING_COMMAND_SEPARATE_SESSION === "1";
50+const exitAfterRunningCommandLaunch = process.env.T3_ACP_EXIT_AFTER_RUNNING_COMMAND_LAUNCH === "1";
3551 const omitXAiPromptCompleteStopReason =
3652 process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1";
3753 const 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
4763 const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
4864 const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
4965 const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
50−const initialGrokReasoningEffort =
51− process.env.T3_ACP_INITIAL_GROK_REASONING_EFFORT?.trim() || undefined;
5266 const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0");
5367 const supportsSessionLifecycle = process.env.T3_ACP_SESSION_LIFECYCLE === "1";
5468 const advertisedAuthMethodId = process.env.T3_ACP_AUTH_METHOD_ID?.trim();
@@ -58,11 +72,6 @@ const permissionOptionIds = { · 3 unmodified lines
5872 allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always",
5973 rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once",
6074 };
61−const omitAllowAlways = process.env.T3_ACP_OMIT_ALLOW_ALWAYS === "1";
62−const permissionRequestCount = Math.max(
63− 1,
64− Number(process.env.T3_ACP_PERMISSION_REQUEST_COUNT ?? "1") || 1,
65−);
6675 const sessionId = "mock-session-1";
6776
6877 let currentModeId = "ask";
@@ -81,6 +90,11 @@ function logExit(reason: string): void { · 12 unmodified lines
8190 appendFileSync(exitLogPath, `${reason}\n`, "utf8");
8291 }
8392
93+function logResidualCallbackResponse(kind: string): void {
94+ if (!residualCallbackResponseLogPath) return;
95+ NodeFS.appendFileSync(residualCallbackResponseLogPath, `${kind}\n`, "utf8");
96+}
97+
8498 process.once("SIGTERM", () => {
8599 logExit("SIGTERM");
86100 process.exit(0);
@@ -254,20 +268,7 @@ function modeState(): AcpSchema.SessionModeState { · 167 unmodified lines
254268 // Mirrors the real Grok ACP: it advertises versioned model ids, never the CLI's own
255269 // "grok-build" product name, and it rejects unknown ids in session/set_model.
256270 const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [
257− {
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-build", name: "Grok Build" },
271272 { modelId: "grok-mock-alt", name: "Grok Mock Alt" },
272273 ];
273274
@@ -491,8 +492,54 @@ const program = Effect.gen(function* () { · 217 unmodified lines
491492 );
492493
493494 yield* agent.handleCancel(({ sessionId }) =>
494− Effect.sync(() => {
495− cancelledSessions.add(String(sessionId ?? "mock-session-1"));
495+ Effect.gen(function* () {
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+ }
496543 }),
497544 );
498545
@@ -501,6 +548,80 @@ const program = Effect.gen(function* () { · 2 unmodified lines
501548 const requestedSessionId = String(request.sessionId ?? sessionId);
502549 promptCount += 1;
503550
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+
504625 if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) {
505626 yield* Effect.sleep(`${promptDelayMs} millis`);
506627 }
@@ -562,70 +683,124 @@ const program = Effect.gen(function* () { · 55 unmodified lines
562683 return yield* Effect.never;
563684 }
564685
565− if (hangPromptForever || (hangFirstPromptForever && promptCount === 1)) {
686+ if (
687+ hangPromptForever ||
688+ (hangFirstPromptForever && promptCount === 1) ||
689+ (emitEmptySuccessfulBashThenHang && promptCount === 2)
690+ ) {
566691 return yield* Effect.never;
567692 }
568693
569− if (emitXAiRateLimitThenHang) {
570− writeJsonRpcNotification("_x.ai/session/prompt_complete", {
571− sessionId: requestedSessionId,
572− promptId: promptIdFromRequestMeta(request) ?? "mock-xai-rate-limit-prompt-1",
573− stopReason: "rate_limit",
574− agentResult: null,
575− });
576− return yield* Effect.never;
577− }
578−
579− if (emitContentThenHang) {
694+ if (
695+ emitRunningCommandThenHang ||
696+ (emitRunningCommandThenHangOnFirstPrompt && promptCount === 1)
697+ ) {
698+ const toolCallId = "tool-call-running-1";
699+ if (runningCommandPidPath !== undefined) {
700+ const command = runningCommandIgnoresTerm
701+ ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s\\n" "$$" "$child" > "$1"; wait "$child"'
702+ : 'sleep 120 & child=$!; printf "%s %s\\n" "$$" "$child" > "$1"; wait "$child"';
703+ if (runningCommandSeparateSession) {
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+ }
580727 yield* agent.client.sessionUpdate({
581728 sessionId: requestedSessionId,
582729 update: {
583− sessionUpdate: "agent_message_chunk",
584− content: { type: "text", text: "partial before stall" },
730+ sessionUpdate: "tool_call",
731+ toolCallId,
732+ title: "Terminal",
733+ kind: "execute",
734+ status: "pending",
735+ rawInput: {
736+ command: ["sleep", "120"],
737+ },
585738 },
586739 });
587− return yield* Effect.never;
588− }
589−
590− if (emitPlanThenHang) {
591740 yield* agent.client.sessionUpdate({
592741 sessionId: requestedSessionId,
593742 update: {
594− sessionUpdate: "plan",
595− entries: [
596− {
597− content: "Wait for more ACP progress",
598− priority: "high",
599− status: "in_progress",
600− },
601− ],
743+ sessionUpdate: "tool_call_update",
744+ toolCallId,
745+ title: "Terminal",
746+ kind: "execute",
747+ status: "in_progress",
748+ rawInput: {
749+ command: ["sleep", "120"],
750+ },
751+ // Grok-like mid-stream Bash re-report: exit_code 0 while still running.
752+ rawOutput: { type: "Bash", exit_code: 0 },
602753 },
603754 });
604− return yield* Effect.never;
605− }
606−
607− if (emitActiveToolThenHang) {
608− const toolCallId = "tool-call-long-running-1";
609755 yield* agent.client.sessionUpdate({
610756 sessionId: requestedSessionId,
611757 update: {
612758 sessionUpdate: "tool_call",
613− toolCallId,
614− title: "Long-running tool",
615− kind: "execute",
759+ toolCallId: "tool-call-output-1",
760+ title: "get_command_or_subagent_output",
761+ kind: "other",
616762 status: "pending",
617− rawInput: { command: ["long-running-tool"] },
763+ rawInput: { task_id: "task-running-1" },
618764 },
619765 });
620766 yield* agent.client.sessionUpdate({
621767 sessionId: requestedSessionId,
622768 update: {
623769 sessionUpdate: "tool_call_update",
624− toolCallId,
770+ toolCallId: "tool-call-output-1",
625771 status: "in_progress",
772+ rawOutput: { task_id: "task-running-1", status: "running" },
626773 },
627774 });
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" };
629804 }
630805
631806 if (emitXAiPromptCompleteThenHang) {
@@ -736,6 +911,32 @@ const program = Effect.gen(function* () { · 104 unmodified lines
736911 return { stopReason: "end_turn" };
737912 }
738913
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+
739940 if (emitToolCalls) {
740941 const toolCallId = "tool-call-1";
741942
@@ -762,57 +963,40 @@ const program = Effect.gen(function* () { · 20 unmodified lines
762963 },
763964 });
764965
765− const permissionOptions: Array<AcpSchema.PermissionOption> = [
766− { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" },
767− ...(omitAllowAlways
768− ? []
769− : [
770− {
771− optionId: permissionOptionIds.allowAlways,
772− name: "Allow always",
773− kind: "allow_always" as const,
966+ const permission = yield* agent.client.requestPermission({
967+ sessionId: requestedSessionId,
968+ toolCall: {
969+ toolCallId,
970+ title: "`cat server/package.json`",
971+ kind: "execute",
972+ status: "pending",
973+ content: [
974+ {
975+ type: "content",
976+ content: {
977+ type: "text",
978+ text: "Not in allowlist: cat server/package.json",
774979 },
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",
796980 },
797− content: [
798− {
799− type: "content",
800− content: {
801− type: "text",
802− text: `Not in allowlist: ${command}`,
803− },
804− },
805− ],
981+ ],
982+ },
983+ options: [
984+ { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" },
985+ {
986+ optionId: permissionOptionIds.allowAlways,
987+ name: "Allow always",
988+ kind: "allow_always",
806989 },
807− options: permissionOptions,
808− });
809− cancelled =
810− cancelled ||
811− cancelledSessions.delete(requestedSessionId) ||
812− permission.outcome.outcome === "cancelled";
813− if (cancelled) {
814− break;
815− }
990+ { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" },
991+ ],
992+ });
993+
994+ const cancelled =
995+ cancelledSessions.delete(requestedSessionId) ||
996+ permission.outcome.outcome === "cancelled";
997+
998+ if (hangAfterPermission) {
999+ return yield* Effect.never;
8161000 }
8171001
8181002 yield* agent.client.sessionUpdate({
@@ -881,6 +1065,159 @@ const program = Effect.gen(function* () { · 62 unmodified lines
8811065 return { stopReason: "end_turn" };
8821066 }
8831067
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+
8841221 if (emitAskQuestion) {
8851222 yield* agent.client.extRequest("cursor/ask_question", {
8861223 toolCallId: "ask-question-tool-call-1",
@@ -900,7 +1237,7 @@ const program = Effect.gen(function* () { · 13 unmodified lines
9001237 return { stopReason: "end_turn" };
9011238 }
9021239
903− if (emitXAiAskUserQuestion || emitXAiAskUserQuestionThenHang) {
1240+ if (emitXAiAskUserQuestion) {
9041241 const result = yield* agent.client.extRequest("_x.ai/ask_user_question", {
9051242 method: "x.ai/ask_user_question",
9061243 params: {
@@ -934,84 +1271,6 @@ const program = Effect.gen(function* () { · 27 unmodified lines
9341271 throw new Error("Expected accepted _x.ai/ask_user_question response answers.");
9351272 }
9361273
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− }
10151274 return { stopReason: "end_turn" };
10161275 }
10171276
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+
11+static const char *pid_path;
12+
13+static 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+
30+int 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
2+import * as NodeChildProcess from "node:child_process";
3+import * as NodeFS from "node:fs";
4+
15 import * as NodeServices from "@effect/platform-node/NodeServices";
26 import { assert, describe, it } from "@effect/vitest";
37 import {
@@ -16,31 +20,58 @@ import { · 12 unmodified lines
1620 type OrchestrationV2ProviderThread,
1721 } from "@t3tools/contracts";
1822 import * as DateTime from "effect/DateTime";
23+import * as Crypto from "effect/Crypto";
24+import * as Deferred from "effect/Deferred";
25+import type * as Duration from "effect/Duration";
26+import * as Cause from "effect/Cause";
27+import * as Clock from "effect/Clock";
1928 import * as Effect from "effect/Effect";
29+import * as Exit from "effect/Exit";
2030 import * as FileSystem from "effect/FileSystem";
2131 import * as Fiber from "effect/Fiber";
2232 import * as Layer from "effect/Layer";
2333 import * as Option from "effect/Option";
2434 import * as Path from "effect/Path";
35+import * as PlatformError from "effect/PlatformError";
2536 import * as Queue from "effect/Queue";
2637 import * as Schema from "effect/Schema";
38+import * as Sink from "effect/Sink";
2739 import * as Stream from "effect/Stream";
40+import * as Scope from "effect/Scope";
2841 import * as TestClock from "effect/testing/TestClock";
2942 import { ChildProcessSpawner } from "effect/unstable/process";
43+import * as EffectAcpErrors from "effect-acp/errors";
3044 import type * as EffectAcpProtocol from "effect-acp/protocol";
45+import type * as EffectAcpSchema from "effect-acp/schema";
3146
3247 import { ServerConfig } from "../../config.ts";
3348 import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
3449 import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
50+import {
51+ normalizeXAiAcpToolCallState,
52+ registerXAiBackgroundTaskTracking,
53+} from "../../provider/acp/XAiAcpExtension.ts";
3554 import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts";
3655 import {
3756 ProviderAdapterV2RuntimePolicy,
57+ type ProviderAdapterV2Event,
3858 type ProviderAdapterV2TurnInput,
3959 } from "../ProviderAdapter.ts";
60+import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts";
4061 import {
4162 AcpProviderCapabilitiesV2,
63+ acpCanonicalJson,
64+ acpClaimNativeTransportRequest,
65+ acpNativeUserInputRequestMatches,
66+ acpPostSettleContinuationOfferEvidence,
67+ acpPostSettleMonitorPromptShouldSuppress,
68+ acpPostSettleWakeEvidence,
69+ acpPostSettleWakeShouldBuffer,
70+ acpProjectedCommandExitCode,
4271 makeAcpAdapterV2,
72+ type AcpAdapterV2ExtensionContext,
4373 type AcpAdapterV2Flavor,
74+ type AcpAdapterV2RuntimeInput,
4475 } from "./AcpAdapterV2.ts";
4576
4677 const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
@@ -51,14 +82,202 @@ const testLayer = Layer.mergeAll(NodeServices.layer, idAllocatorLayer, serverCon · 4 unmodified lines
5182 const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test");
5283 const decodeUnknownJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
5384
85+describe("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+
103+const taskkillPlatformError = (method: string) =>
104+ PlatformError.systemError({ _tag: "Unknown", module: "taskkill-test", method });
105+
106+function 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+
144+function processExists(pid: number): boolean {
145+ try {
146+ process.kill(pid, 0);
147+ return true;
148+ } catch {
149+ return false;
150+ }
151+}
152+
153+const 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+
160+const 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+
167+function 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+
182+function 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+
230+const 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+
54249 function makeMockRuntime(input: {
55250 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
56251 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>>);
58255 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"];
59276 }): AcpAdapterV2Flavor["makeRuntime"] {
277+ let runtimeOrdinal = 0;
60278 return (runtimeInput) =>
61279 Effect.gen(function* () {
280+ runtimeOrdinal += 1;
62281 const protocolEvents = input.protocolEvents;
63282 const protocolLogging =
64283 protocolEvents === undefined
@@ -74,23 +293,66 @@ function makeMockRuntime(input: { · 9 unmodified lines
74293 const context = yield* Layer.build(
75294 AcpSessionRuntime.layer({
76295 ...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 }),
77317 protocolLogging,
78318 spawn: {
79319 command: process.execPath,
80320 args: [input.mockAgentPath],
81321 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+ },
83328 },
84329 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+ }),
85342 }).pipe(
86343 Layer.provide(
87344 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
88345 ),
89346 ),
90347 );
91− return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
348+ const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(
92349 Effect.provide(context),
93350 );
351+ const wrapped = {
352+ ...runtime,
353+ ...(input.wrapCancel === undefined ? {} : { cancel: input.wrapCancel(runtime.cancel) }),
354+ };
355+ return input.wrapRuntime?.(wrapped, runtimeOrdinal) ?? wrapped;
94356 });
95357 }
96358
@@ -123,6 +385,20 @@ function rawProtocolRequest( · 26 unmodified lines
123385 return undefined;
124386 }
125387
388+const 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+
126402 function makeTurnInput(input: {
127403 readonly threadId: ThreadId;
128404 readonly providerThread: OrchestrationV2ProviderThread;
@@ -181,6 +457,418 @@ function makeTurnInput(input: { · 52 unmodified lines
181457 }
182458
183459 describe("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+
184872 it.effect("negotiates and executes optional native session forks through the ACP runtime", () =>
185873 Effect.gen(function* () {
186874 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -244,16 +932,18 @@ describe("AcpAdapterV2", () => { · 57 unmodified lines
244932 sourceProviderThread,
245933 targetThreadId,
246934 });
247− const forkRequest = Option.getOrThrow(
935+ const forkRequestEvent = Option.getOrThrow(
248936 yield* Stream.fromQueue(protocolEvents).pipe(
249937 Stream.filter(
250938 (event) =>
251939 event.direction === "outgoing" && rawProtocolMethod(event) === "session/fork",
252940 ),
253− Stream.map(rawProtocolRequest),
254941 Stream.runHead,
255942 ),
256943 );
944+ const forkRequest = Option.getOrThrow(
945+ Option.fromNullishOr(rawProtocolRequest(forkRequestEvent)),
946+ );
257947
258948 assert.equal(sourceProviderThread.nativeThreadRef?.nativeId, "mock-session-1");
259949 assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, "mock-session-1-fork");
@@ -274,7 +964,7 @@ describe("AcpAdapterV2", () => { · 14 unmodified lines
274964 }).pipe(Effect.provide(testLayer), Effect.scoped),
275965 );
276966
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", () =>
278968 Effect.gen(function* () {
279969 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
280970 const fileSystem = yield* FileSystem.FileSystem;
@@ -284,44 +974,43 @@ describe("AcpAdapterV2", () => { · 3 unmodified lines
284974 const mockAgentPath = yield* path.fromFileUrl(
285975 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
286976 );
977+ const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
287978 const instanceId = ProviderInstanceId.make("acp-test");
288979 const adapter = makeAcpAdapterV2({
289980 instanceId,
290981 flavor: {
291982 driver: ACP_TEST_DRIVER,
292983 capabilities: AcpProviderCapabilitiesV2,
293− makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath }),
984+ makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }),
294985 },
295986 fileSystem,
296987 idAllocator,
297988 serverConfig,
298989 });
299− const threadId = ThreadId.make("thread-acp-unsupported-option");
990+ const threadId = ThreadId.make("thread-acp-idle-finalizer");
300991 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
301992 runtimeMode: "full-access",
302993 interactionMode: "default",
303994 cwd: process.cwd(),
304995 });
305− const error = yield* adapter
996+ const modelSelection = { instanceId, model: "default" } as const;
997+ const sessionScope = yield* Scope.make();
998+ yield* adapter
306999 .openSession({
3071000 threadId,
308− providerSessionId: ProviderSessionId.make("provider-session-acp-unsupported-option"),
309− modelSelection: {
310− instanceId,
311− model: "default",
312− options: [{ id: "missing-option", value: "high" }],
313− },
1001+ providerSessionId: ProviderSessionId.make("provider-session-acp-idle-finalizer"),
1002+ modelSelection,
3141003 runtimePolicy,
3151004 })
316− .pipe(Effect.flip);
317−
318− assert.equal(error._tag, "ProviderAdapterOpenSessionError");
319− assert.include(String(error.cause), "does not expose requested configuration option(s)");
320− assert.include(String(error.cause), "missing-option");
321− }).pipe(Effect.provide(testLayer), Effect.scoped),
1005+ .pipe(Effect.provideService(Scope.Scope, sessionScope));
1006+ yield* pollProtocolMethods(protocolEvents);
1007+ yield* Scope.close(sessionScope, Exit.void);
1008+ const finalizerMethods = yield* pollProtocolMethods(protocolEvents);
1009+ assert.equal(finalizerMethods.filter((method) => method === "session/close").length, 1);
1010+ }).pipe(Effect.provide(testLayer)),
3221011 );
3231012
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", () =>
3251014 Effect.gen(function* () {
3261015 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
3271016 const fileSystem = yield* FileSystem.FileSystem;
@@ -331,20 +1020,67 @@ describe("AcpAdapterV2", () => { · 3 unmodified lines
3311020 const mockAgentPath = yield* path.fromFileUrl(
3321021 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
3331022 );
334− const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
3351023 const instanceId = ProviderInstanceId.make("acp-test");
3361024 const adapter = makeAcpAdapterV2({
3371025 instanceId,
3381026 flavor: {
3391027 driver: ACP_TEST_DRIVER,
3401028 capabilities: AcpProviderCapabilitiesV2,
341− makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }),
1029+ makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath }),
3421030 },
3431031 fileSystem,
3441032 idAllocator,
3451033 serverConfig,
3461034 });
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");
3481084 const secondThreadId = ThreadId.make("thread-acp-active-setup:second");
3491085 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
3501086 runtimeMode: "full-access",
@@ -424,6 +1160,149 @@ describe("AcpAdapterV2", () => { · 73 unmodified lines
4241160 }).pipe(Effect.provide(testLayer), Effect.scoped),
4251161 );
4261162
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+
4271306 it.effect("cancels pending permission requests while interrupting an ACP turn", () =>
4281307 Effect.gen(function* () {
4291308 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -434,6 +1313,7 @@ describe("AcpAdapterV2", () => { · 4 unmodified lines
4341313 const mockAgentPath = yield* path.fromFileUrl(
4351314 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
4361315 );
1316+ const releaseCancel = yield* Deferred.make<void>();
4371317 const instanceId = ProviderInstanceId.make("acp-test");
4381318 const adapter = makeAcpAdapterV2({
4391319 instanceId,
@@ -444,6 +1324,7 @@ describe("AcpAdapterV2", () => { · 4 unmodified lines
4441324 childProcessSpawner,
4451325 mockAgentPath,
4461326 environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
1327+ wrapCancel: (cancel) => Deferred.await(releaseCancel).pipe(Effect.andThen(cancel)),
4471328 }),
4481329 },
4491330 fileSystem,
@@ -489,10 +1370,12 @@ describe("AcpAdapterV2", () => { · 39 unmodified lines
4891370 return yield* Effect.die("Expected a pending ACP permission request with a provider turn");
4901371 }
4911372
492− yield* runtime.interruptTurn({
493− providerThread,
494− providerTurnId: pendingRequest.runtimeRequest.providerTurnId,
495− });
1373+ const interruptFiber = yield* runtime
1374+ .interruptTurn({
1375+ providerThread,
1376+ providerTurnId: pendingRequest.runtimeRequest.providerTurnId,
1377+ })
1378+ .pipe(Effect.forkScoped);
4961379
4971380 const cancelledRequest = Option.getOrThrow(
4981381 yield* runtime.events.pipe(
@@ -506,10 +1389,19 @@ describe("AcpAdapterV2", () => { · 7 unmodified lines
5061389 ),
5071390 );
5081391 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");
5091401 }).pipe(Effect.provide(testLayer), Effect.scoped),
5101402 );
5111403
512− it.effect("releases an ACP turn when cancellation times out", () =>
1404+ it.live("keeps hard teardown excluded until a permission response is enqueued", () =>
5131405 Effect.gen(function* () {
5141406 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
5151407 const fileSystem = yield* FileSystem.FileSystem;
@@ -519,8 +1411,9 @@ describe("AcpAdapterV2", () => { · 3 unmodified lines
5191411 const mockAgentPath = yield* path.fromFileUrl(
5201412 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
5211413 );
1414+ const responseEnqueued = yield* Deferred.make<void>();
1415+ const releaseResponseAcknowledgement = yield* Deferred.make<void>();
5221416 const instanceId = ProviderInstanceId.make("acp-test");
523− const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256);
5241417 const adapter = makeAcpAdapterV2({
5251418 instanceId,
5261419 flavor: {
@@ -529,24 +1422,28 @@ describe("AcpAdapterV2", () => { · 2 unmodified lines
5291422 makeRuntime: makeMockRuntime({
5301423 childProcessSpawner,
5311424 mockAgentPath,
532− environment: { T3_ACP_PROMPT_DELAY_MS: "5000" },
533− protocolEvents,
1425+ environment: { T3_ACP_EMIT_TOOL_CALLS: "1" },
1426+ wrapOutgoingResponse: (onOutgoingResponse) => (requestId) =>
1427+ Deferred.succeed(responseEnqueued, undefined).pipe(
1428+ Effect.andThen(Deferred.await(releaseResponseAcknowledgement)),
1429+ Effect.andThen(onOutgoingResponse(requestId)),
1430+ ),
5341431 }),
5351432 },
5361433 fileSystem,
5371434 idAllocator,
5381435 serverConfig,
5391436 });
540− const threadId = ThreadId.make("thread-acp-cancel-timeout");
1437+ const threadId = ThreadId.make("thread-acp-response-wins-permission");
5411438 const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
542− runtimeMode: "full-access",
1439+ runtimeMode: "approval-required",
5431440 interactionMode: "default",
5441441 cwd: process.cwd(),
5451442 });
5461443 const modelSelection = { instanceId, model: "default" } as const;
5471444 const runtime = yield* adapter.openSession({
5481445 threadId,
549− providerSessionId: ProviderSessionId.make("provider-session-acp-cancel-timeout"),
1446+ providerSessionId: ProviderSessionId.make("provider-session-acp-response-wins-permission"),
5501447 modelSelection,
5511448 runtimePolicy,
5521449 });
@@ -555,57 +1452,5168 @@ describe("AcpAdapterV2", () => { · 2 unmodified lines
5551452 modelSelection,
5561453 runtimePolicy,
5571454 });
558− const now = yield* DateTime.now;
559− const firstTurn = makeTurnInput({
560− threadId,
561− providerThread,
562− instanceId,
563− runtimePolicy,
564− now,
565− });
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,
1455+ yield* runtime.startTurn(
1456+ makeTurnInput({
1457+ threadId,
1458+ providerThread,
1459+ instanceId,
1460+ runtimePolicy,
1461+ now: yield* DateTime.now,
1462+ }),
5731463 );
574− const providerTurnId = idAllocator.derive.providerTurn({
575− driver: ACP_TEST_DRIVER,
576− nativeTurnId: "mock-session-1:turn:1",
577− });
578− const interruptFiber = yield* runtime
579− .interruptTurn({ providerThread, providerTurnId })
580− .pipe(Effect.flip, Effect.forkScoped);
581− yield* Stream.fromQueue(protocolEvents).pipe(
582− Stream.filter(
583− (event) =>
584− event.direction === "outgoing" && rawProtocolMethod(event) === "session/cancel",
1464+ const pending = Option.getOrThrow(
1465+ yield* runtime.events.pipe(
1466+ Stream.filter(
1467+ (event) =>
1468+ event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
1469+ ),
1470+ Stream.runHead,
5851471 ),
586− Stream.runHead,
5871472 );
588− yield* TestClock.adjust("10 seconds");
589− const interruptError = yield* Fiber.join(interruptFiber);
590− assert.equal(interruptError._tag, "ProviderAdapterInterruptError");
1473+ if (
1474+ pending.type !== "runtime_request.updated" ||
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+ );
5911499
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+ });
5921566 yield* runtime.startTurn(
5931567 makeTurnInput({
5941568 threadId,
5951569 providerThread,
5961570 instanceId,
5971571 runtimePolicy,
598− now,
599− ordinal: 2,
1572+ now: yield* DateTime.now,
6001573 }),
6011574 );
602− yield* Stream.fromQueue(protocolEvents).pipe(
603− Stream.filter(
604− (event) =>
605− event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
1575+ const pending = Option.getOrThrow(
1576+ yield* runtime.events.pipe(
1577+ Stream.filter(
1578+ (event) =>
1579+ event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending",
1580+ ),
1581+ Stream.runHead,
6061582 ),
607− Stream.runHead,
6081583 );
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);
6091598 }).pipe(Effect.provide(testLayer), Effect.scoped),
6101599 );
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
1818 type ProviderInstanceId,
1919 type ProviderDriverKind,
2020 type ProviderRequestKind,
21+ type ProviderThreadId,
2122 type ProviderUserInputAnswers,
2223 type RuntimeRequestId,
2324 type ThreadId,
@@ -27,11 +28,14 @@ import * as Cause from "effect/Cause"; · 3 unmodified lines
2728 import * as DateTime from "effect/DateTime";
2829 import * as Deferred from "effect/Deferred";
2930 import * as Effect from "effect/Effect";
31+import * as Exit from "effect/Exit";
3032 import * as FileSystem from "effect/FileSystem";
33+import * as Fiber from "effect/Fiber";
3134 import * as Option from "effect/Option";
3235 import * as Queue from "effect/Queue";
3336 import * as Ref from "effect/Ref";
34−import type * as Scope from "effect/Scope";
37+import * as Scope from "effect/Scope";
38+import * as Semaphore from "effect/Semaphore";
3539 import * as Stream from "effect/Stream";
3640 import * as EffectAcpErrors from "effect-acp/errors";
3741 import type * as EffectAcpSchema from "effect-acp/schema";
@@ -52,6 +56,7 @@ import type { · 14 unmodified lines
5256 } from "../../provider/acp/AcpSessionRuntime.ts";
5357 import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
5458 import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts";
59+import { type ProviderContinuationRequest } from "../ProviderContinuationRequests.ts";
5560 import { makeProviderFailure } from "../ProviderFailure.ts";
5661 import { acpSelectionTransition } from "../ProviderSelectionTransition.ts";
5762 import {
@@ -87,10 +92,15 @@ export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; · 29 unmodified lines
8792 export interface AcpAdapterV2RuntimeInput {
8893 readonly cwd: string;
8994 readonly mcpServers: ReadonlyArray<EffectAcpSchema.McpServer>;
95+ readonly interruptPromptOnCancel?: boolean;
9096 readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"];
9197 readonly clientInfo: AcpSessionRuntimeOptions["clientInfo"];
9298 readonly requestLogger?: NonNullable<AcpSessionRuntimeOptions["requestLogger"]>;
9399 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"];
94104 }
95105
96106 export type AcpAdapterV2NativeLogging = Pick<
@@ -100,15 +110,79 @@ export type AcpAdapterV2NativeLogging = Pick< · 3 unmodified lines
100110
101111 export interface AcpAdapterV2UserInputRequest {
102112 readonly nativeItemId: string;
113+ readonly nativeMethod?: string;
103114 readonly nativeRequestId: string;
115+ readonly nativeSessionId?: string;
104116 readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>;
105117 }
106118
107119 export interface AcpAdapterV2ExtensionContext {
108120 readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"];
109− readonly requestUserInput: (
110− input: AcpAdapterV2UserInputRequest,
111− ) => Effect.Effect<ProviderUserInputAnswers | null, EffectAcpErrors.AcpError>;
121+ /**
122+ * Session-scoped background-task lifecycle reported via extension
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+
140+export 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+ */
156+export const acpRootTurnSettleDebounceMs = 2_000;
157+
158+/** Let trailing root session chunks land before terminalizing a settled turn. */
159+export 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+ */
169+export 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. */
181+export function acpRootTurnShouldRearmRecoveryTimers(context: {
182+ readonly finalized: boolean;
183+ readonly interrupted: boolean;
184+}): boolean {
185+ return !context.finalized && !context.interrupted;
112186 }
113187
114188 export interface AcpAdapterV2Flavor {
@@ -128,7 +202,117 @@ export interface AcpAdapterV2Flavor { · 13 unmodified lines
128202 readonly extractSubagentUpdate?: (
129203 toolCall: AcpToolCallState,
130204 ) => 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;
131257 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. */
311+export function acpSupportsImagePrompts(input: {
312+ readonly flavorSupportsImagePrompts?: boolean | undefined;
313+ readonly negotiatedImage?: boolean | undefined;
314+}): boolean {
315+ return input.flavorSupportsImagePrompts === true || input.negotiatedImage === true;
132316 }
133317
134318 export interface AcpAdapterV2SubagentUpdate {
@@ -136,9 +320,14 @@ export interface AcpAdapterV2SubagentUpdate { · 1 unmodified line
136320 readonly prompt: string;
137321 readonly title: string | null;
138322 readonly model: string | null;
139− readonly status: "running" | "completed" | "failed";
323+ readonly status: "running" | "completed" | "failed" | "interrupted" | "cancelled";
140324 readonly childSessionId: string | null;
141325 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;
142331 }
143332
144333 export interface AcpAdapterV2Options {
@@ -148,6 +337,36 @@ export interface AcpAdapterV2Options { · 3 unmodified lines
148337 readonly idAllocator: IdAllocatorV2Shape;
149338 readonly serverConfig: ServerConfig["Service"];
150339 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+ };
151370 }
152371
153372 export const AcpProviderCapabilitiesV2 = {
@@ -278,7 +497,8 @@ function negotiatedCapabilities( · 124 unmodified lines
278497 };
279498 }
280499
281−function acpMcpServers(threadId: ThreadId): ReadonlyArray<EffectAcpSchema.McpServer> {
500+function acpMcpServers(threadId: ThreadId | null): ReadonlyArray<EffectAcpSchema.McpServer> {
501+ if (threadId === null) return [];
282502 const session = McpProviderSession.readMcpProviderSession(threadId);
283503 if (session === undefined) {
284504 return [];
@@ -352,14 +572,114 @@ function unknownRecord(value: unknown): Record<string, unknown> | undefined { · 67 unmodified lines
352572 : undefined;
353573 }
354574
575+export 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+
589+export 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+
626+export 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+
355657 function nonEmptyText(value: unknown, fallback: string): string {
356658 return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
357659 }
358660
661+function 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+
359675 function textFromUnknown(value: unknown): string | undefined {
360676 if (typeof value === "string") {
361677 return value;
362678 }
679+ const fromBytes = decodeByteText(value);
680+ if (fromBytes !== undefined) {
681+ return fromBytes;
682+ }
363683 if (Array.isArray(value)) {
364684 const parts = value.flatMap((entry) => {
365685 const text = textFromUnknown(entry);
@@ -371,12 +691,33 @@ function textFromUnknown(value: unknown): string | undefined { · 5 unmodified lines
371691 if (record === undefined) {
372692 return undefined;
373693 }
374− for (const key of ["stdout", "stderr", "output", "content", "text", "message"]) {
375− const text = textFromUnknown(record[key]);
694+ // Prefer prompt-facing Grok fields before nested envelopes.
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);
376713 if (text !== undefined && text.length > 0) {
377714 return text;
378715 }
379716 }
717+ const result = unknownRecord(record.Result) ?? unknownRecord(record.result);
718+ if (result !== undefined) {
719+ return textFromUnknown(result);
720+ }
380721 return undefined;
381722 }
382723
@@ -391,6 +732,21 @@ function commandExitCode(value: unknown): number | undefined { · 8 unmodified lines
391732 return undefined;
392733 }
393734
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+ */
740+export 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+
394750 function pathFromToolCall(toolCall: AcpToolCallState): string | undefined {
395751 const locations = toolCall.data.locations;
396752 if (Array.isArray(locations)) {
@@ -443,15 +799,14 @@ function toolStatus( · 46 unmodified lines
443799 }
444800 }
445801
446−function nodeStatus(status: ReturnType<typeof toolStatus>): OrchestrationV2ExecutionNode["status"] {
802+type ProjectedToolStatus = ReturnType<typeof toolStatus> | "interrupted";
803+
804+function nodeStatus(status: ProjectedToolStatus): OrchestrationV2ExecutionNode["status"] {
447805 return status === "pending" ? "running" : status;
448806 }
449807
450−function completedAtForStatus(
451− status: ReturnType<typeof toolStatus>,
452− now: DateTime.Utc,
453−): DateTime.Utc | null {
454− return status === "completed" || status === "failed" ? now : null;
808+function completedAtForStatus(status: ProjectedToolStatus, now: DateTime.Utc): DateTime.Utc | null {
809+ return status === "completed" || status === "failed" || status === "interrupted" ? now : null;
455810 }
456811
457812 function selectPermissionOptionId(
@@ -558,12 +913,175 @@ interface ActiveAcpTurn { · 100 unmodified lines
558913 readonly subagents: Map<string, ActiveAcpSubagent>;
559914 readonly subagentsBySessionId: Map<string, ActiveAcpSubagent>;
560915 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>;
561936 plan: {
562937 readonly id: OrchestrationV2PlanArtifact["id"];
563938 readonly startedAt: DateTime.Utc;
564939 } | null;
565940 interrupted: boolean;
566941 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+
955+type 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+
963+export 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. */
978+export 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+ */
1003+export 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+ */
1030+export 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+
1055+export 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+
1066+export 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+
1077+export 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;
5671085 }
5681086
5691087 interface ActiveAcpSubagent {
@@ -572,12 +1090,29 @@ interface ActiveAcpSubagent { · 2 unmodified lines
5721090 readonly childRootNodeId: OrchestrationV2ExecutionNode["id"];
5731091 readonly turnItemId: OrchestrationV2TurnItem["id"];
5741092 readonly turnItemOrdinal: number;
1093+ /** Turn that spawned the subagent; carryover updates keep this lineage. */
1094+ readonly providerTurnId: OrchestrationV2ProviderTurn["id"];
1095+ readonly parentProviderThreadId: ProviderThreadId;
5751096 childSessionId: string | null;
5761097 assistantText: string;
5771098 nextChildOrdinal: number;
5781099 }
5791100
1101+function 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+
5801112 type PendingRuntimeRequest = {
1113+ readonly generation: number;
1114+ readonly nativeResponseAcknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>;
1115+ readonly transportRequestId: string;
5811116 readonly requestId: RuntimeRequestId;
5821117 readonly runtimeRequest: OrchestrationV2RuntimeRequest;
5831118 readonly node: OrchestrationV2ExecutionNode;
@@ -603,6 +1138,9 @@ interface SnapshotMessageState { · 19 unmodified lines
6031138 export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV2Shape {
6041139 const { flavor, fileSystem, idAllocator, serverConfig } = options;
6051140 const driver = flavor.driver;
1141+ const continuationRequests = options.continuationRequests;
1142+ const postSettleContinuationEnabled =
1143+ flavor.enablePostSettleContinuation === true && continuationRequests !== undefined;
6061144
6071145 return ProviderAdapterV2.of({
6081146 instanceId: options.instanceId,
@@ -617,7 +1155,33 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 8 unmodified lines
6171155 const activeSessionId = yield* Ref.make<string | null>(null);
6181156 const activeSessionSetup = yield* Ref.make<AcpSessionRuntimeStartResult | null>(null);
6191157 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+ );
6201182 const pendingRuntimeRequests = yield* Ref.make(new Map<string, PendingRuntimeRequest>());
1183+ const emitNativeResponseLifecycle =
1184+ options.testHooks?.onNativeResponseLifecycle ?? (() => Effect.void);
6211185 const nextElicitationOrdinal = yield* Ref.make(0);
6221186 const itemOrdinals = yield* Ref.make(new Map<string, number>());
6231187 const nextItemOrdinalsByTurn = yield* Ref.make(new Map<string, number>());
@@ -629,20 +1193,434 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines
6291193 loadingIndex: 0,
6301194 });
6311195
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+
6321529 const emitProviderEvent = (event: ProviderAdapterV2Event) =>
6331530 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;
6341535
6351536 const nativeLogging = options.nativeLogging?.(input.threadId);
636−
637− const runtime = yield* flavor.makeRuntime({
1537+ const makeRuntimeInput = (runtimeGeneration: number): AcpAdapterV2RuntimeInput => ({
6381538 cwd: input.runtimePolicy.cwd ?? process.cwd(),
6391539 mcpServers: acpMcpServers(input.threadId),
1540+ interruptPromptOnCancel: flavor.interruptPromptOnCancel ?? false,
6401541 clientCapabilities: {
6411542 fs: { readTextFile: false, writeTextFile: false },
6421543 terminal: false,
6431544 elicitation: { form: {} },
6441545 },
6451546 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),
6461624 ...(nativeLogging?.requestLogger === undefined
6471625 ? {}
6481626 : { requestLogger: nativeLogging.requestLogger }),
@@ -652,6 +1630,13 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines
6521630 logger: () => Effect.void,
6531631 },
6541632 });
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+ );
6551640
6561641 const resolveItemOrdinal = Effect.fnUntraced(function* (
6571642 context: ActiveAcpTurn,
@@ -811,6 +1796,9 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 153 unmodified lines
8111796 if (stream.current === null) return;
8121797 yield* emitTextSegment(context, kind, true);
8131798 stream.current = null;
1799+ if (kind === "assistant") {
1800+ yield* scheduleSettleRootTurnWhenIdle(context);
1801+ }
8141802 });
8151803
8161804 const closeTextStreams = Effect.fnUntraced(function* (context: ActiveAcpTurn) {
@@ -883,39 +1871,56 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 66 unmodified lines
8831871 context: ActiveAcpTurn,
8841872 update: AcpAdapterV2SubagentUpdate,
8851873 ) {
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+ }
8871891 const now = yield* DateTime.now;
1892+ const nativeTaskId = existing?.task.nativeTaskRef?.nativeId ?? update.nativeTaskId;
8881893 const nativeItemRef = {
8891894 driver,
890− nativeId: update.nativeTaskId,
1895+ nativeId: nativeTaskId,
8911896 strength: "strong" as const,
8921897 };
8931898 const nodeId =
8941899 existing?.task.id ??
8951900 idAllocator.derive.nodeFromProviderItem({
8961901 driver,
897− nativeItemId: update.nativeTaskId,
1902+ nativeItemId: nativeTaskId,
8981903 });
8991904 const childThreadId =
9001905 existing?.childThreadId ??
9011906 idAllocator.derive.threadFromProviderThread({
9021907 driver,
903− nativeThreadId: `${nativeThreadId(driver, context.input.providerThread)}:task:${update.nativeTaskId}`,
1908+ nativeThreadId: `${nativeThreadId(driver, context.input.providerThread)}:task:${nativeTaskId}`,
9041909 });
9051910 const childRootNodeId =
9061911 existing?.childRootNodeId ??
9071912 idAllocator.derive.nodeFromProviderItem({
9081913 driver,
909− nativeItemId: `${update.nativeTaskId}:child-root`,
1914+ nativeItemId: `${nativeTaskId}:child-root`,
9101915 });
9111916 const turnItemId =
9121917 existing?.turnItemId ??
9131918 idAllocator.derive.turnItemFromProviderItem({
9141919 driver,
915− nativeItemId: update.nativeTaskId,
1920+ nativeItemId: nativeTaskId,
9161921 });
9171922 const turnItemOrdinal =
918− existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(context, update.nativeTaskId));
1923+ existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(context, nativeTaskId));
9191924 const taskStatus = update.status;
9201925 const task: OrchestrationV2Subagent = {
9211926 ...(existing?.task ?? {
@@ -947,12 +1952,14 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 25 unmodified lines
9471952 childRootNodeId,
9481953 turnItemId,
9491954 turnItemOrdinal,
1955+ providerTurnId: context.providerTurnId,
1956+ parentProviderThreadId: context.input.providerThread.id,
9501957 childSessionId: null,
9511958 assistantText: "",
9521959 nextChildOrdinal: 101,
9531960 };
9541961 subagent.task = task;
955− context.subagents.set(update.nativeTaskId, subagent);
1962+ context.subagents.set(nativeTaskId, subagent);
9561963
9571964 if (existing === undefined) {
9581965 yield* emitProviderEvent({
@@ -979,7 +1986,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 20 unmodified lines
9791986 creationSource: "provider",
9801987 }),
9811988 });
982− const promptNativeItemId = `${update.nativeTaskId}:prompt`;
1989+ const promptNativeItemId = `${nativeTaskId}:prompt`;
9831990 const promptArtifacts = makeSubagentConversationArtifacts({
9841991 messageId: idAllocator.derive.messageFromProviderItem({
9851992 driver,
@@ -1064,14 +2071,14 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 78 unmodified lines
10642071 node: {
10652072 id: nodeId,
10662073 threadId: context.input.threadId,
1067− runId: context.input.runId,
1068− parentNodeId: context.input.rootNodeId,
1069− rootNodeId: context.input.rootNodeId,
2074+ runId: subagent.task.runId,
2075+ parentNodeId: subagent.task.parentNodeId,
2076+ rootNodeId: subagent.task.parentNodeId,
10702077 kind: "subagent",
10712078 status: taskStatus,
10722079 countsForRun: false,
10732080 providerThreadId: context.input.providerThread.id,
1074− providerTurnId: context.providerTurnId,
2081+ providerTurnId: subagent.providerTurnId,
10752082 nativeItemRef,
10762083 runtimeRequestId: null,
10772084 checkpointScopeId: null,
@@ -1107,10 +2114,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 29 unmodified lines
11072114 turnItem: {
11082115 id: turnItemId,
11092116 threadId: context.input.threadId,
1110− runId: context.input.runId,
2117+ runId: subagent.task.runId,
11112118 nodeId,
11122119 providerThreadId: context.input.providerThread.id,
1113− providerTurnId: context.providerTurnId,
2120+ providerTurnId: subagent.providerTurnId,
11142121 nativeItemRef,
11152122 parentItemId: null,
11162123 ordinal: turnItemOrdinal,
@@ -1131,23 +2138,231 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 14 unmodified lines
11312138 });
11322139 });
11332140
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* (
11352251 context: ActiveAcpTurn,
11362252 incoming: AcpToolCallState,
2253+ projectedStatus?: ProjectedToolStatus,
11372254 ) {
11382255 yield* closeTextStreams(context);
11392256 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;
11412259 context.tools.set(toolCall.toolCallId, toolCall);
1142− const subagentUpdate = flavor.extractSubagentUpdate?.(toolCall);
1143− if (subagentUpdate !== undefined) {
1144− yield* emitSubagent(context, subagentUpdate);
1145− return;
2260+ const backgroundTaskId = flavor.extractBackgroundTaskId?.(toolCall);
2261+ if (backgroundTaskId !== undefined) {
2262+ context.toolCallIdsByBackgroundTaskId.set(backgroundTaskId, toolCall.toolCallId);
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+ }
11462291 }
1147− const status = toolStatus(toolCall.status);
1148− const now = yield* DateTime.now;
1149− const nativeItemId = `${nativeThreadId(driver, context.input.providerThread)}:tool:${toolCall.toolCallId}`;
1150− const ordinal = yield* resolveItemOrdinal(context, nativeItemId);
2292+
2293+ // get_command TaskOutput for registered monitor(s): hydrate those tools.
2294+ const backgroundCompletions =
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);
11512366 const nodeId = idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId });
11522367 const turnItemId = idAllocator.derive.turnItemFromProviderItem({
11532368 driver,
@@ -1203,6 +2418,30 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 49 unmodified lines
12032418 const rawInput = toolCall.data.rawInput;
12042419 const rawOutput = toolCall.data.rawOutput ?? toolCall.data.content;
12052420 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;
12062445 let turnItem: OrchestrationV2TurnItem;
12072446 switch (toolCall.kind) {
12082447 case "read":
@@ -1225,19 +2464,19 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 16 unmodified lines
12252464 }),
12262465 };
12272466 break;
1228− case "execute":
2467+ case "execute": {
2468+ const exitCode = acpProjectedCommandExitCode(status, rawOutput);
12292469 turnItem = {
12302470 ...base,
12312471 type: "command_execution",
1232− input: toolCall.command ?? toolCall.title ?? "Command",
2472+ input: toolCall.command ?? monitorCommand ?? toolCall.title ?? "Command",
12332473 ...(textFromUnknown(rawOutput) === undefined
12342474 ? {}
12352475 : { output: textFromUnknown(rawOutput) }),
1236− ...(commandExitCode(rawOutput) === undefined
1237− ? {}
1238− : { exitCode: commandExitCode(rawOutput) }),
2476+ ...(exitCode === undefined ? {} : { exitCode }),
12392477 };
12402478 break;
2479+ }
12412480 case "edit":
12422481 case "delete":
12432482 case "move":
@@ -1270,15 +2509,33 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 26 unmodified lines
12702509 };
12712510 break;
12722511 default:
1273− turnItem = {
1274− ...base,
1275− type: "dynamic_tool",
1276− toolName: toolCall.title ?? toolCall.kind ?? null,
1277− input: rawInput ?? {},
1278− ...(rawOutput === undefined ? {} : { output: rawOutput }),
1279− };
2512+ if (projectAsCommandExecution) {
2513+ const exitCode = acpProjectedCommandExitCode(status, rawOutput);
2514+ turnItem = {
2515+ ...base,
2516+ type: "command_execution",
2517+ input:
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+ }
12802536 }
12812537 yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem });
2538+ yield* rearmDeferredFinalize(context);
12822539 });
12832540
12842541 const emitPlan = Effect.fnUntraced(function* (
@@ -1417,22 +2674,287 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 132 unmodified lines
14172674 });
14182675 });
14192676
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+
14202887 const handleSessionUpdate = Effect.fnUntraced(function* (
14212888 notification: EffectAcpSchema.SessionNotification,
14222889 ) {
14232890 const context = yield* Ref.get(activeTurn);
14242891 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+ }
14252925 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+ }
14262936 if (
14272937 (update.sessionUpdate === "user_message_chunk" ||
14282938 update.sessionUpdate === "agent_message_chunk") &&
14292939 update.content.type === "text"
14302940 ) {
1431− yield* appendLoadedHistory(
1432− notification,
1433− update.sessionUpdate === "user_message_chunk" ? "user" : "assistant",
1434− update.content.text,
1435− );
2941+ // Late monitor end/event reminders must not become ghost user/assistant
2942+ // history (or OS-facing chatter) after the root turn already finalized.
2943+ const text = update.content.text;
2944+ const lateBackgroundMutations = flavor.extractBackgroundToolMutation?.(text) ?? [];
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+ }
14362958 } else if (
14372959 update.sessionUpdate === "tool_call" ||
14382960 update.sessionUpdate === "tool_call_update" ||
@@ -1444,6 +2966,8 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines
14442966 }
14452967 if (context.finalized) return;
14462968 if (notification.sessionId !== (yield* Ref.get(activeSessionId))) {
2969+ // Finalize may have completed during the activeSessionId yield.
2970+ if (context.finalized) return;
14472971 if (flavor.extractSubagentUpdate === undefined) return;
14482972 if (update.sessionUpdate !== "agent_message_chunk" || update.content.type !== "text") {
14492973 return;
@@ -1458,17 +2982,90 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 8 unmodified lines
14582982 context.pendingSubagentNotifications.set(notification.sessionId, buffered);
14592983 return;
14602984 }
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;
14612988 switch (update.sessionUpdate) {
14622989 case "agent_message_chunk":
14632990 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+ }
14642996 yield* appendText(context, "assistant", update.content.text);
14652997 }
1466− return;
2998+ break;
14672999 case "agent_thought_chunk":
14683000 if (update.content.type === "text") {
14693001 yield* appendText(context, "reasoning", update.content.text);
14703002 }
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;
14723069 default: {
14733070 const parsed = parseSessionUpdateEvent(notification);
14743071 for (const event of parsed.events) {
@@ -1480,20 +3077,13 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines
14803077 }
14813078 }
14823079 }
3080+ if (acpRootSessionUpdateIngestsOutput(notification)) {
3081+ yield* scheduleSettleRootTurnWhenIdle(context);
3082+ }
3083+ // Keep deferred finalize quiet-window fresh while wake traffic lands.
3084+ yield* rearmDeferredFinalize(context);
14833085 });
14843086
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−
14973087 const activeContext = Effect.gen(function* () {
14983088 const context = yield* Ref.get(activeTurn);
14993089 if (context === null) {
@@ -1505,9 +3095,11 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines
15053095 return context;
15063096 });
15073097
1508− const emitApprovalRequest = Effect.fnUntraced(function* (
3098+ const beginApprovalRequest = Effect.fnUntraced(function* (
15093099 context: ActiveAcpTurn,
15103100 params: EffectAcpSchema.RequestPermissionRequest,
3101+ generation: number,
3102+ transportRequestId: string,
15113103 ) {
15123104 yield* closeTextStreams(context);
15133105 const parsed = parsePermissionRequest(params);
@@ -1518,6 +3110,15 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 4 unmodified lines
15183110 nativeRequestId,
15193111 });
15203112 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+ );
15213122 const now = yield* DateTime.now;
15223123 const nodeId = idAllocator.derive.approvalNode({ requestId });
15233124 const requestKind = providerRequestKind(parsed.kind);
@@ -1581,7 +3182,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 57 unmodified lines
15813182 const updated = new Map(current);
15823183 updated.set(String(requestId), {
15833184 type: "approval",
3185+ generation,
3186+ nativeResponseAcknowledgement,
15843187 requestId,
3188+ transportRequestId,
15853189 decision,
15863190 runtimeRequest,
15873191 node,
@@ -1605,55 +3209,19 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 17 unmodified lines
16053209 driver,
16063210 turnItem,
16073211 });
1608− const resolved = yield* Deferred.await(decision).pipe(
1609− Effect.ensuring(
1610− Ref.update(pendingRuntimeRequests, (current) => {
1611− const updated = new Map(current);
1612− updated.delete(String(requestId));
1613− return updated;
1614− }),
1615− ),
1616− );
1617− return resolved;
3212+ return {
3213+ context,
3214+ decision,
3215+ nativeResponseAcknowledgement,
3216+ requestId,
3217+ transportRequestId,
3218+ } as const;
16183219 });
16193220
1620− yield* runtime.handleRequestPermission((params) =>
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* (
3221+ const beginUserInputRequest = Effect.fnUntraced(function* (
16563222 request: AcpAdapterV2UserInputRequest,
3223+ generation: number,
3224+ transportRequestId: string,
16573225 ) {
16583226 const context = yield* activeContext;
16593227 yield* closeTextStreams(context);
@@ -1663,6 +3231,15 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines
16633231 nativeRequestId: request.nativeRequestId,
16643232 });
16653233 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+ );
16663243 const now = yield* DateTime.now;
16673244 const nodeId = idAllocator.derive.nodeFromProviderItem({
16683245 driver,
@@ -1736,7 +3313,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 67 unmodified lines
17363313 const updated = new Map(current);
17373314 updated.set(String(requestId), {
17383315 type: "user_input",
3316+ generation,
3317+ nativeResponseAcknowledgement,
17393318 requestId,
3319+ transportRequestId,
17403320 answers,
17413321 runtimeRequest,
17423322 node,
@@ -1760,19 +3340,62 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 17 unmodified lines
17603340 driver,
17613341 turnItem,
17623342 });
1763− return yield* Deferred.await(answers).pipe(
1764− Effect.ensuring(
1765− Ref.update(pendingRuntimeRequests, (current) => {
1766− const updated = new Map(current);
1767− updated.delete(String(requestId));
1768− return updated;
1769− }),
1770− ),
1771− );
3343+ return {
3344+ answers,
3345+ context,
3346+ nativeResponseAcknowledgement,
3347+ requestId,
3348+ transportRequestId,
3349+ } as const;
17723350 });
17733351
1774− const requestUserInput = (request: AcpAdapterV2UserInputRequest) =>
1775− requestUserInputInternal(request).pipe(
3352+ const requestUserInputWithAdmission = (
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+ }),
17763399 Effect.mapError(
17773400 (cause) =>
17783401 new EffectAcpErrors.AcpTransportError({
@@ -1783,7 +3406,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 4 unmodified lines
17833406 );
17843407
17853408 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+ ]);
17873413 if (requests.length === 0) return;
17883414
17893415 const now = yield* DateTime.now;
@@ -1830,61 +3456,423 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 40 unmodified lines
18303456 );
18313457 });
18323458
1833− yield* runtime.handleElicitation((params) =>
1834− Effect.gen(function* () {
1835− if (params.mode === "url") {
1836− return { action: { action: "decline" } } as const;
3459+ /**
3460+ * Direct Stop after a soft steer clears carryover without an active turn.
3461+ * Emit the same interrupted terminal events terminalizeOpenRunOwnedItems
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;
18373475 }
1838− const questions = Object.entries(params.requestedSchema.properties ?? {}).map(
1839− ([id, property], index): OrchestrationV2UserInputQuestion => {
1840− const record = unknownRecord(property);
1841− const enumValues = Array.isArray(record?.enum)
1842− ? record.enum.filter((value): value is string => typeof value === "string")
1843− : [];
1844− const options =
1845− enumValues.length > 0
1846− ? enumValues.map((value) => ({ label: value, description: value }))
1847− : record?.type === "boolean"
1848− ? [
1849− { label: "true", description: "Yes" },
1850− { label: "false", description: "No" },
1851− ]
1852− : [];
1853− return {
1854− id,
1855− header: nonEmptyText(record?.title, `Question ${index + 1}`),
1856− question: nonEmptyText(record?.description, params.message),
1857− options,
1858− };
3476+ const nativeTaskId = subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id;
3477+ const nativeItemRef = {
3478+ driver,
3479+ nativeId: nativeTaskId,
3480+ strength: "strong" as const,
3481+ };
3482+ const parentProviderThreadId = subagent.parentProviderThreadId;
3483+ const result = subagent.assistantText || subagent.task.result;
3484+ subagent.task = {
3485+ ...subagent.task,
3486+ status: "interrupted",
3487+ result,
3488+ completedAt: now,
3489+ updatedAt: now,
3490+ };
3491+ yield* emitProviderEvent({
3492+ type: "node.updated",
3493+ driver,
3494+ node: {
3495+ id: subagent.task.id,
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,
18593564 },
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,
18703565 });
1871− return answers === null
1872− ? ({ action: { action: "cancel" } } as const)
1873− : ({
1874− action: {
1875− action: "accept",
1876− content: elicitationContent(
1877− answers,
1878− new Set(Object.keys(params.requestedSchema.properties ?? {})),
3566+ }
3567+ });
3568+
3569+ const wireAcpRuntimeHandlers = Effect.fnUntraced(function* () {
3570+ const handlerGeneration = yield* Ref.get(runtimeCallbackGeneration);
3571+ const requestUserInput = (request: AcpAdapterV2UserInputRequest) =>
3572+ Effect.gen(function* () {
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,
18793663 ),
1880− },
1881− } as const);
1882− }),
1883− );
3664+ };
3665+ }),
3666+ );
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+ });
18843873
1885− if (flavor.registerExtensions !== undefined) {
1886− yield* flavor.registerExtensions({ runtime, requestUserInput });
1887− }
3874+ yield* spawnAcpRuntime();
3875+ yield* wireAcpRuntimeHandlers();
18883876
18893877 const started = yield* runtime.start();
18903878 yield* Ref.set(activeSessionId, started.sessionId);
@@ -1893,12 +3881,15 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 2 unmodified lines
18933881 const canLoadSession = started.initializeResult.agentCapabilities?.loadSession === true;
18943882 const canResumeSession =
18953883 started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null;
1896− const supportsImagePrompts =
1897− started.initializeResult.agentCapabilities?.promptCapabilities?.image === true;
3884+ const supportsImagePrompts = acpSupportsImagePrompts({
3885+ flavorSupportsImagePrompts: flavor.supportsImagePrompts,
3886+ negotiatedImage:
3887+ started.initializeResult.agentCapabilities?.promptCapabilities?.image === true,
3888+ });
18983889
18993890 const activateSession = Effect.fnUntraced(function* (
19003891 sessionId: string,
1901− threadId: ThreadId,
3892+ threadId: ThreadId | null,
19023893 ) {
19033894 const activationOptions = { mcpServers: acpMcpServers(threadId) };
19043895 if (canLoadSession) {
@@ -1996,16 +3987,90 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 91 unmodified lines
19963987 completedAt,
19973988 });
19983989
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+
19994049 const finalizeTurn = Effect.fnUntraced(function* (
20004050 context: ActiveAcpTurn,
20014051 status: "completed" | "interrupted" | "failed" | "cancelled",
20024052 failure?: OrchestrationV2ProviderFailure,
4053+ options?: { readonly drainTrailingChunks?: boolean },
20034054 ) {
20044055 if (context.finalized) return;
4056+ const settledStatus = context.interrupted ? "interrupted" : status;
20054057 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+ }
20064071 yield* closeTextStreams(context);
20074072 const now = yield* DateTime.now;
2008− const turn = providerTurnPayload(context, status, now);
4073+ const turn = providerTurnPayload(context, settledStatus, now);
20094074 yield* Ref.update(providerTurns, (current) => {
20104075 const updated = new Map(current);
20114076 updated.set(String(turn.id), turn);
@@ -2031,7 +4096,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 19 unmodified lines
20314096 },
20324097 });
20334098 yield* emitProviderEvent(
2034− status === "failed"
4099+ settledStatus === "failed"
20354100 ? {
20364101 type: "turn.terminal",
20374102 driver,
@@ -2042,7 +4107,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 4 unmodified lines
20424107 context,
20434108 `terminal-failure:${context.providerTurnId}`,
20444109 ),
2045− status,
4110+ status: settledStatus,
20464111 failure: failure ?? makeProviderFailure({ class: "provider_error" }),
20474112 threadDisposition: "reusable",
20484113 }
@@ -2052,15 +4117,108 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines
20524117 providerThreadId: context.input.providerThread.id,
20534118 providerTurnId: context.providerTurnId,
20544119 runOrdinal: context.input.runOrdinal,
2055− status,
4120+ status: settledStatus,
20564121 failure: null,
20574122 threadDisposition: "reusable",
20584123 },
20594124 );
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+ }
20604135 yield* Ref.set(activeTurn, null);
20614136 yield* Deferred.succeed(context.completed, undefined).pipe(Effect.ignore);
20624137 });
20634138
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+
20644222 const resolvePromptParts = Effect.fnUntraced(function* (
20654223 turnInput: ProviderAdapterV2TurnInput,
20664224 ) {
@@ -2110,8 +4268,26 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 43 unmodified lines
21104268 return prompt;
21114269 });
21124270
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")(
21144289 function* (turnInput: ProviderAdapterV2TurnInput) {
4290+ yield* awaitRuntimeTeardown();
21154291 const existing = yield* Ref.get(activeTurn);
21164292 if (existing !== null) {
21174293 return yield* new ProviderAdapterProtocolError({
@@ -2120,7 +4296,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 2 unmodified lines
21204296 });
21214297 }
21224298 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) {
21244303 const activated = yield* activateSession(requestedSessionId, turnInput.threadId);
21254304 yield* Ref.set(activeSessionId, activated.sessionId);
21264305 yield* Ref.set(activeSessionSetup, activated);
@@ -2147,11 +4326,32 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 20 unmodified lines
21474326 yield* Ref.set(activeSelection, turnInput.modelSelection);
21484327 }
21494328 }
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);
21514350 const startedAt = yield* DateTime.now;
21524351 const nativeTurnId = `${requestedSessionId}:turn:${turnInput.providerTurnOrdinal}`;
21534352 const providerTurnId = idAllocator.derive.providerTurn({ driver, nativeTurnId });
21544353 const completed = yield* Deferred.make<void, never>();
4354+ const promptWireSettled = yield* Deferred.make<void, never>();
21554355 const context: ActiveAcpTurn = {
21564356 input: turnInput,
21574357 providerTurnId,
@@ -2165,11 +4365,35 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 7 unmodified lines
21654365 subagents: new Map(),
21664366 subagentsBySessionId: new Map(),
21674367 pendingSubagentNotifications: new Map(),
4368+ toolCallIdsByBackgroundTaskId: new Map(),
4369+ persistentBackgroundTaskIds: new Set(),
4370+ awaitingBackgroundHydration: new Set(),
4371+ pendingInjectedReport: new Set(),
21684372 plan: null,
21694373 interrupted: false,
21704374 finalized: false,
4375+ settleScheduleGeneration: 0,
4376+ promptSettled: false,
4377+ promptSettledStatus: null,
4378+ promptWireSettled,
4379+ backgroundFinalizeGeneration: 0,
21714380 };
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+ }
21724393 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);
21734397 const runningTurn = providerTurnPayload(context, "running", null);
21744398 yield* Ref.update(providerTurns, (current) => {
21754399 const updated = new Map(current);
@@ -2206,35 +4430,100 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 30 unmodified lines
22064430 createdAt: startedAt,
22074431 updatedAt: startedAt,
22084432 });
2209− yield* runtime.prompt({ prompt }).pipe(
2210− Effect.flatMap((result) => {
2211− const status =
2212− result.stopReason === "cancelled"
2213− ? context.interrupted
2214− ? "interrupted"
2215− : "cancelled"
2216− : "completed";
2217− return finalizeTurn(context, status);
2218− }),
4433+ if (isContinuationTurn) {
4434+ const drained = yield* Ref.modify(wakeBuffer, (current) => {
4435+ const next: Array<EffectAcpSchema.SessionNotification> = [];
4436+ return [current.slice(), next] as const;
4437+ });
4438+ yield* Ref.set(continuationRequested, false);
4439+ // Treat attach mode as prompt-settled so deferred finalize / quiet
4440+ // windows can complete the continuation after wake traffic drains.
4441+ context.promptSettled = true;
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.
22194502 Effect.catchCause((cause) =>
2220− finalizeTurn(
2221− context,
2222− context.interrupted ? "interrupted" : "failed",
2223− makeProviderFailure({
2224− cause: Cause.squash(cause),
2225− class: "provider_error",
4503+ runRuntimeCallbackAtGeneration(
4504+ promptGeneration,
4505+ Effect.gen(function* () {
4506+ if (context.finalized) return;
4507+ yield* finalizeTurn(
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+ );
22264525 }),
2227− ).pipe(
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− ),
4526+ ).pipe(Effect.asVoid),
22384527 ),
22394528 Effect.forkIn(sessionScope),
22404529 );
@@ -2254,8 +4543,20 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 13 unmodified lines
22544543 ),
22554544 );
22564545
4546+ const startTurn = Effect.fn("AcpAdapterV2.startTurn.transition")(function* (
4547+ turnInput: ProviderAdapterV2TurnInput,
4548+ ) {
4549+ return yield* runtimeTransitionPermit.withPermit(startTurnUnlocked(turnInput));
4550+ });
4551+
22574552 yield* Effect.addFinalizer(() =>
22584553 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+ );
22594560 const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()];
22604561 yield* Effect.forEach(
22614562 requests,
@@ -2265,14 +4566,40 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines
22654566 : Deferred.succeed(request.answers, null).pipe(Effect.ignore),
22664567 { discard: true },
22674568 );
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;
22684580 const sessionCapabilities =
22694581 started.initializeResult.agentCapabilities?.sessionCapabilities;
22704582 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+ );
22724596 }
22734597 if (flavor.assertComplete !== undefined) {
22744598 yield* flavor.assertComplete.pipe(Effect.orDie);
22754599 }
4600+ if (runtimeScope !== undefined) {
4601+ yield* Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore);
4602+ }
22764603 }),
22774604 );
22784605
@@ -2282,6 +4609,16 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines
22824609 providerSessionId: input.providerSessionId,
22834610 providerSession,
22844611 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+ : {}),
22854622 ensureThread: Effect.fn("AcpAdapterV2.ensureThread")(
22864623 function* (threadInput: ProviderAdapterV2EnsureThreadInput) {
22874624 const now = yield* DateTime.now;
@@ -2320,35 +4657,41 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 32 unmodified lines
23204657 readonly modelSelection?: ModelSelection;
23214658 readonly runtimePolicy?: ProviderAdapterV2RuntimePolicy;
23224659 }) {
2323− const sessionId = nativeThreadId(driver, threadInput.providerThread);
2324− if ((yield* Ref.get(activeSessionId)) !== sessionId) {
2325− yield* Ref.set(snapshot, {
2326− order: [],
2327− messages: new Map(),
2328− loadingRole: null,
2329− loadingIndex: 0,
2330− });
2331− const activated = yield* activateSession(
2332− sessionId,
2333− threadInput.providerThread.appThreadId,
2334− );
2335− yield* Ref.set(activeSessionId, activated.sessionId);
2336− yield* Ref.set(activeSessionSetup, activated);
2337− const nextSelection = threadInput.modelSelection ?? input.modelSelection;
2338− yield* configureSession(
2339− activated,
2340− nextSelection,
2341− threadInput.runtimePolicy ?? input.runtimePolicy,
2342− );
2343− yield* Ref.set(activeSelection, nextSelection);
2344− }
2345− const now = yield* DateTime.now;
2346− return {
2347− ...threadInput.providerThread,
2348− providerSessionId: input.providerSessionId,
2349− status: "idle" as const,
2350− updatedAt: now,
2351− };
4660+ return yield* runtimeTransitionPermit.withPermit(
4661+ Effect.gen(function* () {
4662+ yield* awaitRuntimeTeardown();
4663+ const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired();
4664+ const sessionId = nativeThreadId(driver, threadInput.providerThread);
4665+ if ((yield* Ref.get(activeSessionId)) !== sessionId || restartAfterInterrupt) {
4666+ yield* Ref.set(snapshot, {
4667+ order: [],
4668+ messages: new Map(),
4669+ loadingRole: null,
4670+ loadingIndex: 0,
4671+ });
4672+ const activated = yield* activateSession(
4673+ sessionId,
4674+ threadInput.providerThread.appThreadId,
4675+ );
4676+ yield* Ref.set(activeSessionId, activated.sessionId);
4677+ yield* Ref.set(activeSessionSetup, activated);
4678+ const nextSelection = threadInput.modelSelection ?? input.modelSelection;
4679+ yield* configureSession(
4680+ activated,
4681+ nextSelection,
4682+ threadInput.runtimePolicy ?? input.runtimePolicy,
4683+ );
4684+ yield* Ref.set(activeSelection, nextSelection);
4685+ }
4686+ const now = yield* DateTime.now;
4687+ return {
4688+ ...threadInput.providerThread,
4689+ providerSessionId: input.providerSessionId,
4690+ status: "idle" as const,
4691+ updatedAt: now,
4692+ };
4693+ }),
4694+ );
23524695 },
23534696 (effect, threadInput) =>
23544697 effect.pipe(
@@ -2373,25 +4716,292 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 18 unmodified lines
23734716 ),
23744717 interruptTurn: Effect.fn("AcpAdapterV2.interruptTurn")(
23754718 function* (turnInput: ProviderAdapterV2InterruptInput) {
2376− const context = yield* Ref.get(activeTurn);
2377− if (context?.providerTurnId !== turnInput.providerTurnId) {
2378− return yield* new ProviderAdapterProtocolError({
2379− driver,
2380− detail: `ACP provider turn ${turnInput.providerTurnId} is not active`,
2381− });
2382− }
2383− context.interrupted = true;
2384− yield* runtime.cancel.pipe(Effect.ensuring(cancelPendingRuntimeRequests()));
2385− const stopped = yield* Deferred.await(context.completed).pipe(
2386− Effect.timeoutOption("10 seconds"),
4719+ return yield* Effect.uninterruptibleMask((restore) =>
4720+ Effect.gen(function* () {
4721+ yield* restore(runtimeTransitionPermit.take(1));
4722+ return yield* Effect.gen(function* () {
4723+ const transition = yield* Effect.gen(function* () {
4724+ const interruptContext = yield* Ref.get(activeTurn);
4725+ // Settled steering restart: the native prompt already
4726+ // returned and this is not a user Stop, so keep the
4727+ // process (and its background subagents) alive instead
4728+ // of hard-killing it. The turn still terminalizes below.
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+ }),
23875004 );
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− }
23955005 },
23965006 (effect, turnInput) =>
23975007 effect.pipe(
@@ -2407,28 +5017,43 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 9 unmodified lines
24075017 ),
24085018 ),
24095019 respondToRuntimeRequest: (requestInput) =>
2410− Effect.gen(function* () {
2411− const pending = (yield* Ref.get(pendingRuntimeRequests)).get(
2412− String(requestInput.requestId),
2413− );
2414− if (pending === undefined) {
2415− return yield* new ProviderAdapterProtocolError({
2416− driver,
2417− detail: `No pending ACP runtime request ${requestInput.requestId}`,
2418− });
2419− }
2420− if (pending.type === "user_input") {
2421− yield* Deferred.succeed(pending.answers, requestInput.answers ?? null);
2422− return;
2423− }
2424− if (requestInput.decision === undefined) {
2425− return yield* new ProviderAdapterProtocolError({
2426− driver,
2427− detail: `ACP approval request ${requestInput.requestId} requires a decision`,
2428− });
2429− }
2430− yield* Deferred.succeed(pending.decision, requestInput.decision);
2431− }).pipe(
5020+ Effect.uninterruptibleMask((restore) =>
5021+ Effect.gen(function* () {
5022+ yield* restore(runtimeTransitionPermit.take(1));
5023+ return yield* Effect.gen(function* () {
5024+ yield* awaitRuntimeTeardown();
5025+ const generation = yield* Ref.get(runtimeCallbackGeneration);
5026+ const pending = (yield* Ref.get(pendingRuntimeRequests)).get(
5027+ String(requestInput.requestId),
5028+ );
5029+ if (pending === undefined || pending.generation !== generation) {
5030+ return yield* new ProviderAdapterProtocolError({
5031+ driver,
5032+ detail: `No pending ACP runtime request ${requestInput.requestId}`,
5033+ });
5034+ }
5035+ const settled =
5036+ pending.type === "user_input"
5037+ ? yield* Deferred.succeed(pending.answers, requestInput.answers ?? null)
5038+ : requestInput.decision === undefined
5039+ ? yield* new ProviderAdapterProtocolError({
5040+ driver,
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(
24325057 Effect.mapError(
24335058 (cause) =>
24345059 new ProviderAdapterRuntimeRequestResponseError({
@@ -2440,44 +5065,50 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines
24405065 ),
24415066 readThreadSnapshot: Effect.fn("AcpAdapterV2.readThreadSnapshot")(
24425067 function* (snapshotInput) {
2443− const sessionId = nativeThreadId(driver, snapshotInput.providerThread);
2444− if ((yield* Ref.get(activeSessionId)) !== sessionId) {
2445− if (!capabilities.threads.canReadThreadSnapshot) {
2446− return yield* new ProviderAdapterProtocolError({
2447− driver,
2448− detail: "ACP driver does not support session/load snapshots",
2449− });
2450− }
2451− yield* Ref.set(snapshot, {
2452− order: [],
2453− messages: new Map(),
2454− loadingRole: null,
2455− loadingIndex: 0,
2456− });
2457− const activated = yield* runtime.loadSession(sessionId, {
2458− mcpServers: acpMcpServers(snapshotInput.providerThread.appThreadId),
2459− });
2460− yield* Ref.set(activeSessionId, activated.sessionId);
2461− yield* Ref.set(activeSessionSetup, activated);
2462− yield* Ref.set(activeSelection, null);
2463− }
2464− const state = yield* Ref.get(snapshot);
2465− const now = yield* DateTime.now;
2466− return {
2467− providerThread: {
2468− ...snapshotInput.providerThread,
2469− providerSessionId: input.providerSessionId,
2470− status: "idle" as const,
2471− updatedAt: now,
2472− },
2473− providerTurns: [...(yield* Ref.get(providerTurns)).values()],
2474− messages: state.order.flatMap((key) => {
2475− const message = state.messages.get(key);
2476− return message === undefined ? [] : [message];
5068+ return yield* runtimeTransitionPermit.withPermit(
5069+ Effect.gen(function* () {
5070+ yield* awaitRuntimeTeardown();
5071+ yield* restartRuntimeAfterTeardownIfRequired();
5072+ const sessionId = nativeThreadId(driver, snapshotInput.providerThread);
5073+ if ((yield* Ref.get(activeSessionId)) !== sessionId) {
5074+ if (!capabilities.threads.canReadThreadSnapshot) {
5075+ return yield* new ProviderAdapterProtocolError({
5076+ driver,
5077+ detail: "ACP driver does not support session/load snapshots",
5078+ });
5079+ }
5080+ yield* Ref.set(snapshot, {
5081+ order: [],
5082+ messages: new Map(),
5083+ loadingRole: null,
5084+ loadingIndex: 0,
5085+ });
5086+ const activated = yield* runtime.loadSession(sessionId, {
5087+ mcpServers: acpMcpServers(snapshotInput.providerThread.appThreadId),
5088+ });
5089+ yield* Ref.set(activeSessionId, activated.sessionId);
5090+ yield* Ref.set(activeSessionSetup, activated);
5091+ yield* Ref.set(activeSelection, null);
5092+ }
5093+ const state = yield* Ref.get(snapshot);
5094+ const now = yield* DateTime.now;
5095+ return {
5096+ providerThread: {
5097+ ...snapshotInput.providerThread,
5098+ providerSessionId: input.providerSessionId,
5099+ status: "idle" as const,
5100+ updatedAt: now,
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+ };
24775110 }),
2478− runtimeRequests: [],
2479− providerPayload: { protocol: ACP_PROTOCOL, sessionId },
2480− };
5111+ );
24815112 },
24825113 (effect, snapshotInput) =>
24835114 effect.pipe(
@@ -2502,44 +5133,50 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 18 unmodified lines
25025133 ),
25035134 forkThread: Effect.fn("AcpAdapterV2.forkThread")(
25045135 function* (forkInput) {
2505− if (!capabilities.threads.canForkThread) {
2506− return yield* new ProviderAdapterProtocolError({
2507− driver,
2508− detail: "ACP driver did not negotiate session/fork",
2509− });
2510− }
2511− if (forkInput.providerTurnId !== undefined) {
2512− return yield* new ProviderAdapterProtocolError({
2513− driver,
2514− detail: "ACP session/fork can only fork the current session head",
2515− });
2516− }
2517− const sourceSessionId = nativeThreadId(driver, forkInput.sourceProviderThread);
2518− const forked = yield* runtime.forkSession(sourceSessionId, {
2519− mcpServers: acpMcpServers(forkInput.targetThreadId),
2520− });
2521− yield* Ref.set(activeSessionId, forked.sessionId);
2522− yield* Ref.set(activeSessionSetup, forked);
2523− yield* Ref.set(activeSelection, null);
2524− const now = yield* DateTime.now;
2525− return makeProviderThread({
2526− driver,
2527− providerInstanceId: options.instanceId,
2528− idAllocator,
2529− appThreadId: forkInput.targetThreadId,
2530− providerSessionId: input.providerSessionId,
2531− nativeThreadId: forked.sessionId,
2532− ...(forkInput.ownerNodeId === undefined
2533− ? {}
2534− : { ownerNodeId: forkInput.ownerNodeId }),
2535− forkedFrom: {
2536− providerThreadId: forkInput.sourceProviderThread.id,
2537− ...(forkInput.providerTurnId === undefined
2538− ? {}
2539− : { providerTurnId: forkInput.providerTurnId }),
2540− },
2541− now,
2542− });
5136+ return yield* runtimeTransitionPermit.withPermit(
5137+ Effect.gen(function* () {
5138+ yield* awaitRuntimeTeardown();
5139+ yield* restartRuntimeAfterTeardownIfRequired();
5140+ if (!capabilities.threads.canForkThread) {
5141+ return yield* new ProviderAdapterProtocolError({
5142+ driver,
5143+ detail: "ACP driver did not negotiate session/fork",
5144+ });
5145+ }
5146+ if (forkInput.providerTurnId !== undefined) {
5147+ return yield* new ProviderAdapterProtocolError({
5148+ driver,
5149+ detail: "ACP session/fork can only fork the current session head",
5150+ });
5151+ }
5152+ const sourceSessionId = nativeThreadId(driver, forkInput.sourceProviderThread);
5153+ const forked = yield* runtime.forkSession(sourceSessionId, {
5154+ mcpServers: acpMcpServers(forkInput.targetThreadId),
5155+ });
5156+ yield* Ref.set(activeSessionId, forked.sessionId);
5157+ yield* Ref.set(activeSessionSetup, forked);
5158+ yield* Ref.set(activeSelection, null);
5159+ const now = yield* DateTime.now;
5160+ return makeProviderThread({
5161+ driver,
5162+ providerInstanceId: options.instanceId,
5163+ idAllocator,
5164+ appThreadId: forkInput.targetThreadId,
5165+ providerSessionId: input.providerSessionId,
5166+ nativeThreadId: forked.sessionId,
5167+ ...(forkInput.ownerNodeId === undefined
5168+ ? {}
5169+ : { ownerNodeId: forkInput.ownerNodeId }),
5170+ forkedFrom: {
5171+ providerThreadId: forkInput.sourceProviderThread.id,
5172+ ...(forkInput.providerTurnId === undefined
5173+ ? {}
5174+ : { providerTurnId: forkInput.providerTurnId }),
5175+ },
5176+ now,
5177+ });
5178+ }),
5179+ );
25435180 },
25445181 (effect, forkInput) =>
25455182 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
146146 assert.equal((yield* Ref.get(updates)).length, 2);
147147 }),
148148 );
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+ );
149189 });
150190
151191 describe("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
868868 }) =>
869869 flushLock.withPermit(
870870 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) => {
872889 const next = new Map(current);
873− const pending: Array<CodexAgentMessageDeltaUpdate> = [];
874890 for (const [key, message] of current) {
875891 if (!options.predicate(message) || (options.onlyDirty && !message.dirty)) {
876892 continue;
877893 }
878− pending.push({
879− turnId: message.turnId,
880− itemId: message.itemId,
881− text: message.text,
882− completed: options.completed,
883− });
884894 if (options.completed) {
885895 next.delete(key);
886896 } else {
887897 next.set(key, { ...message, dirty: false });
888898 }
889899 }
890− return [pending, next] as const;
900+ return next;
891901 });
892− if (options.releaseSchedule === true) {
893− yield* Ref.set(flushScheduled, false);
894− }
895− yield* Effect.forEach(updates, input.emit, { discard: true });
896902 }),
897903 );
898904
@@ -936,14 +942,15 @@ export const makeCodexAgentMessageDeltaCoalescer = Effect.fn( · 37 unmodified lines
936942 complete: ({ turnId, itemId, finalText }) =>
937943 flushLock.withPermit(
938944 Effect.gen(function* () {
939− const text = yield* Ref.modify(buffered, (current) => {
940− const key = codexAgentMessageBufferKey(turnId, itemId);
941− const existing = current.get(key);
945+ const key = codexAgentMessageBufferKey(turnId, itemId);
946+ const existing = (yield* Ref.get(buffered)).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) => {
942950 const next = new Map(current);
943951 next.delete(key);
944− return [finalText && finalText.length > 0 ? finalText : (existing?.text ?? ""), next];
952+ return next;
945953 });
946− yield* input.emit({ turnId, itemId, text, completed: true });
947954 return text;
948955 }),
949956 ),
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts+253−2
@@ -1,9 +1,25 @@
11 import { assert, describe, it } from "@effect/vitest";
2+import * as Effect from "effect/Effect";
23 import type * as EffectAcpSchema from "effect-acp/schema";
34
45 import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";
5−import { AcpProviderCapabilitiesV2, acpPermissionDisposition } from "./AcpAdapterV2.ts";
6−import { GrokProviderCapabilitiesV2 } from "./GrokAdapterV2.ts";
6+import {
7+ AcpProviderCapabilitiesV2,
8+ acpCompletedTurnShouldTerminalizeTool,
9+ acpPermissionDisposition,
10+ acpRootSessionUpdateIngestsOutput,
11+ acpRootTurnCompletionDrainMs,
12+ acpRootTurnHasIngestedOutput,
13+ acpRootTurnIsIdle,
14+ acpRootTurnSettleDebounceMs,
15+ acpRootTurnShouldRearmRecoveryTimers,
16+ acpSupportsImagePrompts,
17+} from "./AcpAdapterV2.ts";
18+import {
19+ makeGrokAcpAdapterFlavor,
20+ GrokProviderCapabilitiesV2,
21+ type GrokAdapterV2Options,
22+} from "./GrokAdapterV2.ts";
723
824 function permissionRequest(
925 kind: EffectAcpSchema.ToolKind,
@@ -37,7 +53,221 @@ function runtimePolicy(input: { · 27 unmodified lines
3753 });
3854 }
3955
56+describe("acpRootTurnSettleDebounceMs", () => {
57+ it("keeps the historical debounce constant for re-enable experiments", () => {
58+ assert.equal(acpRootTurnSettleDebounceMs, 2_000);
59+ });
60+});
61+
62+describe("acpRootTurnCompletionDrainMs", () => {
63+ it("gives trailing root chunks a short landing window", () => {
64+ assert.equal(acpRootTurnCompletionDrainMs, 100);
65+ });
66+});
67+
68+describe("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+
129+describe("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+
151+describe("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+
162+describe("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+
40210 describe("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+
41271 it("keeps optional protocol features conservative until a flavor or handshake confirms them", () => {
42272 assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsModelSwitchInSession);
43273 assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsRuntimeModeSwitchInSession);
@@ -45,6 +275,27 @@ describe("GrokAdapterV2 capabilities", () => { · 1 unmodified line
45275 assert.isFalse(AcpProviderCapabilitiesV2.tools.supportsMcpTools);
46276 });
47277
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+
48299 it("declares Grok Task envelopes as native subagents", () => {
49300 assert.isFalse(GrokProviderCapabilitiesV2.threads.canForkThread);
50301 assert.isTrue(GrokProviderCapabilitiesV2.subagents.supportsSubagents);
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts+75−7
@@ -21,17 +21,26 @@ import { · 20 unmodified lines
2121 resolveGrokAcpBaseModelId,
2222 } from "../../provider/acp/GrokAcpSupport.ts";
2323 import {
24+ extractXAiAcpBackgroundToolMutation,
25+ extractXAiAcpSubagentEndNotice,
26+ extractXAiAcpSubagentUpdate,
2427 extractXAiAskUserQuestionIdentity,
2528 extractXAiAskUserQuestions,
26− extractXAiAcpSubagentUpdate,
29+ extractXAiBackgroundTaskCompletion,
30+ extractXAiKilledBackgroundTasks,
31+ extractXAiMonitorTaskId,
32+ isXAiPersistentMonitor,
2733 makeXAiAskUserQuestionCancelledResponse,
2834 makeXAiAskUserQuestionResponse,
35+ normalizeXAiAcpToolCallState,
36+ registerXAiBackgroundTaskTracking,
2937 XAiAskUserQuestionRequest,
3038 } from "../../provider/acp/XAiAcpExtension.ts";
3139 import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
3240 import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
3341 import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts";
3442 import { IdAllocatorV2 } from "../IdAllocator.ts";
43+import { ProviderContinuationRequests } from "../ProviderContinuationRequests.ts";
3544 import { ProviderAdapterV2 } from "../ProviderAdapter.ts";
3645 import {
3746 ProviderAdapterDriverCreateError,
@@ -41,6 +50,7 @@ import { · 3 unmodified lines
4150 import {
4251 AcpProviderCapabilitiesV2,
4352 makeAcpAdapterV2,
53+ type AcpAdapterV2ExtensionContext,
4454 type AcpAdapterV2Flavor,
4555 type AcpAdapterV2RuntimeInput,
4656 } from "./AcpAdapterV2.ts";
@@ -88,6 +98,7 @@ export interface GrokAdapterV2Options { · 41 unmodified lines
8898 readonly idAllocator: IdAllocatorV2["Service"];
8999 readonly serverConfig: ServerConfig["Service"];
90100 readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"];
101+ readonly continuationRequests?: Parameters<typeof makeAcpAdapterV2>[0]["continuationRequests"];
91102 readonly makeRuntime?: (
92103 input: AcpAdapterV2RuntimeInput,
93104 ) => Effect.Effect<
@@ -101,7 +112,16 @@ export interface GrokAdapterV2Options { · 7 unmodified lines
101112 export const registerGrokAcpExtensions: NonNullable<AcpAdapterV2Flavor["registerExtensions"]> = ({
102113 runtime,
103114 requestUserInput,
115+ applyBackgroundTaskMutation,
104116 }) =>
117+ registerXAiBackgroundTaskTracking(runtime, applyBackgroundTaskMutation).pipe(
118+ Effect.andThen(registerGrokAskUserQuestionExtensions({ runtime, requestUserInput })),
119+ );
120+
121+const registerGrokAskUserQuestionExtensions = ({
122+ runtime,
123+ requestUserInput,
124+}: Pick<AcpAdapterV2ExtensionContext, "runtime" | "requestUserInput">) =>
105125 Effect.forEach(
106126 ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const,
107127 (method) =>
@@ -115,37 +135,78 @@ export const registerGrokAcpExtensions: NonNullable<AcpAdapterV2Flavor["register · 7 unmodified lines
115135 }));
116136 return requestUserInput({
117137 nativeItemId: `${identity.sessionId}:xai-question:${identity.toolCallId}`,
138+ nativeMethod: method,
118139 nativeRequestId: identity.toolCallId,
140+ nativeSessionId: identity.sessionId,
119141 questions,
120142 }).pipe(
121− Effect.map((answers) =>
122− answers === null
123− ? makeXAiAskUserQuestionCancelledResponse()
124− : makeXAiAskUserQuestionResponse(params, answers),
143+ Effect.flatMap(({ acknowledgeNativeResponse, answers }) =>
144+ Effect.succeed(
145+ answers === null
146+ ? makeXAiAskUserQuestionCancelledResponse()
147+ : makeXAiAskUserQuestionResponse(params, answers),
148+ ).pipe(Effect.tap(() => acknowledgeNativeResponse)),
125149 ),
126150 );
127151 }),
128152 { discard: true },
129153 );
130154
131−export function makeGrokAdapterV2(options: GrokAdapterV2Options) {
132− const flavor: AcpAdapterV2Flavor = {
155+export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdapterV2Flavor {
156+ return {
133157 driver: GROK_PROVIDER,
134158 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,
135180 resolveModelId: (selection) => resolveGrokAcpBaseModelId(selection.model),
136181 makeRuntime:
137182 options.makeRuntime ??
138183 ((input) =>
139184 makeGrokAcpRuntime({
140185 ...input,
186+ interruptPromptOnCancel: input.interruptPromptOnCancel ?? false,
141187 grokSettings: options.settings,
142188 environment: options.environment,
143189 childProcessSpawner: options.childProcessSpawner,
144190 })),
145191 registerExtensions: registerGrokAcpExtensions,
146192 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,
147204 ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }),
148205 };
206+}
207+
208+export function makeGrokAdapterV2(options: GrokAdapterV2Options) {
209+ const flavor = makeGrokAcpAdapterFlavor(options);
149210 return makeAcpAdapterV2({
150211 instanceId: options.instanceId,
151212 flavor,
@@ -153,6 +214,9 @@ export function makeGrokAdapterV2(options: GrokAdapterV2Options) { · 1 unmodified line
153214 idAllocator: options.idAllocator,
154215 serverConfig: options.serverConfig,
155216 ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }),
217+ ...(options.continuationRequests === undefined
218+ ? {}
219+ : { continuationRequests: options.continuationRequests }),
156220 });
157221 }
158222
@@ -176,6 +240,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapte · 17 unmodified lines
176240 const idAllocator = yield* IdAllocatorV2;
177241 const providerEventLoggers = yield* ProviderEventLoggers;
178242 const serverConfig = yield* ServerConfig;
243+ const continuationRequests = yield* ProviderContinuationRequests;
179244 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
180245 return makeGrokAdapterV2({
181246 instanceId: input.instanceId,
@@ -185,6 +250,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapte · 3 unmodified lines
185250 fileSystem,
186251 idAllocator,
187252 serverConfig,
253+ continuationRequests,
188254 nativeLogging: (threadId) =>
189255 makeNativeLogger({
190256 nativeEventLogger: providerEventLoggers.native,
@@ -226,6 +292,7 @@ export const layer: Layer.Layer< · 35 unmodified lines
226292 const idAllocator = yield* IdAllocatorV2;
227293 const providerEventLoggers = yield* ProviderEventLoggers;
228294 const serverConfig = yield* ServerConfig;
295+ const continuationRequests = yield* ProviderContinuationRequests;
229296 const makeNativeLogger = yield* makeAcpNativeLoggerFactory();
230297 return makeGrokAdapterV2({
231298 instanceId: GROK_DEFAULT_INSTANCE_ID,
@@ -235,6 +302,7 @@ export const layer: Layer.Layer< · 3 unmodified lines
235302 fileSystem,
236303 idAllocator,
237304 serverConfig,
305+ continuationRequests,
238306 nativeLogging: (threadId) =>
239307 makeNativeLogger({
240308 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
1717
1818 import { CheckpointRollbackServiceV2 } from "./CheckpointRollbackService.ts";
1919 import type { OrchestrationEffectV2 } from "./EffectOutbox.ts";
20−import { executorLayer, OrchestrationEffectExecutorV2 } from "./EffectWorker.ts";
20+import {
21+ executorLayer,
22+ isNonRetryableProviderTurnControlFailure,
23+ OrchestrationEffectExecutorV2,
24+} from "./EffectWorker.ts";
2125 import { RunFinalizationService } from "./RunFinalizationService.ts";
2226 import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts";
2327 import { ProviderTurnControlServiceV2 } from "./ProviderTurnControlService.ts";
@@ -133,6 +137,40 @@ function makeExecutorLayer(input: { · 109 unmodified lines
133137 return executorLayer.pipe(Layer.provide(dependencies));
134138 }
135139
140+it("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+
136174 it.effect("detaches a handed-off session only after the old turn terminalizes", () =>
137175 Effect.gen(function* () {
138176 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
2525 },
2626 ) {}
2727
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+ */
37+export 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+
2852 export interface OrchestrationEffectExecutorV2Shape {
2953 readonly execute: (
3054 effect: OrchestrationEffectV2,
@@ -348,14 +372,19 @@ export const layerWithOptions = ( · 317 unmodified lines
348372 }
349373
350374 const error = Cause.pretty(exit.cause);
375+ const nonRetryable = isNonRetryableProviderTurnControlFailure(effect.request.type, error);
351376 yield* Effect.logWarning("Orchestration effect execution failed", {
352377 effectId: effect.id,
353378 effectType: effect.request.type,
354379 attemptCount: effect.attemptCount,
380+ nonRetryable,
355381 error,
356382 });
357− const updated =
358− effect.attemptCount >= maxAttempts
383+ // Prefer succeed for terminal interrupt races so the outbox does not
384+ // 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
359388 ? yield* outbox.fail({ effectId: effect.id, workerId, error })
360389 : yield* outbox.retry({
361390 effectId: effect.id,
apps/server/src/orchestration-v2/ProjectionStore.ts+2−0
@@ -573,6 +573,7 @@ function activeLocalTurnItems( · 572 unmodified lines
573573 item,
574574 runs: projection.runs,
575575 attempts: projection.attempts,
576+ items: projection.turnItems,
576577 }),
577578 )
578579 .map((item, position) => ({
@@ -668,6 +669,7 @@ function visibleTurnItemsThroughRun(input: { · 89 unmodified lines
668669 isOrchestrationV2SupersededInterrupt({
669670 item,
670671 attempts: input.sourceProjection.attempts,
672+ items: input.sourceProjection.turnItems,
671673 })
672674 ) {
673675 return false;
apps/server/src/orchestration-v2/ProviderAdapter.ts+2−0
@@ -407,6 +407,8 @@ export interface ProviderAdapterV2SteerInput { · 406 unmodified lines
407407 export interface ProviderAdapterV2InterruptInput {
408408 readonly providerThread: OrchestrationV2ProviderThread;
409409 readonly providerTurnId: ProviderTurnId;
410+ /** When true, the next `startTurn` may respawn the provider runtime (Grok Stop recovery). */
411+ readonly requestRuntimeRestart?: boolean;
410412 }
411413
412414 export 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
22 import * as Context from "effect/Context";
33 import * as Effect from "effect/Effect";
44 import * as Layer from "effect/Layer";
5+import * as Option from "effect/Option";
56 import * as Queue from "effect/Queue";
67
78 export interface ProviderContinuationRequest {
@@ -9,6 +10,9 @@ export interface ProviderContinuationRequest { · 1 unmodified line
910 readonly providerThreadId: ProviderThreadId;
1011 readonly driver: ProviderDriverKind;
1112 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>;
1216 }
1317
1418 /**
apps/server/src/orchestration-v2/ProviderContinuationService.test.ts+193−0
@@ -0,0 +1,193 @@
1+import { assert, describe, it } from "@effect/vitest";
2+import {
3+ ProviderDriverKind,
4+ ProviderThreadId,
5+ ThreadId,
6+ type OrchestrationV2ThreadProjection,
7+} from "@t3tools/contracts";
8+import * as Deferred from "effect/Deferred";
9+import * as Effect from "effect/Effect";
10+import * as Layer from "effect/Layer";
11+import * as Option from "effect/Option";
12+import * as Queue from "effect/Queue";
13+import * as Ref from "effect/Ref";
14+import * as Semaphore from "effect/Semaphore";
15+
16+import { layer as idAllocatorLayer } from "./IdAllocator.ts";
17+import {
18+ type ProviderContinuationRequest,
19+ ProviderContinuationRequests,
20+ layer as continuationRequestsLayer,
21+} from "./ProviderContinuationRequests.ts";
22+import { workerLive } from "./ProviderContinuationService.ts";
23+import { ThreadManagementService } from "./ThreadManagementService.ts";
24+
25+const threadId = ThreadId.make("thread-provider-continuation");
26+const providerThreadId = ProviderThreadId.make("provider-thread-continuation");
27+const driver = ProviderDriverKind.make("continuation-test");
28+const projection = {
29+ thread: { archivedAt: null },
30+ messages: [],
31+} as unknown as OrchestrationV2ThreadProjection;
32+
33+const 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+
44+const 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+
69+function 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+
83+describe("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
3232 threadId: request.threadId,
3333 providerThreadId: request.providerThreadId,
3434 });
35+ // Still run the guard so adapters can clear sticky continuationRequested.
36+ if (request.dispatchIfCurrent !== undefined) {
37+ yield* request.dispatchIfCurrent(Effect.void);
38+ }
3539 return;
3640 }
41+ // The ordinal is display metadata only: allocate.message appends a
42+ // random UUID, so a stale projection read here cannot collide ids.
3743 const messageId = yield* ids.allocate.message({
3844 threadId: request.threadId,
3945 ordinal: projection.messages.length + 1,
4046 });
4147 const commandId = CommandId.make(`provider-continuation:${messageId}`);
42− yield* threads.dispatch({
48+ const dispatch = threads.dispatch({
4349 type: "message.dispatch",
4450 commandId,
4551 threadId: request.threadId,
@@ -50,6 +56,11 @@ export const workerLive = Layer.effectDiscard( · 4 unmodified lines
5056 createdBy: "agent",
5157 creationSource: "provider",
5258 });
59+ if (request.dispatchIfCurrent === undefined) {
60+ yield* dispatch;
61+ return;
62+ }
63+ yield* request.dispatchIfCurrent(dispatch);
5364 },
5465 );
5566
apps/server/src/orchestration-v2/ProviderSessionManager.test.ts+93−0
@@ -1233,6 +1233,99 @@ it.effect( · 1232 unmodified lines
12331233 }),
12341234 );
12351235
1236+it.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+
12361329 it.effect(
12371330 "ProviderSessionManagerV2 keeps active sessions alive until the provider turn terminates",
12381331 () =>
apps/server/src/orchestration-v2/ProviderSessionManager.ts+28−12
@@ -605,31 +605,47 @@ export const layerWithOptions = ( · 604 unmodified lines
605605 ) {
606606 return;
607607 }
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;
608611 const hasPendingWork =
609− entry.runtime.hasPendingBackgroundWork === undefined
612+ probedRuntime.hasPendingBackgroundWork === undefined
610613 ? false
611− : yield* entry.runtime.hasPendingBackgroundWork.pipe(
614+ : yield* probedRuntime.hasPendingBackgroundWork.pipe(
612615 Effect.catchCause(() => Effect.succeed(false)),
613616 );
614617 if (hasPendingWork) {
615618 const now = yield* Clock.currentTimeMillis;
616619 const pinnedSinceMs = entry.pinnedSinceMs ?? now;
617620 if (now - pinnedSinceMs < maxIdlePinMs) {
618− yield* Effect.logInfo("orchestration-v2.driver-session.idle-release-deferred", {
619− providerSessionId: input.providerSessionId,
620− pinnedForMs: now - pinnedSinceMs,
621− });
622− yield* Ref.update(sessions, (latest) => {
621+ const shouldContinuePin = yield* Ref.modify(sessions, (latest) => {
623622 const latestEntry = latest.get(key);
624− if (latestEntry === undefined || latestEntry.busyCount > 0) {
625− return latest;
623+ if (
624+ latestEntry === undefined ||
625+ latestEntry.busyCount > 0 ||
626+ latestEntry.idleGeneration !== input.generation ||
627+ latestEntry.runtime !== probedRuntime
628+ ) {
629+ return [false, latest] as const;
626630 }
627631 const updated = new Map(latest);
628632 updated.set(key, { ...latestEntry, pinnedSinceMs });
629− return updated;
633+ return [true, updated] as const;
630634 });
631− yield* scheduleIdleReleaseInternal(input.providerSessionId);
632− return;
635+ if (!shouldContinuePin) {
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);
633649 }
634650 yield* Effect.logWarning("orchestration-v2.driver-session.idle-release-pin-expired", {
635651 providerSessionId: input.providerSessionId,
apps/server/src/orchestration-v2/ProviderTurnControlService.ts+43−5
@@ -129,6 +129,28 @@ export const layer: Layer.Layer< · 128 unmodified lines
129129 }
130130 const session = yield* sessions.get(input.providerSessionId);
131131 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+ }
132154 return yield* new ProviderTurnControlError({
133155 threadId: input.threadId,
134156 operation: input.operation,
@@ -147,6 +169,7 @@ export const layer: Layer.Layer< · 12 unmodified lines
147169 yield* loaded.session.value.interruptTurn({
148170 providerThread: loaded.providerThread,
149171 providerTurnId: loaded.providerTurn.id,
172+ requestRuntimeRestart: true,
150173 });
151174 }).pipe(
152175 Effect.mapError((cause) =>
@@ -163,13 +186,28 @@ export const layer: Layer.Layer< · 10 unmodified lines
163186 interruptAndAwaitTerminal: (input) =>
164187 Effect.gen(function* () {
165188 const loaded = yield* load({ ...input, operation: "restart" });
166− if (Option.isSome(loaded.session)) {
167− yield* loaded.session.value.interruptTurn({
168− providerThread: loaded.providerThread,
169− providerTurnId: loaded.providerTurn.id,
170− });
189+ if (Option.isNone(loaded.session)) {
190+ // No live adapter: nothing can emit a terminal provider-turn update
191+ // from interrupt. Do not poll for projection terminalization or the
192+ // restart effect stalls; let detach/start proceed.
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;
171204 }
172205
206+ yield* loaded.session.value.interruptTurn({
207+ providerThread: loaded.providerThread,
208+ providerTurnId: loaded.providerTurn.id,
209+ });
210+
173211 for (let remaining = 1_000; remaining > 0; remaining -= 1) {
174212 const projection = yield* projections.getThreadProjection(input.threadId);
175213 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
439439 }),
440440 Effect.catchCause(() => Effect.succeed(false)),
441441 ),
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+ ),
442459 message: {
443460 messageId: message.id,
444461 text:
apps/server/src/orchestration-v2/RunExecutionService.test.ts+189−1
@@ -31,7 +31,7 @@ import * as Stream from "effect/Stream"; · 30 unmodified lines
3131 import { ServerSettingsService } from "../serverSettings.ts";
3232 import { CheckpointServiceV2 } from "./CheckpointService.ts";
3333 import { EventSinkV2 } from "./EventSink.ts";
34−import { layer as idAllocatorLayer } from "./IdAllocator.ts";
34+import { IdAllocatorV2, layer as idAllocatorLayer } from "./IdAllocator.ts";
3535 import type { ProviderAdapterV2Event, ProviderAdapterV2SessionRuntime } from "./ProviderAdapter.ts";
3636 import { ProviderEventIngestorV2 } from "./ProviderEventIngestor.ts";
3737 import {
@@ -542,6 +542,194 @@ it.effect("does not pin ingestion on background items when the root turn is inte · 504 unmodified lines
542542 }),
543543 );
544544
545+it.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+
558+it.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+
581+it.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+
595+it.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+
608+function 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+
545733 interface BackgroundScenarioIds {
546734 readonly threadId: ThreadId;
547735 readonly childThreadId: ThreadId;
apps/server/src/orchestration-v2/RunExecutionService.ts+49−23
@@ -260,6 +260,7 @@ export interface RunExecutionServiceV2StartRootRunInput { · 259 unmodified lines
260260 readonly relatedProviderThreadIds?: ReadonlyArray<ProviderThreadId>;
261261 readonly shouldStartProviderTurn?: () => Effect.Effect<boolean, never>;
262262 readonly shouldFinalizeRun?: () => Effect.Effect<boolean, never>;
263+ readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect<boolean, never>;
263264 readonly message: ProviderAdapterV2TurnMessage;
264265 readonly modelSelection: ModelSelection;
265266 readonly runtimePolicy: ProviderAdapterV2RuntimePolicy;
@@ -323,6 +324,7 @@ export const layer: Layer.Layer< · 57 unmodified lines
323324 readonly providerThread: OrchestrationV2ProviderThread;
324325 readonly attempt: OrchestrationV2RunAttempt;
325326 readonly shouldFinalizeRun?: () => Effect.Effect<boolean, never>;
327+ readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect<boolean, never>;
326328 readonly terminal: ProviderTerminalEvent;
327329 readonly failureItemPersisted: boolean;
328330 }) =>
@@ -336,30 +338,37 @@ export const layer: Layer.Layer< · 7 unmodified lines
336338 const shouldFinalizeRun =
337339 input.shouldFinalizeRun === undefined ? true : yield* input.shouldFinalizeRun();
338340 if (!shouldFinalizeRun) {
339− // A newer attempt already owns the run and the command that created
340− // it terminalized this attempt as superseded. Preserve that domain
341− // status while retaining the provider's interruption artifact.
341+ // Superseded attempt (steer / selection restart). Emit
342+ // run_interrupt_result only when hard Stop left an unpaired request
343+ // for this run; plain steers and already-paired stops emit nothing.
342344 if (input.terminal.status === "interrupted") {
343− yield* eventSink.write({
344− events: [
345− {
346− id: yield* idAllocator.allocate.event({ threadId: input.run.threadId }),
347− type: "turn-item.updated" as const,
348− threadId: input.run.threadId,
349− runId: input.run.id,
350− nodeId: input.rootNode.id,
351− providerInstanceId: input.run.providerInstanceId,
352− occurredAt: completedAt,
353− payload: makeInterruptResultTurnItem({
354− idAllocator,
355− run: input.run,
356− rootNode: input.rootNode,
357− providerThread: input.providerThread,
358− completedAt,
359− }),
360− },
361− ],
362− });
345+ const hasUnpairedRequest =
346+ input.hasUnpairedRunInterruptRequest === undefined
347+ ? false
348+ : yield* input.hasUnpairedRunInterruptRequest();
349+ if (hasUnpairedRequest) {
350+ yield* eventSink.writeWithEffects({
351+ effects: [],
352+ events: [
353+ {
354+ id: yield* idAllocator.allocate.event({ threadId: input.run.threadId }),
355+ type: "turn-item.updated" as const,
356+ threadId: input.run.threadId,
357+ runId: input.run.id,
358+ nodeId: input.rootNode.id,
359+ providerInstanceId: input.run.providerInstanceId,
360+ occurredAt: completedAt,
361+ payload: makeInterruptResultTurnItem({
362+ idAllocator,
363+ run: input.run,
364+ rootNode: input.rootNode,
365+ providerThread: input.providerThread,
366+ completedAt,
367+ }),
368+ },
369+ ],
370+ });
371+ }
363372 }
364373 return;
365374 }
@@ -594,6 +603,11 @@ export const layer: Layer.Layer< · 228 unmodified lines
594603 ...(input.shouldFinalizeRun === undefined
595604 ? {}
596605 : { shouldFinalizeRun: input.shouldFinalizeRun }),
606+ ...(input.hasUnpairedRunInterruptRequest === undefined
607+ ? {}
608+ : {
609+ hasUnpairedRunInterruptRequest: input.hasUnpairedRunInterruptRequest,
610+ }),
597611 terminal,
598612 failureItemPersisted: terminal.status === "failed",
599613 }).pipe(
@@ -776,6 +790,12 @@ export const layer: Layer.Layer< · 176 unmodified lines
776790 ...(input.shouldFinalizeRun === undefined
777791 ? {}
778792 : { shouldFinalizeRun: input.shouldFinalizeRun }),
793+ ...(input.hasUnpairedRunInterruptRequest === undefined
794+ ? {}
795+ : {
796+ hasUnpairedRunInterruptRequest:
797+ input.hasUnpairedRunInterruptRequest,
798+ }),
779799 terminal: makeFailedTerminalEvent(
780800 makeProviderFailure({
781801 cause: Cause.squash(cause),
@@ -847,6 +867,12 @@ export const layer: Layer.Layer< · 65 unmodified lines
847867 ...(input.shouldFinalizeRun === undefined
848868 ? {}
849869 : { shouldFinalizeRun: input.shouldFinalizeRun }),
870+ ...(input.hasUnpairedRunInterruptRequest === undefined
871+ ? {}
872+ : {
873+ hasUnpairedRunInterruptRequest:
874+ input.hasUnpairedRunInterruptRequest,
875+ }),
850876 terminal: makeFailedTerminalEvent(
851877 makeProviderFailure({
852878 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
490490 projection.providerTurns.map((turn) => turn.status),
491491 ["interrupted", "completed"],
492492 );
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+ );
494499 assert.equal(projection.runs[0]?.modelSelection.model, replacementSelection.model);
495500 assert.isTrue(captured.failedReplacementOpen);
496501 // 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
2424
2525 const projection = projectionFor(result, transcript.scenario);
2626 assertSemanticProjectionIntegrity(projection);
27− assertTurnItemTypes(projection, ["user_message", "run_interrupt_result", "assistant_message"]);
27+ assertTurnItemTypes(projection, ["user_message", "assistant_message"]);
2828 assertRuntimeRequestCounts(projection, { total: 0 });
2929 assertUserMessagesInclude(projection, [
3030 MESSAGE_STEERING_INITIAL_PROMPT,
@@ -47,9 +47,10 @@ export function assertCursorMessageSteeringOutput( · 16 unmodified lines
4747 );
4848 assert.equal(projection.runs[0]?.activeAttemptId, projection.attempts[1]?.id);
4949 assert.equal(projection.runs[0]?.rootNodeId, projection.attempts[1]?.rootNodeId);
50− assert.notInclude(
51− projection.visibleTurnItems.map((row) => row.item.type),
52− "run_interrupt_result",
53− "restart steering must keep its internal interruption out of visible chat history",
50+ assert.isFalse(
51+ projection.turnItems.some(
52+ (item) => item.type === "run_interrupt_request" || item.type === "run_interrupt_result",
53+ ),
54+ "steer supersede must not project run_interrupt_* items (those are hard Stop only)",
5455 );
5556 }
apps/server/src/orchestration-v2/testkit/fixtures/message_steering/grok_output.ts+6−5
@@ -23,7 +23,7 @@ export function assertGrokMessageSteeringOutput( · 22 unmodified lines
2323
2424 const projection = projectionFor(result, transcript.scenario);
2525 assertSemanticProjectionIntegrity(projection);
26− assertTurnItemTypes(projection, ["user_message", "run_interrupt_result", "assistant_message"]);
26+ assertTurnItemTypes(projection, ["user_message", "assistant_message"]);
2727 assertRuntimeRequestCounts(projection, { total: 0 });
2828 assertUserMessagesInclude(projection, [
2929 MESSAGE_STEERING_INITIAL_PROMPT,
@@ -46,9 +46,10 @@ export function assertGrokMessageSteeringOutput( · 16 unmodified lines
4646 );
4747 assert.equal(projection.runs[0]?.activeAttemptId, projection.attempts[1]?.id);
4848 assert.equal(projection.runs[0]?.rootNodeId, projection.attempts[1]?.rootNodeId);
49− assert.notInclude(
50− projection.visibleTurnItems.map((row) => row.item.type),
51− "run_interrupt_result",
52− "restart steering must keep its internal interruption out of visible chat history",
49+ assert.isFalse(
50+ projection.turnItems.some(
51+ (item) => item.type === "run_interrupt_request" || item.type === "run_interrupt_result",
52+ ),
53+ "steer supersede must not project run_interrupt_* items (those are hard Stop only)",
5354 );
5455 }
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+34−0
@@ -753,6 +753,40 @@ describe("AcpSessionRuntime", () => { · 752 unmodified lines
753753 ),
754754 );
755755
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+
756790 it.effect("rejects invalid config option values before sending session/set_config_option", () => {
757791 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-"));
758792 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
99 parsePermissionRequest,
1010 parseSessionModeState,
1111 parseSessionUpdateEvent,
12+ sessionUpdateCountsAsLoadReplayActivity,
1213 sessionUpdateIsReplay,
1314 syntheticLoadSessionResponseFromInitialize,
1415 toolCallProgressLength,
@@ -86,6 +87,98 @@ describe("AcpRuntimeModel", () => { · 71 unmodified lines
8687 ).toBe(false);
8788 });
8889
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+
89182 it("builds a synthetic load response from initialize model state", () => {
90183 const response = syntheticLoadSessionResponseFromInitialize({
91184 protocolVersion: 1,
apps/server/src/provider/acp/AcpRuntimeModel.ts+30−0
@@ -700,8 +700,38 @@ export function sessionUpdateIsReplay(params: EffectAcpSchema.SessionNotificatio · 699 unmodified lines
700700 return isRecord(meta) && meta.isReplay === true;
701701 }
702702
703+/** Replay chunks and substantive updates during session/load; not Grok keepalives. */
704+export 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+
703731 export interface SessionLoadGate {
704732 readonly active: boolean;
733+ /** Only notifications for this session refresh load-replay idle activity. */
734+ readonly sessionId: string;
705735 readonly lastActivityAtMillis: number | undefined;
706736 readonly idleGap: Duration.Duration;
707737 readonly initializeResult: EffectAcpSchema.InitializeResponse;
apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts+1030−0
@@ -0,0 +1,1030 @@
1+// @effect-diagnostics nodeBuiltinImport:off
2+import * as NodeChildProcess from "node:child_process";
3+import * as NodeFS from "node:fs";
4+import * as NodePath from "node:path";
5+import * as NodeURL from "node:url";
6+
7+import { describe, expect, it } from "@effect/vitest";
8+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
9+import * as Clock from "effect/Clock";
10+import * as Effect from "effect/Effect";
11+import * as Exit from "effect/Exit";
12+import * as Scope from "effect/Scope";
13+
14+import {
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+
38+const threadSpawnHelperSource = NodeURL.fileURLToPath(
39+ new URL("../../../scripts/acp-thread-spawn-helper.c", import.meta.url),
40+);
41+
42+const 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+
59+function 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+
86+const server = () => identity(process.pid, 1, process.pid, process.pid, "server");
87+
88+function processExists(pid: number): boolean {
89+ try {
90+ process.kill(pid, 0);
91+ return true;
92+ } catch {
93+ return false;
94+ }
95+}
96+
97+describe("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
2+import * as NodeCrypto from "node:crypto";
3+import * as NodeFS from "node:fs";
4+import * as NodePath from "node:path";
5+
16 import * as Cause from "effect/Cause";
27 import * as Clock from "effect/Clock";
38 import * as Context from "effect/Context";
@@ -5,11 +10,13 @@ import * as Crypto from "effect/Crypto"; · 1 unmodified line
510 import * as Deferred from "effect/Deferred";
611 import * as Duration from "effect/Duration";
712 import * as Effect from "effect/Effect";
13+import * as Exit from "effect/Exit";
814 import * as Fiber from "effect/Fiber";
915 import * as Layer from "effect/Layer";
1016 import * as Option from "effect/Option";
1117 import * as Queue from "effect/Queue";
1218 import * as Ref from "effect/Ref";
19+import * as Schema from "effect/Schema";
1320 import * as Scope from "effect/Scope";
1421 import * as Semaphore from "effect/Semaphore";
1522 import * as Stream from "effect/Stream";
@@ -20,6 +27,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; · 4 unmodified lines
2027 import type * as EffectAcpSchema from "effect-acp/schema";
2128 import type * as EffectAcpProtocol from "effect-acp/protocol";
2229 import { resolveSpawnCommand } from "@t3tools/shared/shell";
30+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
2331
2432 import {
2533 collectSessionConfigOptionValues,
@@ -28,6 +36,7 @@ import { · 2 unmodified lines
2836 mergeToolCallState,
2937 parseSessionModeState,
3038 parseSessionUpdateEvent,
39+ sessionUpdateCountsAsLoadReplayActivity,
3140 sessionUpdateIsReplay,
3241 waitForSessionLoadReplayIdle,
3342 type SessionLoadGate,
@@ -63,6 +72,16 @@ export interface AcpSessionRuntimeOptions { · 29 unmodified lines
6372 readonly resumeSessionId?: string;
6473 readonly sessionLoadTimeout?: Duration.Input;
6574 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;
6685 readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"];
6786 readonly clientInfo: {
6887 readonly name: string;
@@ -76,6 +95,10 @@ export interface AcpSessionRuntimeOptions { · 7 unmodified lines
7695 readonly logOutgoing?: boolean;
7796 readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>;
7897 };
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"];
79102 }
80103
81104 export interface AcpSessionRequestLogEvent {
@@ -86,6 +109,925 @@ export interface AcpSessionRequestLogEvent { · 4 unmodified lines
86109 readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>;
87110 }
88111
112+export 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+
126+const isAcpProcessGroupTerminationError = Schema.is(AcpProcessGroupTerminationError);
127+
128+export 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+
143+export 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+
151+export 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+
161+export interface AcpLinuxCgroupController {
162+ readonly create: () => AcpLinuxCgroupLease | undefined;
163+}
164+
165+export interface AcpPosixOwnershipRoot {
166+ captureAttempted?: boolean;
167+ value: AcpOwnedPosixProcess | undefined;
168+}
169+
170+function 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+
176+export function parseUnifiedCgroupPath(contents: string): string | undefined {
177+ const matches = [...contents.matchAll(/^0::(\/.*)$/gm)];
178+ return matches.length === 1 ? matches[0]![1] : undefined;
179+}
180+
181+export function isSafeCgroupPath(value: string): boolean {
182+ return (
183+ value.startsWith("/") &&
184+ !value.includes("(deleted)") &&
185+ NodePath.posix.normalize(value) === value
186+ );
187+}
188+
189+export 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+
206+export function parseCgroup2Mount(
207+ contents: string,
208+): { readonly mountPoint: string; readonly root: string } | undefined {
209+ return parseCgroup2Mounts(contents)[0];
210+}
211+
212+export 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+
241+export 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+
275+const STALE_ACP_CGROUP_SIBLING = /^t3-acp-(\d+)-/;
276+
277+export 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+
284+function 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+
293+function 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. */
301+export 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+
331+function 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+
402+export 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.
434+const wallClock = Clock.Clock.defaultValue();
435+const withWallClock = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
436+ Effect.provideService(effect, Clock.Clock, wallClock);
437+
438+export 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+
492+export 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+
508+function 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. */
545+export function posixProcessIsZombie(process: AcpPosixProcessIdentity): boolean {
546+ return process.state === "Z";
547+}
548+
549+function 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+
575+function 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+
594+export 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+
686+export 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+
707+export 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+
716+function 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+
727+export interface AcpOwnedPosixProcess extends AcpPosixProcessIdentity {
728+ readonly parentExecutable: string | undefined;
729+ readonly parentStartTime: string;
730+}
731+
732+function processIdentityKey(process: AcpPosixProcessIdentity): string {
733+ return `${process.pid}:${process.startTime}`;
734+}
735+
736+function 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+
753+export 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+
845+export 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+
988+export function windowsTaskkillResultIsSuccess(exitCode: number, _output?: string): boolean {
989+ return exitCode === 0;
990+}
991+
992+export 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+
891031 export function selectAcpAgentAuthMethod(
901032 authMethods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined,
911033 preferredMethodId?: string,
@@ -237,6 +1179,13 @@ export class AcpSessionRuntime extends Context.Service< · 145 unmodified lines
2371179 * @see https://agentclientprotocol.com/protocol/schema#session/cancel
2381180 */
2391181 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>;
2401189 /**
2411190 * Selects the active mode through the negotiated `mode` configuration option.
2421191 * This is a no-op when the requested mode is already active.
@@ -341,6 +1290,7 @@ export const make = ( · 98 unmodified lines
3411290 const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined));
3421291 const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" });
3431292 const promptSerializationSemaphore = yield* Semaphore.make(1);
1293+ const sessionLoadSemaphore = yield* Semaphore.make(1);
3441294 const activePromptFiberRef = yield* Ref.make<
3451295 Option.Option<Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>>
3461296 >(Option.none());
@@ -382,12 +1332,70 @@ export const make = ( · 35 unmodified lines
3821332 options.spawn.args,
3831333 options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {},
3841334 );
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+ };
3851390 const child = yield* spawner
3861391 .spawn(
387− ChildProcess.make(spawnCommand.command, spawnCommand.args, {
1392+ ChildProcess.make(containedSpawnCommand.command, containedSpawnCommand.args, {
3881393 ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}),
389− ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}),
390− shell: spawnCommand.shell,
1394+ ...(spawnEnvironment ? { env: spawnEnvironment, extendEnv: true } : {}),
1395+ ...(options.ownDetachedProcessGroup === undefined
1396+ ? {}
1397+ : { detached: options.ownDetachedProcessGroup }),
1398+ shell: containedSpawnCommand.shell,
3911399 }),
3921400 )
3931401 .pipe(
@@ -401,6 +1409,155 @@ export const make = ( · 7 unmodified lines
4011409 ),
4021410 );
4031411
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+
4041561 const acpContext = yield* Layer.build(
4051562 EffectAcpClient.layerChildProcess(child, {
4061563 ...(options.protocolLogging?.logIncoming !== undefined
@@ -410,6 +1567,17 @@ export const make = ( · 3 unmodified lines
4101567 ? { logOutgoing: options.protocolLogging.logOutgoing }
4111568 : {}),
4121569 ...(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 } : {}),
4131581 }),
4141582 ).pipe(Effect.provideService(Scope.Scope, runtimeScope));
4151583
@@ -419,14 +1587,16 @@ export const make = ( · 3 unmodified lines
4191587 Effect.gen(function* () {
4201588 const gate = yield* Ref.get(sessionLoadGateRef);
4211589 if (Option.isSome(gate) && gate.value.active) {
422− const lastActivityAtMillis = yield* Clock.currentTimeMillis;
423− yield* Ref.set(
424− sessionLoadGateRef,
425− Option.some({
426− ...gate.value,
427− lastActivityAtMillis,
428− }),
429− );
1590+ if (sessionUpdateCountsAsLoadReplayActivity(notification, gate.value.sessionId)) {
1591+ const lastActivityAtMillis = yield* Clock.currentTimeMillis;
1592+ yield* Ref.set(
1593+ sessionLoadGateRef,
1594+ Option.some({
1595+ ...gate.value,
1596+ lastActivityAtMillis,
1597+ }),
1598+ );
1599+ }
4301600 return;
4311601 }
4321602 if (sessionUpdateIsReplay(notification)) {
@@ -550,20 +1720,121 @@ export const make = ( · 117 unmodified lines
5501720 ): Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError> =>
5511721 Effect.gen(function* () {
5521722 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+ }
5531748 const nextState = {
5541749 sessionId,
5551750 initializeResult: current.initializeResult,
5561751 sessionSetupResult,
557− modelConfigId: extractModelConfigId(sessionSetupResult),
1752+ modelConfigId: nextModelConfigId,
5581753 } satisfies AcpStartedState;
559− yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult));
560− yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult));
5611754 yield* Ref.set(toolCallsRef, new Map());
5621755 yield* Ref.set(assistantSegmentRef, { nextSegmentIndex: 0 });
5631756 yield* Ref.set(startStateRef, { _tag: "Started", result: nextState });
5641757 return nextState;
5651758 });
5661759
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+
5671838 const setConfigOption = (
5681839 configId: string,
5691840 value: string | boolean,
@@ -669,27 +1940,9 @@ export const make = ( · 99 unmodified lines
6691940 cwd: options.cwd,
6701941 mcpServers: options.mcpServers ?? [],
6711942 } satisfies EffectAcpSchema.LoadSessionRequest;
672− const resumed = yield* runLoggedRequest(
673− "session/load",
674− loadPayload,
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− }
1943+
1944+ sessionId = options.resumeSessionId;
1945+ sessionSetupResult = yield* runLoadSessionWithReplayIdle(loadPayload, initializeResult);
6931946 } else {
6941947 const createPayload = {
6951948 cwd: options.cwd,
@@ -760,6 +2013,14 @@ export const make = ( · 64 unmodified lines
7602013 });
7612014
7622015 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",
7632024 handleRequestPermission: acp.handleRequestPermission,
7642025 handleElicitation: acp.handleElicitation,
7652026 handleReadTextFile: acp.handleReadTextFile,
@@ -782,17 +2043,13 @@ export const make = ( · 16 unmodified lines
7822043 getConfigOptions: Ref.get(configOptionsRef),
7832044 loadSession: (sessionId, activationOptions) =>
7842045 start.pipe(
785− Effect.flatMap(() => {
2046+ Effect.flatMap((started) => {
7862047 const requestPayload = {
7872048 sessionId,
7882049 cwd: options.cwd,
7892050 mcpServers: activationOptions?.mcpServers ?? options.mcpServers ?? [],
7902051 } satisfies EffectAcpSchema.LoadSessionRequest;
791− return runLoggedRequest(
792− "session/load",
793− requestPayload,
794− acp.agent.loadSession(requestPayload),
795− );
2052+ return runLoadSessionWithReplayIdle(requestPayload, started.initializeResult);
7962053 }),
7972054 Effect.flatMap((response) => adoptSession(sessionId, response)),
7982055 ),
@@ -914,6 +2171,7 @@ export const make = ( · 115 unmodified lines
9142171 }),
9152172 ),
9162173 ),
2174+ ...(options.ownDetachedProcessGroup === true ? { terminateProcessGroup } : {}),
9172175 setMode: (modeId) =>
9182176 Ref.get(modeStateRef).pipe(
9192177 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
55 import {
66 applyGrokAcpModelSelection,
77 buildGrokAcpSpawnInput,
8− grokAcpSpawnArgs,
9− isValidGrokReasoningEffortToken,
8+ grokAcpRuntimeProcessOwnership,
109 resolveGrokAcpBaseModelId,
1110 } from "./GrokAcpSupport.ts";
1211
12+describe("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+
1335 describe("resolveGrokAcpBaseModelId", () => {
1436 it("normalizes empty and custom Grok model ids", () => {
1537 expect(resolveGrokAcpBaseModelId(undefined)).toBe("grok-build");
@@ -18,35 +40,6 @@ describe("resolveGrokAcpBaseModelId", () => { · 2 unmodified lines
1840 });
1941 });
2042
21−describe("grokAcpSpawnArgs", () => {
22− it("inherits the Grok CLI config when no T3 runtime mode is set", () => {
23− expect(grokAcpSpawnArgs()).toEqual(["agent", "stdio"]);
24− });
25−
26− it("forces Grok to ask when T3 is Supervised", () => {
27− expect(grokAcpSpawnArgs("approval-required")).toEqual([
28− "--permission-mode",
29− "default",
30− "agent",
31− "stdio",
32− ]);
33− });
34−
35− it("maps Full access to Grok always-approve", () => {
36− expect(grokAcpSpawnArgs("full-access")).toEqual(["agent", "--always-approve", "stdio"]);
37− });
38−
39− it("maps Auto-accept edits and Auto onto Grok permission modes", () => {
40− expect(grokAcpSpawnArgs("auto-accept-edits")).toEqual([
41− "--permission-mode",
42− "acceptEdits",
43− "agent",
44− "stdio",
45− ]);
46− expect(grokAcpSpawnArgs("auto")).toEqual(["--permission-mode", "auto", "agent", "stdio"]);
47− });
48−});
49−
5043 describe("buildGrokAcpSpawnInput", () => {
5144 it("passes the T3 Code referrer through Grok OAuth env", () => {
5245 const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", {
@@ -64,38 +57,15 @@ describe("buildGrokAcpSpawnInput", () => { · 11 unmodified lines
6457 },
6558 });
6659 });
67−
68− it("puts Supervised on the Grok argv so config always-approve cannot win", () => {
69− const spawn = buildGrokAcpSpawnInput(
70− { binaryPath: "/usr/local/bin/grok" },
71− "/tmp/project",
72− undefined,
73− "approval-required",
74− );
75− expect(spawn.args).toEqual(["--permission-mode", "default", "agent", "stdio"]);
76− });
77−});
78−
79−describe("isValidGrokReasoningEffortToken", () => {
80− it("accepts future ACP tokens and rejects malformed metadata values", () => {
81− expect(isValidGrokReasoningEffortToken("xhigh")).toBe(true);
82− expect(isValidGrokReasoningEffortToken("turbo_v2")).toBe(true);
83− expect(isValidGrokReasoningEffortToken("not a token")).toBe(false);
84− expect(isValidGrokReasoningEffortToken("-leading-dash")).toBe(false);
85− expect(isValidGrokReasoningEffortToken("x".repeat(33))).toBe(false);
86− });
8760 });
8861
8962 describe("applyGrokAcpModelSelection", () => {
9063 const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => {
91− const modelCalls: Array<{
92− modelId: string;
93− meta?: { readonly [key: string]: unknown } | null;
94− }> = [];
64+ const modelCalls: Array<string> = [];
9565 const runtime = {
96− setSessionModel: (modelId: string, meta?: { readonly [key: string]: unknown } | null) =>
66+ setSessionModel: (modelId: string) =>
9767 Effect.gen(function* () {
98− modelCalls.push(meta === undefined ? { modelId } : { modelId, meta });
68+ modelCalls.push(modelId);
9969 if (failure) return yield* failure;
10070 return {};
10171 }),
@@ -112,58 +82,11 @@ describe("applyGrokAcpModelSelection", () => { · 10 unmodified lines
11282 requestedModelId: "grok-mock-alt",
11383 mapError: (cause) => cause.message,
11484 });
115− expect(modelCalls).toEqual([{ modelId: "grok-mock-alt" }]);
85+ expect(modelCalls).toEqual(["grok-mock-alt"]);
11686 expect(result).toBe("grok-mock-alt");
11787 }),
11888 );
11989
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−
16790 it.effect("skips set_model when requested matches current", () =>
16891 Effect.gen(function* () {
16992 const { runtime, modelCalls } = makeRecordingRuntime();
apps/server/src/provider/acp/GrokAcpSupport.ts+29−82
@@ -1,4 +1,5 @@
1−import { type GrokSettings, ProviderDriverKind, type RuntimeMode } from "@t3tools/contracts";
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts";
23 import * as Crypto from "effect/Crypto";
34 import * as Effect from "effect/Effect";
45 import * as Layer from "effect/Layer";
@@ -6,6 +7,7 @@ import * as Scope from "effect/Scope"; · 1 unmodified line
67 import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
78 import * as EffectAcpErrors from "effect-acp/errors";
89 import type * as EffectAcpSchema from "effect-acp/schema";
10+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
911 import { normalizeModelSlug } from "@t3tools/shared/model";
1012
1113 import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
@@ -24,33 +26,16 @@ interface GrokAcpRuntimeInput extends Omit<AcpSessionRuntimeOptions, "authMethod · 12 unmodified lines
2426 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
2527 readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined;
2628 readonly environment?: NodeJS.ProcessEnv;
27− readonly runtimeMode?: RuntimeMode;
28−}
29−
30−export 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− }
4329 }
4430
4531 export function buildGrokAcpSpawnInput(
4632 grokSettings: GrokAcpRuntimeGrokSettings | null | undefined,
4733 cwd: string,
4834 environment?: NodeJS.ProcessEnv,
49− runtimeMode?: RuntimeMode,
5035 ): AcpSessionRuntime.AcpSpawnInput {
5136 return {
5237 command: grokSettings?.binaryPath || "grok",
53− args: [...grokAcpSpawnArgs(runtimeMode)],
38+ args: ["agent", "stdio"],
5439 cwd,
5540 env: {
5641 ...environment,
@@ -65,6 +50,21 @@ function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): st · 8 unmodified lines
6550 : GROK_AUTH_METHOD_CACHED_TOKEN;
6651 }
6752
53+export 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+
6868 export const makeGrokAcpRuntime = (
6969 input: GrokAcpRuntimeInput,
7070 ): Effect.Effect<
@@ -73,16 +73,15 @@ export const makeGrokAcpRuntime = ( · 2 unmodified lines
7373 Crypto.Crypto | Scope.Scope
7474 > =>
7575 Effect.gen(function* () {
76+ const processGroupPlatform = yield* HostProcessPlatform.pipe(
77+ Effect.provide(NodeServices.layer),
78+ );
7679 const acpContext = yield* Layer.build(
7780 AcpSessionRuntime.layer({
7881 ...input,
79− spawn: buildGrokAcpSpawnInput(
80− input.grokSettings,
81− input.cwd,
82− input.environment,
83− input.runtimeMode,
84− ),
82+ spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment),
8583 authMethodId: resolveGrokAuthMethodId(input.environment),
84+ ...grokAcpRuntimeProcessOwnership(processGroupPlatform),
8685 }).pipe(
8786 Layer.provide(
8887 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner),
@@ -107,17 +106,6 @@ export function resolveGrokAcpBaseModelId(model: string | null | undefined): str · 18 unmodified lines
107106 return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? GROK_DEFAULT_MODEL_SLUG;
108107 }
109108
110−const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i;
111−
112−export function isValidGrokReasoningEffortToken(value: string): boolean {
113− return GROK_REASONING_EFFORT_TOKEN.test(value);
114−}
115−
116−export function normalizeGrokReasoningEffort(value: string | undefined): string | undefined {
117− const effort = value?.trim();
118− return effort && isValidGrokReasoningEffortToken(effort) ? effort : undefined;
119−}
120−
121109 export function currentGrokModelIdFromSessionSetup(
122110 sessionSetupResult:
123111 | EffectAcpSchema.LoadSessionResponse
@@ -127,59 +115,18 @@ export function currentGrokModelIdFromSessionSetup( · 3 unmodified lines
127115 return sessionSetupResult.models?.currentModelId?.trim() || undefined;
128116 }
129117
130−export 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−
153118 export function applyGrokAcpModelSelection<E>(input: {
154119 readonly runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "setSessionModel">;
155120 readonly currentModelId: string | undefined;
156− readonly currentReasoningEffort?: string | undefined;
157121 readonly requestedModelId: string | undefined;
158− readonly requestedReasoningEffort?: string | undefined;
159122 readonly mapError: (cause: EffectAcpErrors.AcpError) => E;
160123 }): Effect.Effect<string | undefined, E> {
161− // The product slug is never sent over the wire; it keeps the session's current model.
162− const requestedModelId =
163− input.requestedModelId === GROK_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId;
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) {
124+ const shouldSwitchModel =
125+ input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId;
126+ if (!shouldSwitchModel) {
173127 return Effect.succeed(input.currentModelId);
174128 }
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.
182129 return input.runtime
183− .setSessionModel(targetModelId, reasoningMeta)
184− .pipe(Effect.mapError(input.mapError), Effect.as(targetModelId));
130+ .setSessionModel(input.requestedModelId)
131+ .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId));
185132 }
apps/server/src/provider/acp/XAiAcpExtension.test.ts+1086−57
@@ -1,42 +1,77 @@
1−// @effect-diagnostics nodeBuiltinImport:off
2−import * as NodePath from "node:path";
3−import * as NodeURL from "node:url";
4−
5−import * as NodeServices from "@effect/platform-node/NodeServices";
61 import { it } from "@effect/vitest";
2+import * as Deferred from "effect/Deferred";
73 import * as Effect from "effect/Effect";
4+import * as Fiber from "effect/Fiber";
85 import * as Schema from "effect/Schema";
96 import { describe, expect } from "vite-plus/test";
107
118 import {
12− extractXAiAskUserQuestions,
9+ extractXAiAcpBackgroundToolMutation,
10+ extractXAiAcpSubagentEndNotice,
1311 extractXAiAcpSubagentUpdate,
12+ extractXAiAskUserQuestions,
13+ extractXAiBackgroundTaskCompletion,
14+ extractXAiKilledBackgroundTasks,
15+ extractXAiMonitorTaskId,
16+ isGenericAcpToolTitle,
17+ isXAiMonitorTool,
18+ isXAiPersistentMonitor,
1419 makeXAiAskUserQuestionCancelledResponse,
1520 makeXAiAskUserQuestionResponse,
1621 makeXAiPromptCompletionRuntime,
22+ normalizeXAiAcpToolCallState,
23+ resolveXAiAcpToolTitle,
24+ xAiBackgroundTaskLifecycleMutation,
25+ xAiPromptCompleteFromSessionUpdate,
1726 XAiAskUserQuestionRequest,
1827 } from "./XAiAcpExtension.ts";
1928 import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
2029
21−const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
22−const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
30+const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest);
2331
24−const makePromptCompletionRuntime = (env: NodeJS.ProcessEnv) =>
25− Effect.gen(function* () {
26− const runtime = yield* AcpSessionRuntime.make({
27− spawn: {
28− command: process.execPath,
29− args: [mockAgentPath],
30− env,
31− },
32− cwd: process.cwd(),
33− clientInfo: { name: "t3-test", version: "0.0.0" },
34− authMethodId: "test",
32+describe("xAiPromptCompleteFromSessionUpdate", () => {
33+ it("maps live turn_completed snake_case payloads", () => {
34+ expect(
35+ xAiPromptCompleteFromSessionUpdate({
36+ sessionId: "019f4428-4bf1-7e52-b7c6-c29b506543b1",
37+ update: {
38+ sessionUpdate: "turn_completed",
39+ prompt_id: "t3-xai-prompt-1",
40+ stop_reason: "end_turn",
41+ },
42+ }),
43+ ).toEqual({
44+ sessionId: "019f4428-4bf1-7e52-b7c6-c29b506543b1",
45+ promptId: "t3-xai-prompt-1",
46+ stopReason: "end_turn",
3547 });
36− return yield* makeXAiPromptCompletionRuntime(runtime);
3748 });
3849
39−const 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+});
4075
4176 describe("XAiAcpExtension", () => {
4277 it("recognizes Grok Task starts as native subagents", () => {
@@ -62,6 +97,7 @@ describe("XAiAcpExtension", () => { · 19 unmodified lines
6297 status: "running",
6398 childSessionId: null,
6499 result: null,
100+ suppressNormalTool: true,
65101 });
66102 });
67103
@@ -95,6 +131,792 @@ describe("XAiAcpExtension", () => { · 27 unmodified lines
95131 status: "completed",
96132 childSessionId: "019f0220-e192-7c41-9e9d-b406bc3459c8",
97133 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",
98920 });
99921 });
100922
@@ -335,58 +1157,265 @@ describe("XAiAcpExtension", () => { · 234 unmodified lines
3351157 });
3361158 });
3371159
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", () =>
3391161 Effect.gen(function* () {
340− const runtime = yield* makePromptCompletionRuntime({
341− T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1",
342− });
343− yield* runtime.start();
1162+ const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1163+ const hungPrompt = yield* Deferred.make<never>();
1164+ const baseRuntime = {
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"];
3441184
345− const promptResult = yield* runtime.prompt({
346− prompt: [{ type: "text", text: "hi" }],
1185+ const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
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",
3471195 });
348− const promptId = promptResult._meta?.promptId;
1196+ const response = yield* Fiber.join(promptFiber);
1197+ expect(response.stopReason).toBe("end_turn");
1198+ }),
1199+ );
3491200
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;
3501236 expect(typeof promptId).toBe("string");
351− expect(promptResult).toMatchObject({
352− stopReason: "end_turn",
353− _meta: {
354− sessionId: "mock-session-1",
355− promptId,
356− requestId: promptId,
1237+ const sessionUpdateHandler = handlers.get("_x.ai/session/update");
1238+ expect(sessionUpdateHandler).toBeDefined();
1239+ yield* sessionUpdateHandler!({
1240+ sessionId: "root-session",
1241+ update: {
1242+ sessionUpdate: "turn_completed",
1243+ prompt_id: promptId,
1244+ stop_reason: "end_turn",
3571245 },
3581246 });
359− }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1247+ const response = yield* Fiber.join(promptFiber);
1248+ expect(response.stopReason).toBe("end_turn");
1249+ }),
3601250 );
3611251
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", () =>
3631253 Effect.gen(function* () {
364− const runtime = yield* makePromptCompletionRuntime({
365− T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG: "1",
366− });
367− yield* runtime.start();
1254+ const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>();
1255+ const hungPrompt = yield* Deferred.make<never>();
1256+ const baseRuntime = {
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"];
3681276
369− const firstPromptResult = yield* runtime.prompt({
370− prompt: [{ type: "text", text: "first" }],
1277+ const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
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+ },
3711291 });
372− expect(firstPromptResult).toMatchObject({
373− stopReason: "end_turn",
374− _meta: { promptId: "mock-stale-xai-prompt-1" },
1292+ yield* sessionUpdateHandler!({
1293+ sessionId: "root-session",
1294+ update: {
1295+ sessionUpdate: "turn_completed",
1296+ prompt_id: "some-other-cli-turn",
1297+ stop_reason: "end_turn",
1298+ },
3751299 });
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"];
3761330
377− const secondPromptResult = yield* runtime.prompt({
378− prompt: [{ type: "text", text: "second" }],
1331+ const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime);
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",
3791340 });
380− const secondPromptId = secondPromptResult._meta?.promptId;
381− expect(typeof secondPromptId).toBe("string");
382− expect(secondPromptId).not.toBe("mock-stale-xai-prompt-1");
383− expect(secondPromptResult).toMatchObject({
384− stopReason: "end_turn",
385− _meta: {
386− promptId: secondPromptId,
387− requestId: secondPromptId,
1341+ yield* Effect.yieldNow;
1342+ expect(promptFiber.pollUnsafe()).toBeUndefined();
1343+ yield* Fiber.interrupt(promptFiber);
1344+ }),
1345+ );
1346+
1347+ it.effect(
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 });
3881405 },
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,
3891418 });
390− }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
1419+ }),
3911420 );
3921421 });
apps/server/src/provider/acp/XAiAcpExtension.ts+875−398
@@ -1,16 +1,19 @@
1−import * as NodeOS from "node:os";
2−
31 import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts";
2+import * as Cause from "effect/Cause";
43 import * as Deferred from "effect/Deferred";
54 import * as Effect from "effect/Effect";
5+import * as Fiber from "effect/Fiber";
66 import * as Ref from "effect/Ref";
77 import * as Schema from "effect/Schema";
8−import * as EffectAcpErrors from "effect-acp/errors";
8+import type * as EffectAcpErrors from "effect-acp/errors";
99 import type * as EffectAcpSchema from "effect-acp/schema";
1010
1111 import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
1212 import type { AcpToolCallState } from "./AcpRuntimeModel.ts";
1313
14+const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing";
15+const completedXAiPromptIdLimit = 128;
16+
1417 const XAiPromptCompleteNotification = Schema.Struct({
1518 sessionId: Schema.String,
1619 promptId: Schema.optional(Schema.String),
@@ -20,19 +23,60 @@ const XAiPromptCompleteNotification = Schema.Struct({ · 3 unmodified lines
2023
2124 type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type;
2225
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+ */
32+const 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+
44+type XAiSessionUpdateNotification = typeof XAiSessionUpdateNotification.Type;
45+
46+const 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+ */
53+export 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+
2374 interface PendingXAiPromptCompletion {
2475 readonly sessionId: string;
2576 readonly promptId: string;
26− readonly deferred: Deferred.Deferred<
27− EffectAcpSchema.PromptResponse,
28− EffectAcpErrors.AcpRequestError
29− >;
77+ readonly deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse>;
3078 }
3179
32−const completedXAiPromptIdLimit = 128;
33−const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing";
34−const xAiRateLimitedErrorCode = -32003;
35−
3680 export interface XAiAcpSubagentUpdate {
3781 readonly nativeTaskId: string;
3882 readonly prompt: string;
@@ -41,8 +85,26 @@ export interface XAiAcpSubagentUpdate { · 2 unmodified lines
4185 readonly status: "running" | "completed" | "failed";
4286 readonly childSessionId: string | null;
4387 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+
95+export interface XAiAcpBackgroundToolMutation {
96+ readonly taskId: string;
97+ readonly status: "running" | "completed" | "failed";
98+ readonly appendOutput: string;
99+}
100+
101+export interface XAiAcpSubagentEndNotice {
102+ readonly childSessionId: string;
103+ readonly status: "completed" | "failed";
44104 }
45105
106+const XAI_UUID_RE = "[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}";
107+
46108 function unknownRecord(value: unknown): Record<string, unknown> | undefined {
47109 return typeof value === "object" && value !== null && !Array.isArray(value)
48110 ? (value as Record<string, unknown>)
@@ -53,10 +115,42 @@ function nonEmptyString(value: unknown): string | undefined { · 4 unmodified lines
53115 return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
54116 }
55117
56−function xAiTaskOutputText(toolCall: AcpToolCallState): string | undefined {
118+function titleKey(toolCall: AcpToolCallState): string {
119+ return (toolCall.title ?? "").trim().toLowerCase();
120+}
121+
122+function 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+
134+function xAiToolOutputText(toolCall: AcpToolCallState): string | undefined {
57135 const rawOutput = unknownRecord(toolCall.data.rawOutput);
58− const rawOutputText = nonEmptyString(rawOutput?.text);
59− if (rawOutputText !== undefined) return rawOutputText;
136+ if (rawOutput !== undefined) {
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+ }
60154 const content = toolCall.data.content;
61155 if (!Array.isArray(content)) return undefined;
62156 const text = content
@@ -70,41 +164,631 @@ function xAiTaskOutputText(toolCall: AcpToolCallState): string | undefined { · 7 unmodified lines
70164 return text.length > 0 ? text : undefined;
71165 }
72166
167+function firstUuidMatch(text: string, pattern: RegExp): string | undefined {
168+ const match = pattern.exec(text);
169+ return match?.[1];
170+}
171+
172+function 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+
182+function 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+
191+function isXAiMonitorStartAck(output: string | undefined): boolean {
192+ if (output === undefined) return false;
193+ return /monitor started\s*\(\s*task\s+/i.test(output);
194+}
195+
196+function 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+
213+function 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+
235+export 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+
249+export 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+ */
277+export 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+ */
291+export 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+ */
331+export 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+
358+const 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+
373+type XAiTaskLifecycleNotification = typeof XAiTaskLifecycleNotification.Type;
374+
375+export interface XAiBackgroundTaskLifecycleMutation {
376+ readonly sessionId: string;
377+ readonly taskId: string;
378+ readonly status: "running" | "completed";
379+}
380+
381+export 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+ */
403+export 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+ */
431+export 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+
436+export 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+ */
476+export 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+ */
520+export 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+ */
588+function 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+
603+export 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+
622+function 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). */
644+function 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+
667+function 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+
692+function 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+
73720 /**
74− * Recognizes Grok's private `Task` tool envelope without teaching the generic
75− * ACP adapter about xAI tool names or result formatting.
721+ * Recognizes Grok Task / spawn_subagent envelopes and get_command hydration
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.
76727 */
77728 export function extractXAiAcpSubagentUpdate(
78729 toolCall: AcpToolCallState,
79730 ): XAiAcpSubagentUpdate | undefined {
80731 const rawInput = unknownRecord(toolCall.data.rawInput);
81− const isTask =
82− toolCall.title === "Task" ||
83− rawInput?.variant === "CursorTask" ||
84− nonEmptyString(rawInput?.subagent_type) !== undefined;
85− if (!isTask || rawInput === undefined) return undefined;
86−
87− const output = xAiTaskOutputText(toolCall);
88− const childSessionId =
89− output?.match(/(?:^|\n)Agent ID:\s*([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\b/i)?.[1] ??
90− null;
91− const result =
92− output?.replace(/(?:^|\n)Agent ID:\s*[0-9a-f-]+[^\n]*(?:\n|$)/gi, "\n").trim() || null;
93− const status =
94− toolCall.status === "failed"
95− ? "failed"
96− : toolCall.status === "completed"
97− ? "completed"
98− : "running";
732+ const output = xAiToolOutputText(toolCall);
733+
734+ if (isXAiGetSubagentOutputTool(toolCall, rawInput)) {
735+ const taskIds = taskIdsFromGetOutputTool(toolCall, rawInput);
736+ // Prefer the completed task identity from the result/header over the first
737+ // requested task_ids entry (one call can poll multiple task_ids).
738+ const childSessionId =
739+ resultTaskIdFromGetOutputTool(toolCall, output) ??
740+ taskIds[0] ??
741+ extractXAiChildSessionId(output);
742+ if (childSessionId === null) return undefined;
743+ // Prefer the durable subagent row as the completion surface (Claude/Codex
744+ // style). Suppress the noisy TaskOutput tool card; monitor hydration is
745+ // handled separately via extractBackgroundTaskCompletion.
746+ return {
747+ nativeTaskId: toolCall.toolCallId,
748+ prompt: "",
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+ }
99782
100783 return {
101784 nativeTaskId: toolCall.toolCallId,
102− prompt: nonEmptyString(rawInput.prompt) ?? "",
103− title: nonEmptyString(rawInput.description) ?? null,
104− model: nonEmptyString(rawInput.model) ?? null,
785+ prompt: nonEmptyString(rawInput?.prompt) ?? "",
786+ title: nonEmptyString(rawInput?.description) ?? null,
787+ model: nonEmptyString(rawInput?.model) ?? null,
105788 status,
106789 childSessionId,
107− result,
790+ result: asyncSpawnAck ? null : legacyResult,
791+ suppressNormalTool: true,
108792 };
109793 }
110794
@@ -290,301 +974,57 @@ export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCan · 179 unmodified lines
290974 return { outcome: "cancelled" };
291975 }
292976
293−// ---------------------------------------------------------------------------
294−// x.ai/exit_plan_mode — plan approval gate (mirrors Grok Build TUI plan window)
295−// ---------------------------------------------------------------------------
296−
297−const XAiExitPlanModeParams = Schema.Struct({
298− sessionId: Schema.String,
299− toolCallId: Schema.String,
300− planContent: Schema.optional(Schema.NullOr(Schema.String)),
301−});
302−
303−const XAiWrappedExitPlanModeParams = Schema.Struct({
304− method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]),
305− params: XAiExitPlanModeParams,
306−});
307−
308−export const XAiExitPlanModeRequest = Schema.Union([
309− XAiExitPlanModeParams,
310− XAiWrappedExitPlanModeParams,
311−]);
312−
313−type XAiExitPlanModeRequestParams = typeof XAiExitPlanModeParams.Type;
314−type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type;
315−
316−function 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. */
321−export const XAI_EMPTY_PLAN_MARKDOWN =
322− "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)";
323−
324−export 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;
977+function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined {
978+ const meta = response._meta;
979+ if (meta === null || typeof meta !== "object") {
980+ return undefined;
336981 }
337− return XAI_EMPTY_PLAN_MARKDOWN;
338−}
339−
340−export type XAiExitPlanModeOutcome = "approved" | "abandoned" | "request_changes";
341−
342−export 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− */
351−export 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−
360−function normalizeFsPath(value: string): string {
361− return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
362−}
363−
364−function pathHasTraversalSegment(normalized: string): boolean {
365− return normalized.split("/").includes("..");
982+ const promptId = meta.promptId ?? meta.requestId;
983+ return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined;
366984 }
367985
368−function addGrokSessionPrefix(
369− prefixes: Set<string>,
370− homeOrRoot: string,
371− nestedGrokDir: boolean,
372−): void {
373− const root = normalizeFsPath(homeOrRoot);
374− if (!root) {
375− return;
986+function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.StopReason {
987+ switch (value) {
988+ case "cancelled":
989+ case "end_turn":
990+ case "max_tokens":
991+ case "max_turn_requests":
992+ case "refusal":
993+ return value;
994+ default:
995+ return "end_turn";
376996 }
377− prefixes.add(nestedGrokDir ? `${root}/.grok/sessions/` : `${root}/sessions/`);
378−}
379−
380−/** Injected host bits so these helpers stay off `process.platform` / `process.env`. */
381−export interface GrokPlanPathHost {
382− readonly platform: NodeJS.Platform;
383− readonly environment: NodeJS.ProcessEnv;
384−}
385−
386−function 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;
398997 }
399998
400−const CANONICAL_HOME_GROK_SESSION_PATH =
401− /^(?:\/home\/[^/]+|\/Users\/[^/]+|[a-zA-Z]:\/Users\/[^/]+)\/\.grok\/sessions\/(?:[^/]+\/)+plan\.md$/;
402−const CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH = new RegExp(
403− CANONICAL_HOME_GROK_SESSION_PATH.source,
404− "i",
405−);
406−
407−/**
408− * True when a path is Grok's session plan file under a Grok home
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− */
413−export 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;
999+function promptResponseFromXAi(
1000+ notification: XAiPromptCompleteNotification,
1001+): EffectAcpSchema.PromptResponse {
1002+ const stopReason = normalizeXAiStopReason(notification.stopReason);
1003+ const meta: Record<string, unknown> = {
1004+ sessionId: notification.sessionId,
1005+ };
1006+ if (notification.stopReason === undefined) {
1007+ meta[xAiStopReasonMissingMetaKey] = true;
4291008 }
430− for (const prefix of grokPlanSessionPrefixes(host.environment)) {
431− const needle = win32 ? prefix.toLowerCase() : prefix;
432− if (!haystack.startsWith(needle)) {
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− }
1009+ if (notification.promptId !== undefined) {
1010+ meta.promptId = notification.promptId;
1011+ meta.requestId = notification.promptId;
4401012 }
441− return (
442− win32 ? CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH : CANONICAL_HOME_GROK_SESSION_PATH
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− */
450−export function extractGrokPlanMarkdownFromToolCallData(
451− data: Record<string, unknown> | undefined,
452− host: GrokPlanPathHost,
453−): string | undefined {
454− if (!data) {
455− return undefined;
1013+ if (notification.agentResult !== undefined) {
1014+ meta.agentResult = notification.agentResult;
4561015 }
457−
458− let sawPlanWrite = false;
459− const takePlanText = (
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;
1016+ return {
1017+ stopReason,
1018+ _meta: meta,
4691019 };
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−
501−function isRecord(value: unknown): value is Record<string, unknown> {
502− return typeof value === "object" && value !== null && !Array.isArray(value);
5031020 }
5041021
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− */
509−export 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−
5821022 const registerXAiPromptCompletionFallback = (
5831023 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,
5841024 sessionId: string,
5851025 promptId: string,
5861026 ) =>
587− Deferred.make<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>().pipe(
1027+ Deferred.make<EffectAcpSchema.PromptResponse>().pipe(
5881028 Effect.tap((deferred) =>
5891029 Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]),
5901030 ),
@@ -593,7 +1033,7 @@ const registerXAiPromptCompletionFallback = ( · 2 unmodified lines
5931033
5941034 const unregisterXAiPromptCompletionFallback = (
5951035 pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>,
596− deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>,
1036+ deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse>,
5971037 ) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred));
5981038
5991039 const abortPendingPromptCompletions = (
@@ -655,7 +1095,15 @@ const resolveXAiPromptCompletionFallback = ({ · 55 unmodified lines
6551095 entry.sessionId === notification.sessionId &&
6561096 entry.promptId === notification.promptId,
6571097 )
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+ })();
6591107 if (index < 0) {
6601108 return [Effect.void, pending] as const;
6611109 }
@@ -664,54 +1112,22 @@ const resolveXAiPromptCompletionFallback = ({ · 2 unmodified lines
6641112 return [Effect.void, pending] as const;
6651113 }
6661114 return [
667− settleXAiPromptCompletion(entry.deferred, notification),
1115+ Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid),
6681116 [...pending.slice(0, index), ...pending.slice(index + 1)],
6691117 ] as const;
6701118 }).pipe(Effect.flatten);
6711119 }),
6721120 );
6731121
674−const 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−
698−function 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−
7091122 const rememberCompletedXAiPromptId = (
7101123 completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>,
7111124 response: EffectAcpSchema.PromptResponse,
7121125 fallbackPromptId: string,
7131126 ) => {
7141127 const promptId = promptIdFromResponse(response) ?? fallbackPromptId;
1128+ if (promptId.length === 0) {
1129+ return Effect.void;
1130+ }
7151131 return Ref.update(completedPromptIdsRef, (completedPromptIds) => {
7161132 if (completedPromptIds.includes(promptId)) {
7171133 return completedPromptIds;
@@ -720,14 +1136,111 @@ const rememberCompletedXAiPromptId = ( · 2 unmodified lines
7201136 });
7211137 };
7221138
723−function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined {
724− const meta = response._meta;
725− if (meta === null || typeof meta !== "object") {
726− return undefined;
727− }
728− const promptId = meta.promptId ?? meta.requestId;
729− return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined;
730−}
1139+/**
1140+ * Grok-specific ACP runtime wrapper. Races `session/prompt` against root-matched
1141+ * terminal notifications:
1142+ * - `_x.ai/session/update` + `turn_completed` + matching `prompt_id` (current CLI)
1143+ * - `_x.ai/session/prompt_complete` (legacy / alternate builds)
1144+ *
1145+ * Pending entries are keyed by root sessionId + T3-injected promptId, so
1146+ * foreign/child sessions and `task-completed-*` ids do not settle the root turn.
1147+ */
1148+export 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+);
7311244
7321245 export function promptResponseHasMissingXAiStopReason(
7331246 response: EffectAcpSchema.PromptResponse,
@@ -735,39 +1248,3 @@ export function promptResponseHasMissingXAiStopReason( · 1 unmodified line
7351248 const meta = response._meta;
7361249 return meta !== null && typeof meta === "object" && meta[xAiStopReasonMissingMetaKey] === true;
7371250 }
738−
739−function 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−
762−function 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 @@
1−import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, RunId } from "@t3tools/contracts";
1+import {
2+ EnvironmentId,
3+ MessageId,
4+ ProjectId,
5+ ProviderInstanceId,
6+ ThreadId,
7+ RunId,
8+ TurnItemId,
9+ type OrchestrationV2ProjectedTurnItem,
10+} from "@t3tools/contracts";
11+import * as DateTime from "effect/DateTime";
212 import { describe, expect, it } from "vite-plus/test";
313
414 import type { Thread } from "../types";
@@ -11,27 +21,17 @@ import { · 6 unmodified lines
1121 buildLoadingThreadFromShell,
1222 buildThreadTurnInterruptInput,
1323 createLocalDispatchSnapshot,
24+ deriveCommittedServerUserMessageIds,
1425 deriveComposerSendState,
1526 dismissBranchMismatchForSession,
16− ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
1727 getStartedThreadModelChangeBlockReason,
18− isVideoPreviewRequestCurrent,
19− hasEnvironmentReconnectWarningGraceElapsed,
2028 hasServerAcknowledgedLocalDispatch,
2129 isBranchMismatchDismissedForSession,
2230 reconcileMountedTerminalThreadIds,
2331 reconcileRetainedMountedThreadIds,
24− resolveBackgroundDraftWorkspaceOptions,
25− resolveDraftPromotionNavigationTarget,
26− resolveProactiveTurnDiffAction,
2732 resolveThreadMetadataUpdateForNextTurn,
2833 resolveSendEnvMode,
29− resolveDraftHeroState,
30− scheduleEnvironmentReconnectWarning,
3134 startNewThreadForProject,
32− codexArtifactTemplatePromptToAppend,
33− shouldDockDraftHeroForSubmission,
34− shouldReleaseTimelineAnchorForToolActivity,
3535 shouldShowBranchMismatchBanner,
3636 shouldShowPlanFollowUpPrompt,
3737 shouldWriteThreadErrorToCurrentServerThread,
@@ -259,184 +259,6 @@ const helloWorldTemplate: CodexArtifactTemplate = { · 221 unmodified lines
259259 skillName: "artifact-template-hello-world",
260260 };
261261
262−describe("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−
296−describe("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−
404−describe("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−
440262 function makeThread(overrides: Partial<Thread> = {}): Thread {
441263 return makeThreadFixture({
442264 id: threadId,
@@ -800,23 +622,6 @@ describe("resolveSendEnvMode", () => { · 357 unmodified lines
800622 });
801623 });
802624
803−describe("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−
820625 describe("branchMismatchKey", () => {
821626 it("builds a key from thread id and both branches", () => {
822627 expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe(
@@ -1042,29 +847,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 219 unmodified lines
1042847 ).toBe(false);
1043848 });
1044849
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−
1068850 it("acknowledges a settled newer turn", () => {
1069851 const localDispatch = createLocalDispatchSnapshot(
1070852 makeThread({ latestRun: completedTurn, runtime: readySession }),
@@ -1195,3 +977,103 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 124 unmodified lines
1195977 expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true);
1196978 });
1197979 });
980+
981+describe("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
66 isProviderDriverKind,
77 ProjectId,
88 type ModelSelection,
9− type ProviderInteractionMode,
9+ type OrchestrationV2ProjectedTurnItem,
1010 type ProviderDriverKind,
1111 type ServerProvider,
1212 type ScopedProjectRef,
@@ -560,6 +560,22 @@ export function createLocalDispatchSnapshot( · 547 unmodified lines
560560 };
561561 }
562562
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+ */
569+export 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+
563579 export function hasServerAcknowledgedLocalDispatch(input: {
564580 localDispatch: LocalDispatchSnapshot | null;
565581 phase: SessionPhase;
apps/web/src/components/ChatView.tsx+13−276
@@ -140,7 +140,6 @@ import { · 139 unmodified lines
140140 selectThreadPanelOpen,
141141 selectThreadRightPanelState,
142142 type RightPanelSurface,
143− updatePullRequestTabStatus,
144143 useRightPanelStore,
145144 } from "../rightPanelStore";
146145 import {
@@ -157,11 +156,7 @@ import { · 10 unmodified lines
157156 selectThreadPreviewMiniPlayer,
158157 usePreviewMiniPlayerStore,
159158 } from "../previewMiniPlayerStore";
160−import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic";
161−import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel";
162−import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts";
163−import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState";
164−import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs";
159+import { RightPanelTabs } from "./RightPanelTabs";
165160 import { AgentsPanel } from "./AgentsPanel";
166161 import {
167162 deriveAgentPanelModel,
@@ -183,14 +178,9 @@ import { · 15 unmodified lines
183178 projectScriptIdFromCommand,
184179 } from "~/projectScripts";
185180 import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
186−import { useBrowserHistoryStore } from "~/browserHistoryStore";
187181 import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels";
188182 import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances";
189−import {
190− useClientSettings,
191− useClientSettingsHydrated,
192− useEnvironmentSettings,
193−} from "../hooks/useSettings";
183+import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings";
194184 import { useNowMinute } from "../hooks/useNowMinute";
195185 import { useNewThreadHandler } from "../hooks/useHandleNewThread";
196186 import { resolveAppModelSelectionForInstance } from "../modelSelection";
@@ -198,7 +188,6 @@ import { getTerminalFocusOwner } from "../lib/terminalFocus"; · 1 unmodified line
198188 import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
199189 import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
200190 import {
201− derivePhysicalProjectKey,
202191 deriveLogicalProjectKeyFromSettings,
203192 selectProjectGroupingSettings,
204193 } from "../logicalProject";
@@ -311,10 +300,9 @@ import { · 106 unmodified lines
311300 buildThreadTurnInterruptInput,
312301 collectUserMessageBlobPreviewUrls,
313302 createLocalDispatchSnapshot,
303+ deriveCommittedServerUserMessageIds,
314304 deriveComposerSendState,
315305 dismissBranchMismatchForSession,
316− hasEnvironmentReconnectWarningGraceElapsed,
317− scheduleEnvironmentReconnectWarning,
318306 hasServerAcknowledgedLocalDispatch,
319307 isBranchMismatchDismissedForSession,
320308 shouldShowBranchMismatchBanner,
@@ -519,14 +507,6 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { · 198 unmodified lines
519507 if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false;
520508 if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false;
521509
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−
530510 return true;
531511 }
532512
@@ -1297,8 +1277,8 @@ function ChatViewContent(props: ChatViewProps) { · 764 unmodified lines
12971277 routeKind === "server" ? routeThreadRef : null,
12981278 );
12991279 const committedServerMessageIds = useMemo(
1300− () => new Set(serverProjection?.messages.map((message) => message.id) ?? []),
1301− [serverProjection],
1280+ () => deriveCommittedServerUserMessageIds(serverVisibleTurnItems),
1281+ [serverVisibleTurnItems],
13021282 );
13031283 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
13041284 const activeThreadLastVisitedAt = useUiStateStore(
@@ -1636,7 +1616,11 @@ function ChatViewContent(props: ChatViewProps) { · 331 unmodified lines
16361616 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
16371617 const canCheckoutPullRequestIntoThread = isLocalDraftThread;
16381618 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;
16401624 const runningTerminalIds = useThreadRunningTerminalIds({
16411625 environmentId: activeThread?.environmentId ?? null,
16421626 threadId: activeThreadId,
@@ -1672,11 +1656,8 @@ function ChatViewContent(props: ChatViewProps) { · 29 unmodified lines
16721656 return labels;
16731657 }, [activeThreadKnownSessions]);
16741658 const activeThreadRef = useMemo(
1675− () =>
1676− activeThreadEnvironmentId && activeThreadId
1677− ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId)
1678− : null,
1679− [activeThreadEnvironmentId, activeThreadId],
1659+ () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null),
1660+ [activeThread],
16801661 );
16811662 const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null;
16821663 const [timelineAnchor, setTimelineAnchor] = useState<{
@@ -1697,23 +1678,6 @@ function ChatViewContent(props: ChatViewProps) { · 14 unmodified lines
16971678 const activeRightPanelSurface = useRightPanelStore((state) =>
16981679 selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef),
16991680 );
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);
17171681 const activeFileSurface =
17181682 activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null;
17191683 const activePreviewState = useThreadPreviewState(activeThreadRef);
@@ -1847,7 +1811,6 @@ function ChatViewContent(props: ChatViewProps) { · 127 unmodified lines
18471811 const activeProjectKey = activeProject
18481812 ? `${activeProject.environmentId}:${activeProject.workspaceRoot}`
18491813 : null;
1850− const clientSettingsHydrated = useClientSettingsHydrated();
18511814 const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState<
18521815 ReadonlyMap<string, ReadonlySet<string>>
18531816 >(() => new Map());
@@ -1886,54 +1849,11 @@ function ChatViewContent(props: ChatViewProps) { · 32 unmodified lines
18861849 // drive the environment picker in BranchToolbar.
18871850 const allProjects = useProjects();
18881851 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− ]);
19141852 const activeEnvironment =
19151853 activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null);
19161854 const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available";
19171855 const activeEnvironmentUnavailable =
19181856 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]);
19371857 const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null;
19381858 const activeEnvironmentUnavailableState = useMemo<EnvironmentUnavailableState | null>(() => {
19391859 if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) {
@@ -1962,6 +1882,7 @@ function ChatViewContent(props: ChatViewProps) { · 22 unmodified lines
19621882 },
19631883 [retryEnvironment],
19641884 );
1885+ const projectGroupingSettings = selectProjectGroupingSettings(settings);
19651886 const logicalProjectEnvironments = useMemo(() => {
19661887 if (!activeProject) return [];
19671888 const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings);
@@ -2115,8 +2036,6 @@ function ChatViewContent(props: ChatViewProps) { · 147 unmodified lines
21152036 const serverConfig = activeThread
21162037 ? (activeEnvironment?.serverConfig ?? null)
21172038 : (primaryEnvironment?.serverConfig ?? null);
2118− const pullRequestsCapabilityKnown = serverConfig !== null;
2119− const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true;
21202039 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);
21212040 const versionMismatchDismissKey =
21222041 versionMismatch && activeThread
@@ -3266,27 +3185,6 @@ function ChatViewContent(props: ChatViewProps) { · 1143 unmodified lines
32663185 },
32673186 [activeProject, activeThreadRef],
32683187 );
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− );
32903188 const togglePreviewPanel = useCallback(() => {
32913189 if (!activeThreadRef || !isPreviewSupportedInRuntime()) return;
32923190 if (previewPanelOpen) {
@@ -4136,113 +4034,6 @@ function ChatViewContent(props: ChatViewProps) { · 843 unmodified lines
41364034 threadBranch: activeThread?.branch ?? null,
41374035 gitStatus: gitStatusQuery.data ?? null,
41384036 });
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;
42464037 const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true;
42474038 const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true;
42484039 const nowMinute = useNowMinute();
@@ -4965,7 +4756,6 @@ function ChatViewContent(props: ChatViewProps) { · 716 unmodified lines
49654756 "This will discard newer messages and turn diffs in this thread.",
49664757 "This action cannot be undone.",
49674758 ].join("\n"),
4968− { variant: "destructive" },
49694759 );
49704760 if (!confirmed) {
49714761 return;
@@ -6313,47 +6103,6 @@ function ChatViewContent(props: ChatViewProps) { · 1341 unmodified lines
63136103 workspaceMutationId={workspaceMutationId}
63146104 />
63156105 </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− />
63576106 ) : activeRightPanelSurface?.kind === "agents" ? (
63586107 <AgentsPanel
63596108 model={agentPanelModel}
@@ -6838,16 +6587,10 @@ function ChatViewContent(props: ChatViewProps) { · 478 unmodified lines
68386587 onAddTerminal={addTerminalSurface}
68396588 onAddDiff={addDiffSurface}
68406589 onAddFiles={addFilesSurface}
6841− onAddPullRequest={addPullRequestSurface}
68426590 onAddAgents={addAgentsSurface}
68436591 browserAvailable={isPreviewSupportedInRuntime()}
6844− terminalAvailable={activeProject !== null}
68456592 diffAvailable={isServerThread && isGitRepo}
68466593 filesAvailable={activeProject !== null}
6847− pullRequestAvailable={pullRequestSurfaceAvailable}
6848− agentsAvailable
6849− pullRequestStatuses={pullRequestTabStatuses}
6850− liveAgentCount={agentPanelModel.liveCount}
68516594 >
68526595 {rightPanelContent}
68536596 </RightPanelTabs>
@@ -6873,16 +6616,10 @@ function ChatViewContent(props: ChatViewProps) { · 19 unmodified lines
68736616 onAddTerminal={addTerminalSurface}
68746617 onAddDiff={addDiffSurface}
68756618 onAddFiles={addFilesSurface}
6876− onAddPullRequest={addPullRequestSurface}
68776619 onAddAgents={addAgentsSurface}
68786620 browserAvailable={isPreviewSupportedInRuntime()}
6879− terminalAvailable={activeProject !== null}
68806621 diffAvailable={isServerThread && isGitRepo}
68816622 filesAvailable={activeProject !== null}
6882− pullRequestAvailable={pullRequestSurfaceAvailable}
6883− agentsAvailable
6884− pullRequestStatuses={pullRequestTabStatuses}
6885− liveAgentCount={agentPanelModel.liveCount}
68866623 >
68876624 {rightPanelContent}
68886625 </RightPanelTabs>
apps/web/src/components/chat/MessagesTimeline.tsx+20−14
@@ -1369,10 +1369,6 @@ function ProposedPlanTimelineRow({ · 1368 unmodified lines
13691369
13701370 type V2EventTone = "muted" | "warning" | "danger" | "success";
13711371
1372−function subagentDisplayTitle(title: string): string {
1373− return title.replace(/^Subagent:\s*/i, "");
1374−}
1375−
13761372 function v2EventPresentation(item: OrchestrationV2TurnItem): {
13771373 readonly label: string;
13781374 readonly detail: string | null;
@@ -1429,14 +1425,6 @@ function v2EventPresentation(item: OrchestrationV2TurnItem): { · 50 unmodified lines
14291425 icon: MinusIcon,
14301426 };
14311427 }
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− };
14401428 case "approval_request":
14411429 return {
14421430 label: "Approval requested",
@@ -1511,7 +1499,14 @@ function V2EventTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "event" · 68 unmodified lines
15111499 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
15121500 <span className="text-xs font-medium text-foreground/90">{presentation.label}</span>
15131501 {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+ >
15151510 {item.status}
15161511 </span>
15171512 ) : null}
@@ -2303,8 +2298,19 @@ function workEntryPreview( · 785 unmodified lines
23032298 workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">,
23042299 workspaceRoot: string | undefined,
23052300 ) {
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+ }
23062313 if (workEntry.command) return workEntry.command;
2307− if (workEntry.detail) return workEntry.detail;
23082314 if ((workEntry.changedFiles?.length ?? 0) === 0) return null;
23092315 const [firstPath] = workEntry.changedFiles ?? [];
23102316 if (!firstPath) return null;
apps/web/src/keybindings.test.ts+7−0
@@ -594,6 +594,13 @@ describe("chat/editor shortcuts", () => { · 593 unmodified lines
594594 platform: "Linux",
595595 }),
596596 );
597+ assert.isFalse(
598+ isOpenFavoriteEditorShortcut(
599+ event({ key: "o", metaKey: true, repeat: true }),
600+ DEFAULT_BINDINGS,
601+ { platform: "MacIntel" },
602+ ),
603+ );
597604 });
598605
599606 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
1414 type?: string;
1515 code?: string;
1616 key: string;
17+ repeat?: boolean;
1718 metaKey: boolean;
1819 ctrlKey: boolean;
1920 shiftKey: boolean;
@@ -440,7 +441,10 @@ export function isOpenFavoriteEditorShortcut( · 420 unmodified lines
440441 keybindings: ResolvedKeybindingsConfig,
441442 options?: ShortcutMatchOptions,
442443 ): boolean {
443− return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options);
444+ return (
445+ event.repeat !== true &&
446+ matchesCommandShortcut(event, keybindings, "editor.openFavorite", options)
447+ );
444448 }
445449
446450 export function isTerminalClearShortcut(
packages/client-runtime/src/state/orchestrationV2Projection.ts+1−0
@@ -39,6 +39,7 @@ function shouldShowLocalTurnItem( · 38 unmodified lines
3939 item,
4040 runs: projection.runs,
4141 attempts: projection.attempts,
42+ items: projection.turnItems,
4243 });
4344 }
4445
packages/effect-acp/src/client.ts+14−22
@@ -1,5 +1,6 @@
11 import * as Context from "effect/Context";
22 import * as Effect from "effect/Effect";
3+import * as Stdio from "effect/Stdio";
34 import * as Layer from "effect/Layer";
45 import * as Schema from "effect/Schema";
56 import * as Scope from "effect/Scope";
@@ -26,16 +27,10 @@ export interface AcpClientOptions { · 20 unmodified lines
2627 readonly logIncoming?: boolean;
2728 readonly logOutgoing?: boolean;
2829 readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>;
29− /** Transforms child output before protocol logging and parsing. */
30− readonly transformStdout?: (
31− stdout: ChildProcessSpawner.ChildProcessHandle["stdout"],
32− ) => AcpProtocol.AcpStdio["stdin"];
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>;
30+ readonly onIncomingRequest?: AcpProtocol.AcpPatchedProtocolOptions["onIncomingRequest"];
31+ readonly onTermination?: AcpProtocol.AcpPatchedProtocolOptions["onTermination"];
32+ readonly onOutgoingResponseFailure?: AcpProtocol.AcpPatchedProtocolOptions["onOutgoingResponseFailure"];
33+ readonly onOutgoingResponse?: AcpProtocol.AcpPatchedProtocolOptions["onOutgoingResponse"];
3934 }
4035
4136 type AcpClientRaw = {
@@ -316,7 +311,7 @@ interface BufferedNotificationHandler<A> { · 274 unmodified lines
316311 }
317312
318313 export const make = Effect.fn("effect-acp/AcpClient.make")(function* (
319− stdio: AcpProtocol.AcpStdio,
314+ stdio: Stdio.Stdio,
320315 options: AcpClientOptions = {},
321316 terminationError?: Effect.Effect<AcpError.AcpError>,
322317 ): 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
413408 ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}),
414409 ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}),
415410 ...(options.logger ? { logger: options.logger } : {}),
416− ...(options.transformSessionUpdate
417− ? { transformSessionUpdate: options.transformSessionUpdate }
418− : {}),
411+ ...(options.onIncomingRequest ? { onIncomingRequest: options.onIncomingRequest } : {}),
419412 ...(options.onTermination ? { onTermination: options.onTermination } : {}),
413+ ...(options.onOutgoingResponseFailure
414+ ? { onOutgoingResponseFailure: options.onOutgoingResponseFailure }
415+ : {}),
416+ ...(options.onOutgoingResponse ? { onOutgoingResponse: options.onOutgoingResponse } : {}),
420417 onNotification: dispatchNotification,
421418 onExtRequest: dispatchExtRequest,
422419 });
@@ -585,19 +582,14 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( · 162 unmodified lines
585582 });
586583 });
587584
588−export const layer = (
589− stdio: AcpProtocol.AcpStdio,
590− options: AcpClientOptions = {},
591−): Layer.Layer<AcpClient> => Layer.effect(AcpClient, make(stdio, options));
585+export const layer = (stdio: Stdio.Stdio, options: AcpClientOptions = {}): Layer.Layer<AcpClient> =>
586+ Layer.effect(AcpClient, make(stdio, options));
592587
593588 export const layerChildProcess = (
594589 handle: ChildProcessSpawner.ChildProcessHandle,
595590 options: AcpClientOptions = {},
596591 ): Layer.Layer<AcpClient> => {
597− const stdio = {
598− ...makeChildStdio(handle),
599− stdin: options.transformStdout?.(handle.stdout) ?? handle.stdout,
600− };
592+ const stdio = makeChildStdio(handle);
601593 const terminationError = makeTerminationError(handle);
602594 return Layer.effect(AcpClient, make(stdio, options, terminationError));
603595 };
packages/effect-acp/src/protocol.test.ts+200−0
@@ -3,8 +3,12 @@ import * as AcpError from "./errors.ts"; · 2 unmodified lines
33 import * as Effect from "effect/Effect";
44 import * as Deferred from "effect/Deferred";
55 import * as Fiber from "effect/Fiber";
6+import * as Exit from "effect/Exit";
67 import * as Queue from "effect/Queue";
78 import * as Schema from "effect/Schema";
9+import * as Scope from "effect/Scope";
10+import * as Sink from "effect/Sink";
11+import * as Stdio from "effect/Stdio";
812 import * as Stream from "effect/Stream";
913 import * as Ref from "effect/Ref";
1014 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
@@ -533,6 +537,177 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { · 522 unmodified lines
533537 }),
534538 );
535539
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+
536711 it.effect("cleans up interrupted extension requests before a late response arrives", () =>
537712 Effect.gen(function* () {
538713 const { stdio, input, output } = yield* makeInMemoryStdio();
@@ -638,6 +813,31 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { · 99 unmodified lines
638813 }),
639814 );
640815
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+
641841 it.effect("does not emit a second process-exit error after a decode failure", () =>
642842 Effect.gen(function* () {
643843 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
44 import * as Exit from "effect/Exit";
55 import * as Queue from "effect/Queue";
66 import * as Ref from "effect/Ref";
7−import type * as PlatformError from "effect/PlatformError";
87 import * as Schema from "effect/Schema";
98 import * as Scope from "effect/Scope";
109 import * as Stream from "effect/Stream";
@@ -43,21 +42,18 @@ export type AcpIncomingNotification = · 32 unmodified lines
4342 readonly params: unknown;
4443 };
4544
46−/** Standard I/O whose input can report provider-specific ACP failures. */
47−export interface AcpStdio extends Omit<Stdio.Stdio, "stdin"> {
48− readonly stdin: Stream.Stream<Uint8Array, PlatformError.PlatformError | AcpError.AcpError>;
49−}
50−
5145 export interface AcpPatchedProtocolOptions {
52− readonly stdio: AcpStdio;
46+ readonly stdio: Stdio.Stdio;
5347 readonly terminationError?: Effect.Effect<AcpError.AcpError>;
5448 readonly serverRequestMethods: ReadonlySet<string>;
5549 readonly logIncoming?: boolean;
5650 readonly logOutgoing?: boolean;
5751 readonly logger?: (event: AcpProtocolLogEvent) => Effect.Effect<void, never>;
58− readonly transformSessionUpdate?: (
59− notification: AcpSchema.SessionNotification,
60− ) => AcpSchema.SessionNotification;
52+ readonly onIncomingRequest?: (
53+ requestId: string,
54+ method: string,
55+ payload: unknown,
56+ ) => Effect.Effect<void, never>;
6157 readonly onNotification?: (
6258 notification: AcpIncomingNotification,
6359 ) => Effect.Effect<void, AcpError.AcpError, never>;
@@ -66,6 +62,18 @@ export interface AcpPatchedProtocolOptions { · 2 unmodified lines
6662 params: unknown,
6763 ) => Effect.Effect<unknown, AcpError.AcpError, never>;
6864 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+ };
6977 }
7078
7179 export interface AcpPatchedProtocol {
@@ -81,6 +89,17 @@ interface AcpPendingRequest { · 9 unmodified lines
8189 readonly method: string;
8290 }
8391
92+interface AcpOutgoingWrite {
93+ readonly acknowledgement?: Deferred.Deferred<void, AcpError.AcpError>;
94+ readonly payload: string | Uint8Array;
95+}
96+
97+interface AcpOutgoingWriterState {
98+ readonly outstandingAcknowledgements: ReadonlySet<Deferred.Deferred<void, AcpError.AcpError>>;
99+ readonly intentionalEnd: boolean;
100+ readonly terminalError?: AcpError.AcpError;
101+}
102+
84103 const decodeSessionUpdate = Schema.decodeUnknownEffect(AcpSchema.SessionNotification);
85104 const decodeElicitationComplete = Schema.decodeUnknownEffect(
86105 AcpSchema.ElicitationCompleteNotification,
@@ -108,16 +127,15 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 21 unmodified lines
108127 MAX_BUFFERED_RAW_NOTIFICATIONS,
109128 );
110129 const disconnects = yield* Queue.unbounded<number>();
111− const outgoing = yield* Queue.unbounded<string | Uint8Array, Cause.Done<void>>();
112− const nextRequestId = yield* Ref.make(1);
130+ const outgoing = yield* Queue.unbounded<AcpOutgoingWrite, AcpError.AcpError | Cause.Done<void>>();
131+ const outgoingWriterState = yield* Ref.make<AcpOutgoingWriterState>({
132+ intentionalEnd: false,
133+ outstandingAcknowledgements: new Set(),
134+ });
135+ const nextRequestId = yield* Ref.make(1n);
113136 const terminationHandled = yield* Ref.make(false);
114− const terminationFailure = yield* Deferred.make<never, AcpError.AcpError>();
115137 const extPending = yield* Ref.make(new Map<string, AcpPendingRequest>());
116138
117− const ensureActive = Ref.get(terminationHandled).pipe(
118− Effect.flatMap((terminated) => (terminated ? Deferred.await(terminationFailure) : Effect.void)),
119− );
120−
121139 const logProtocol = (event: AcpProtocolLogEvent) => {
122140 if (event.direction === "incoming" && !options.logIncoming) {
123141 return Effect.void;
@@ -139,7 +157,6 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 15 unmodified lines
139157 if (message._tag === "Interrupt") {
140158 return;
141159 }
142− yield* ensureActive;
143160 yield* logProtocol({
144161 direction: "outgoing",
145162 stage: "decoded",
@@ -166,8 +183,66 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 20 unmodified lines
166183 payload: typeof encoded === "string" ? encoded : new TextDecoder().decode(encoded),
167184 });
168185
169− yield* ensureActive;
170− yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid);
186+ const acknowledgement =
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+ }
171246 }
172247 });
173248
@@ -233,7 +308,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 59 unmodified lines
233308 }),
234309 }).pipe(Effect.asVoid);
235310
236− const handleTermination = (classify: () => Effect.Effect<AcpError.AcpError>) =>
311+ const handleTermination = (classify: () => Effect.Effect<AcpError.AcpError | undefined>) =>
237312 Ref.modify(terminationHandled, (handled) => {
238313 if (handled) {
239314 return [Effect.void, true] as const;
@@ -242,7 +317,9 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 2 unmodified lines
242317 Effect.gen(function* () {
243318 yield* Queue.offer(disconnects, 0);
244319 const error = yield* classify();
245− yield* Deferred.fail(terminationFailure, error);
320+ if (!error) {
321+ return;
322+ }
246323 yield* failAllExtPending(error);
247324 yield* emitClientProtocolError(error);
248325 if (options.onTermination) {
@@ -303,7 +380,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 54 unmodified lines
303380 ({
304381 _tag: "SessionUpdate",
305382 method: CLIENT_METHODS.session_update,
306− params: options.transformSessionUpdate?.(params) ?? params,
383+ params,
307384 }) satisfies AcpIncomingNotification,
308385 ),
309386 Effect.mapError((cause) =>
@@ -343,8 +420,11 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 33 unmodified lines
343420 });
344421 }
345422
423+ const observeIncoming =
424+ options.onIncomingRequest?.(message.id, message.tag, message.payload) ?? Effect.void;
425+
346426 if (!options.serverRequestMethods.has(message.tag)) {
347− return handleExtRequest(message).pipe(
427+ return observeIncoming.pipe(Effect.andThen(handleExtRequest(message))).pipe(
348428 Effect.catchTags({
349429 AcpProtocolParseError: (error) =>
350430 Effect.logWarning(error).pipe(
@@ -369,7 +449,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 18 unmodified lines
369449 );
370450 }
371451
372− return Queue.offer(serverQueue, message).pipe(Effect.asVoid);
452+ return observeIncoming.pipe(Effect.andThen(Queue.offer(serverQueue, message)), Effect.asVoid);
373453 };
374454
375455 const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) =>
@@ -511,7 +591,112 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 135 unmodified lines
511591 Effect.forkScoped,
512592 );
513593
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+ );
515700
516701 const clientProtocol = RpcClient.Protocol.of({
517702 run: (_clientId, f) =>
@@ -542,8 +727,19 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 24 unmodified lines
542727 Effect.forever,
543728 ),
544729 disconnects,
545− send: (_clientId, response) => offerOutgoing(response).pipe(Effect.orDie),
546− end: (_clientId) => Queue.end(outgoing),
730+ send: (_clientId, response) =>
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+ ),
547743 clientIds: Effect.succeed(new Set([0])),
548744 initialMessage: Effect.succeedNone,
549745 supportsAck: true,
@@ -558,7 +754,6 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 8 unmodified lines
558754 method: string,
559755 payload: unknown,
560756 ) {
561− yield* ensureActive;
562757 yield* logProtocol({
563758 direction: "outgoing",
564759 stage: "decoded",
@@ -574,12 +769,10 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 9 unmodified lines
574769 }
575770 const encoded = `${exit.value}\n`;
576771 yield* logProtocol({ direction: "outgoing", stage: "raw", payload: encoded });
577− yield* ensureActive;
578772 yield* Queue.offer(outgoing, encoded);
579773 });
580774
581775 const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) {
582− yield* ensureActive;
583776 const requestId = yield* Ref.modify(
584777 nextRequestId,
585778 (current) => [current, current + 1] as const,
packages/shared/src/agentAwareness.test.ts+16−0
@@ -56,4 +56,20 @@ describe("projectThreadAwarenessV2", () => { · 55 unmodified lines
5656 }),
5757 ).toMatchObject({ phase: "waiting_for_input", headline: "Waiting for input" });
5858 });
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+ });
5975 });
packages/shared/src/agentAwareness.ts+4−1
@@ -87,7 +87,10 @@ function resolveThreadAwarenessPhaseV2( · 86 unmodified lines
8787 if (thread.pendingRuntimeRequest?.kind === "user_input") {
8888 return "waiting_for_input";
8989 }
90− if (thread.pendingRuntimeRequest !== null) {
90+ if (
91+ thread.pendingRuntimeRequest !== null &&
92+ thread.pendingRuntimeRequest.kind !== "auth_refresh"
93+ ) {
9194 return "waiting_for_approval";
9295 }
9396 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
77 const nodeId = NodeId.make("node:timeline-visibility");
88
99 describe("isOrchestrationV2TurnItemVisible", () => {
10− it("hides interruption results from superseded attempts", () => {
10+ it("hides unpaired interruption results from superseded attempts", () => {
1111 expect(
1212 isOrchestrationV2TurnItemVisible({
1313 item: { type: "run_interrupt_result", runId, nodeId },
1414 runs: [{ id: runId, status: "running" }],
1515 attempts: [{ runId, rootNodeId: nodeId, status: "superseded" }],
16+ items: [{ type: "run_interrupt_result", runId, nodeId }],
1617 }),
1718 ).toBe(false);
1819 });
1920
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", () => {
2136 expect(
2237 isOrchestrationV2TurnItemVisible({
2338 item: { type: "run_interrupt_result", runId, nodeId },
2439 runs: [{ id: runId, status: "interrupted" }],
2540 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+ ],
2656 }),
2757 ).toBe(true);
2858 });
@@ -40,6 +70,7 @@ describe("isOrchestrationV2TurnItemVisible", () => { · 11 unmodified lines
4070 },
4171 { runId, rootNodeId: nodeId, status: "interrupted" },
4272 ],
73+ items: [{ type: "run_interrupt_result", runId, nodeId }],
4374 }),
4475 ).toBe(true);
4576 });
packages/shared/src/orchestrationV2Timeline.ts+18−2
@@ -11,24 +11,36 @@ type TimelineTurnItem = Pick<OrchestrationV2TurnItem, "type" | "runId" | "nodeId · 10 unmodified lines
1111 export function isOrchestrationV2SupersededInterrupt(input: {
1212 readonly item: TimelineTurnItem;
1313 readonly attempts: ReadonlyArray<TimelineRunAttempt>;
14+ readonly items: ReadonlyArray<TimelineTurnItem>;
1415 }): boolean {
1516 const { item } = input;
1617 if (item.type !== "run_interrupt_result" || item.runId === null || item.nodeId === null) {
1718 return false;
1819 }
1920
20− return input.attempts.some(
21+ const isSuperseded = input.attempts.some(
2122 (attempt) =>
2223 attempt.runId === item.runId &&
2324 attempt.rootNodeId === item.nodeId &&
2425 attempt.status === "superseded",
2526 );
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;
2637 }
2738
2839 export function isOrchestrationV2TurnItemVisible(input: {
2940 readonly item: TimelineTurnItem;
3041 readonly runs: ReadonlyArray<TimelineRun>;
3142 readonly attempts: ReadonlyArray<TimelineRunAttempt>;
43+ readonly items: ReadonlyArray<TimelineTurnItem>;
3244 }): boolean {
3345 const { item } = input;
3446 if (
@@ -38,5 +50,9 @@ export function isOrchestrationV2TurnItemVisible(input: { · 3 unmodified lines
3850 return false;
3951 }
4052
41− return !isOrchestrationV2SupersededInterrupt({ item, attempts: input.attempts });
53+ return !isOrchestrationV2SupersededInterrupt({
54+ item,
55+ attempts: input.attempts,
56+ items: input.items,
57+ });
4258 }

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