1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2// @effect-diagnostics nodeBuiltinImport:off
3
4import {
5 type ChatAttachment,
6 type ModelSelection,
7 type OrchestrationV2ConversationMessage,
8 type OrchestrationV2ExecutionNode,
9 type OrchestrationV2PlanArtifact,
10 type OrchestrationV2PlanStep,
11 type OrchestrationV2ProviderCapabilities,
12 type OrchestrationV2ProviderFailure,
13 type OrchestrationV2ProviderRetry,
14 type OrchestrationV2ProviderSession,
15 type OrchestrationV2ProviderThread,
16 type OrchestrationV2ProviderThreadNativeMetadata,
17 type OrchestrationV2ProviderTurn,
18 type OrchestrationV2RuntimeRequest,
19 type OrchestrationV2Subagent,
20 type OrchestrationV2TurnItem,
21 type OrchestrationV2UserInputQuestion,
22 type OrchestrationV2WebSearchResult,
23 type ProviderApprovalDecision,
24 type ProviderApprovalOption,
25 type ProviderInstanceId,
26 type ProviderInteractionMode,
27 type ProviderDriverKind,
28 type ProviderRequestKind,
29 type ProviderThreadId,
30 type ProviderUserInputAnswers,
31 type RuntimeRequestId,
32 type ThreadTokenUsageSnapshot,
33 type ThreadId,
34} from "@t3tools/contracts";
35import { AgentScope, AgentScopeThreadId } from "@t3tools/shared/AgentScope";
36import { modelSelectionsEqual } from "@t3tools/shared/model";
37import { type SelfInvocation, selfInvocationArgs } from "@t3tools/shared/nodeRuntime";
38import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff";
39import * as Cause from "effect/Cause";
40import * as Crypto from "effect/Crypto";
41import * as DateTime from "effect/DateTime";
42import * as Deferred from "effect/Deferred";
43import type * as Duration from "effect/Duration";
44import * as Effect from "effect/Effect";
45import * as Exit from "effect/Exit";
46import * as FileSystem from "effect/FileSystem";
47import * as Fiber from "effect/Fiber";
48import * as Option from "effect/Option";
49import * as Queue from "effect/Queue";
50import * as Ref from "effect/Ref";
51import * as Result from "effect/Result";
52import * as Scope from "effect/Scope";
53import * as Semaphore from "effect/Semaphore";
54import * as Stream from "effect/Stream";
55import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
56import * as EffectAcpErrors from "effect-acp/errors";
57import type * as EffectAcpProtocol from "effect-acp/protocol";
58import type * as EffectAcpSchema from "effect-acp/compat";
59
60import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity";
61import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
62import { makeAcpMcpOverAcpBridge, type AcpMcpOverAcpBridge } from "./mcpOverAcpBridge.ts";
63import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation";
64import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
65import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession";
66import {
67 applyAcpAgentTerminalUpdate,
68 acpContentBlockDisplayText,
69 embeddedTerminalIdsFromSessionUpdate,
70 extractMcpToolCallIdentity,
71 mergeToolCallState,
72 parsePermissionRequest,
73 parseSessionUpdateEvent,
74 toolCallVisibleOutputChanged,
75 type AcpPlanUpdate,
76 type AcpAgentTerminalState,
77 type AcpSessionModeState,
78 type AcpToolCallState,
79} from "./runtimeModel.ts";
80import {
81 acpClientExecuteDisposition,
82 acpMcpToolApprovalElicitationDisposition,
83 acpPermissionDisposition,
84 type AcpPermissionDisposition,
85 makeAcpClientPolicyGrants,
86 unknownRecord,
87} from "./clientPolicy.ts";
88import {
89 makeAcpClientTerminals,
90 resolveEmbeddedTerminalContent,
91 type AcpClientTerminals,
92} from "./clientTerminals.ts";
93import { ACP_EMPTY_OPTION_VALUE, ACP_SESSION_MODE_OPTION_ID } from "./sessionConfig.ts";
94import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
95import {
96 t3AcpPromptWithInstructions,
97 type T3AcpInstructionState,
98} from "@t3tools/provider-core/server/orchestrationInstructions";
99import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions";
100import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
101import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
102import {
103 type BackgroundWork,
104 type BackgroundWorkReport,
105 backgroundWorkNotification,
106} from "@t3tools/provider-core/server/notification";
107import {
108 makeProviderFailure,
109 makeProviderRetryTurnItem,
110} from "@t3tools/provider-core/server/failure";
111import { acpSelectionTransition } from "@t3tools/provider-core/server/selectionTransition";
112import {
113 isProviderNativeImageAttachment,
114 providerMessageTextWithAttachmentPaths,
115} from "@t3tools/provider-core/server/attachmentPrompt";
116import {
117 makeSubagentChildThread,
118 makeSubagentConversationArtifacts,
119 subagentThreadTitle,
120} from "@t3tools/provider-core/server/subagentProjection";
121import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
122
123export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const;
124
125/**
126 * Quiet window after a settled turn's last background rearm before it
127 * finalizes, so a slightly late post-hydration assistant chunk stays in the
128 * same turn. Grok commonly sends its final summary just over two seconds after
129 * the hydrated tool frame; two seconds split that tail into a second synthetic
130 * wake. Longer floors (4–20s) only prolonged Working. No per-model carveouts.
131 */
132const ACP_DEFERRED_FINALIZE_DEBOUNCE: Duration.Input = "3000 millis";
133
134export interface AcpAdapterV2RuntimeInput {
135 readonly cwd: string;
136 /**
137 * Policy the session opened with. A runtime-mode change reopens the session,
138 * so flavors that encode permissions in the launch command (Grok) read it here.
139 */
140 readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
141 readonly mcpServers: ReadonlyArray<EffectAcpSchema.McpServer>;
142 readonly acpMcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
143 /** Scoped credentials for terminal fallback when an ACP agent drops `mcpServers`. */
144 readonly processEnvironment?: NodeJS.ProcessEnv;
145 readonly resumeSessionId?: string;
146 readonly interruptPromptOnCancel?: boolean;
147 readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"];
148 readonly clientInfo: AcpSessionRuntime.AcpSessionRuntimeOptions["clientInfo"];
149 readonly requestLogger?: NonNullable<AcpSessionRuntime.AcpSessionRuntimeOptions["requestLogger"]>;
150 readonly protocolLogging: NonNullable<
151 AcpSessionRuntime.AcpSessionRuntimeOptions["protocolLogging"]
152 >;
153 readonly onTermination: NonNullable<AcpSessionRuntime.AcpSessionRuntimeOptions["onTermination"]>;
154 readonly onOutgoingResponseFailure?: AcpSessionRuntime.AcpSessionRuntimeOptions["onOutgoingResponseFailure"];
155 readonly onOutgoingResponse?: AcpSessionRuntime.AcpSessionRuntimeOptions["onOutgoingResponse"];
156}
157
158export type AcpAdapterV2NativeLogging = Pick<
159 AcpSessionRuntime.AcpSessionRuntimeOptions,
160 "requestLogger" | "protocolLogging"
161>;
162
163export interface AcpAdapterV2UserInputRequest {
164 readonly nativeItemId: string;
165 readonly nativeRequestId: string;
166 readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>;
167}
168
169export interface AcpAdapterV2ExtensionContext {
170 readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"];
171 readonly reportProviderRetry: (input: {
172 readonly sessionId: string;
173 readonly failure: OrchestrationV2ProviderFailure;
174 }) => Effect.Effect<void>;
175 /**
176 * A subagent's structured end on the root session (Grok `subagent_finished`),
177 * keyed by its child session id. Finishes the subagent row, in the turn that
178 * holds it or in the carryover of a settled one.
179 */
180 readonly finishSubagent: (notice: {
181 readonly sessionId: string;
182 readonly childSessionId: string;
183 readonly status: "completed" | "failed" | "cancelled";
184 readonly result: string | null;
185 }) => Effect.Effect<void>;
186 /**
187 * Session-scoped background-task lifecycle reported via extension
188 * notifications (e.g. Grok `x.ai/task_backgrounded`; older builds use the
189 * underscore alias). Mutations for non-root sessions are ignored. A terminal
190 * mutation also finishes the tool that registered the task in a settled turn
191 * still held open for it, with `output` as its final text when given.
192 */
193 readonly applyBackgroundTaskMutation: (mutation: {
194 readonly sessionId: string;
195 readonly taskId: string;
196 readonly status: "running" | "completed" | "failed";
197 readonly output?: string;
198 /** What the task is, so a wake it causes can name it. */
199 readonly report?: BackgroundWork;
200 }) => Effect.Effect<void>;
201 readonly requestUserInput: (
202 input: AcpAdapterV2UserInputRequest,
203 requestContext: EffectAcpProtocol.AcpRequestContext,
204 ) => Effect.Effect<
205 {
206 readonly acknowledgeNativeResponse: Effect.Effect<void, EffectAcpErrors.AcpError>;
207 readonly answers: ProviderUserInputAnswers | null;
208 },
209 EffectAcpErrors.AcpError
210 >;
211 /** Surfaces plan markdown as this turn's proposed-plan card (#8358). */
212 readonly captureProposedPlan: (input: { readonly planMarkdown: string }) => Effect.Effect<void>;
213 /** Last markdown captured for the active turn, as the exit-gate fallback. */
214 readonly lastProposedPlanMarkdown: Effect.Effect<string | undefined>;
215}
216
217export interface AcpAdapterV2Flavor {
218 /** Interprets provider-specific prompt errors before they cross into orchestration. */
219 readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure;
220 readonly driver: ProviderDriverKind;
221 readonly capabilities: OrchestrationV2ProviderCapabilities;
222 readonly clientCapabilitiesMeta?: NonNullable<
223 EffectAcpSchema.InitializeRequest["clientCapabilities"]
224 >["_meta"];
225 readonly normalizeSessionUpdate?: (
226 notification: EffectAcpSchema.SessionNotification,
227 ) => EffectAcpSchema.SessionNotification;
228 readonly onAvailableCommandsUpdate?: (
229 commands: ReadonlyArray<EffectAcpSchema.AvailableCommand>,
230 ) => Effect.Effect<void>;
231 readonly onSessionConfigurationUpdate?: (
232 configOptions: ReadonlyArray<EffectAcpSchema.SessionConfigOption>,
233 modeState: AcpSessionModeState | undefined,
234 ) => Effect.Effect<void>;
235 readonly onUrlElicitation?: (input: {
236 readonly elicitationId: string;
237 readonly url: string;
238 readonly message: string;
239 }) => Effect.Effect<boolean>;
240 readonly withRuntimeStartup?: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
241 readonly makeRuntime: (
242 input: AcpAdapterV2RuntimeInput,
243 ) => Effect.Effect<
244 AcpSessionRuntime.AcpSessionRuntime["Service"],
245 EffectAcpErrors.AcpError,
246 ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope
247 >;
248 readonly resolveModelId?: (selection: ModelSelection) => string | undefined;
249 /**
250 * Replaces the default model application on session setup. Returns the model
251 * the session now runs on. Antigravity resolves its provider-default alias
252 * against the account's catalog instead of sending it to the agent.
253 */
254 readonly applyModelSelection?: (input: {
255 readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"];
256 readonly startResult: AcpSessionRuntime.AcpSessionRuntimeStartResult;
257 readonly modelSelection: ModelSelection;
258 }) => Effect.Effect<string | undefined, EffectAcpErrors.AcpError>;
259 /** Native session mode to select for a runtime policy (e.g. Antigravity `yolo`). */
260 readonly sessionModeForPolicy?: (
261 policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
262 ) => string | undefined;
263 /**
264 * Opts the session into the ACP client `fs` capability. Agents read and write
265 * files themselves under their own permission model unless a flavor sets
266 * this. Requests pass the runtime policy guard, then these handlers, which
267 * receive the cwd of the policy active when the request arrives (null when
268 * the session has no workspace). Antigravity sets it and confines requests
269 * to that workspace.
270 */
271 readonly clientFileSystem?: {
272 readonly readTextFile: (
273 request: EffectAcpSchema.ReadTextFileRequest,
274 cwd: string | null,
275 ) => Effect.Effect<EffectAcpSchema.ReadTextFileResponse, EffectAcpErrors.AcpError>;
276 readonly writeTextFile: (
277 request: EffectAcpSchema.WriteTextFileRequest,
278 cwd: string | null,
279 ) => Effect.Effect<EffectAcpSchema.WriteTextFileResponse, EffectAcpErrors.AcpError>;
280 };
281 /**
282 * Permission requests that are really questions (Antigravity `interaction_*`
283 * tool calls). Returns the question and a response builder; undefined routes
284 * the request through the normal approval card.
285 */
286 readonly extractPermissionQuestion?: (request: EffectAcpSchema.RequestPermissionRequest) =>
287 | {
288 readonly question: OrchestrationV2UserInputQuestion;
289 readonly respond: (
290 answers: ProviderUserInputAnswers,
291 ) => EffectAcpSchema.RequestPermissionResponse | undefined;
292 }
293 | undefined;
294 /**
295 * Replaces T3's runtime-policy answer to a permission request. Grok's Auto
296 * mode only asks about what its own classifier refused, so those must reach
297 * the user instead of being approved by T3's policy.
298 */
299 readonly permissionDisposition?: (
300 policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
301 request: EffectAcpSchema.RequestPermissionRequest,
302 ) => AcpPermissionDisposition;
303 /** Approval choices to advertise on the approval card for a permission request. */
304 readonly approvalOptions?: (
305 request: EffectAcpSchema.RequestPermissionRequest,
306 ) => ReadonlyArray<ProviderApprovalOption>;
307 /**
308 * Activate saved sessions with `session/resume` before `session/load` when
309 * the agent supports both. Antigravity's load replays history slowly.
310 */
311 readonly preferResumeSession?: boolean;
312 readonly onSessionEvent?: (
313 event: AcpSessionRuntime.AcpSessionRuntimeEvent,
314 ) => Effect.Effect<void>;
315 /** Batch launches without child completion signals become idle when the root turn ends. */
316 readonly subagentsIdleOnTurnCompletion?: boolean;
317 readonly supportsCompaction?: boolean;
318 readonly runtimeHarness?: string;
319 readonly registerExtensions?: (
320 context: AcpAdapterV2ExtensionContext,
321 ) => Effect.Effect<void, EffectAcpErrors.AcpError>;
322 readonly extractSubagentUpdate?: (
323 toolCall: AcpToolCallState,
324 ) => AcpAdapterV2SubagentUpdate | undefined;
325 /**
326 * Optional Grok-style rewrite before tool projection (e.g. keep monitor start
327 * ACKs in the running state until stream end).
328 */
329 readonly normalizeToolCall?: (toolCall: AcpToolCallState) => AcpToolCallState;
330 /**
331 * Optional plan-file sniffing (#8358): providers that write their proposed
332 * plan to a file mid-turn (Grok plan.md) return its markdown from a tool
333 * call so T3 can show the proposed-plan card while plan mode is active.
334 */
335 readonly extractProposedPlanMarkdown?: (toolCall: AcpToolCallState) => string | undefined;
336 /**
337 * Optional mapping from a long-lived background tool start ACK to a task id
338 * (e.g. monitor task uuid) so later synthetic text events can update it.
339 */
340 readonly extractBackgroundTaskId?: (toolCall: AcpToolCallState) => string | undefined;
341 /**
342 * Optional parse of root-session synthetic text (monitor-event lines, monitor
343 * ended reminders). Returns every task mutation in the chunk so coalesced
344 * progress / end notices are not dropped.
345 */
346 readonly extractBackgroundToolMutation?: (text: string) => ReadonlyArray<{
347 readonly taskId: string;
348 readonly status: "running" | "completed" | "failed";
349 readonly appendOutput: string;
350 }>;
351 /**
352 * Optional parse of root-session synthetic text announcing a background
353 * subagent's end ("Background subagent "<uuid>" ... completed successfully").
354 * Older builds may never hydrate via get_command_or_subagent_output, so this
355 * remains a terminal fallback. Current Grok additionally emits structured
356 * `subagent_finished` session notifications.
357 */
358 readonly extractSubagentEndNotice?: (text: string) =>
359 | {
360 readonly childSessionId: string;
361 readonly status: "completed" | "failed";
362 }
363 | undefined;
364 /**
365 * Optional hydration when a later tool (e.g. get_command TaskOutput) completes
366 * previously registered background task id(s).
367 */
368 readonly extractBackgroundTaskCompletion?: (toolCall: AcpToolCallState) => ReadonlyArray<{
369 readonly taskId: string;
370 readonly status: "running" | "completed" | "failed";
371 readonly appendOutput: string;
372 }>;
373 /**
374 * Persistent monitors (e.g. Grok `persistent: true`) should not hold root-turn
375 * deferred finalize open forever. Still tracked for post-settle wake.
376 */
377 readonly isPersistentBackgroundTool?: (toolCall: AcpToolCallState) => boolean;
378 /**
379 * Whether a root-session frame belongs to a turn the agent started itself
380 * after background work ended (Grok `task-completed-*`), not to T3's prompt.
381 * Such frames never project into a root turn held open for that work; they
382 * take the post-settle wake path once the held turn finalizes.
383 */
384 readonly isProviderWakeNotification?: (
385 notification: EffectAcpSchema.SessionNotification,
386 ) => boolean;
387 /**
388 * When true, keep the active turn open after session/prompt returns while
389 * background tools/subagents are still running so later monitor/wake traffic
390 * can project (Grok monitors finish after the root prompt settles).
391 */
392 readonly deferFinalizeForBackgroundWork?: boolean;
393 readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>;
394 /** Interrupt the local prompt fiber before `session/cancel` (Grok wedged prompts). */
395 readonly interruptPromptOnCancel?: boolean;
396 /**
397 * Kill and respawn the ACP child process before the next `session/prompt` after a
398 * user interrupt. Grok can keep `task_already_running` state until the process exits.
399 */
400 readonly restartRuntimeAfterInterrupt?: boolean;
401 /**
402 * When true, every interrupt restarts the runtime, not just those carrying
403 * `requestRuntimeRestart` (user Stop). Leave unset to keep non-Stop
404 * interrupts (steering, restart_active) soft: `session/cancel` plus session
405 * reuse in the same process.
406 */
407 readonly restartRuntimeOnEveryInterrupt?: boolean;
408 readonly terminateRuntimeProcessGroupOnInterrupt?: boolean;
409 /**
410 * When true, an interrupt without `requestRuntimeRestart` (steering restart)
411 * on a turn whose native prompt already settled skips the hard process-group
412 * kill, the ACP cancel, and the runtime respawn entirely: the turn
413 * terminalizes locally while background subagents keep running in the same
414 * process and carry over into the replacement turn. Verified against the
415 * real Grok CLI (tmp/grok-acp-experiments E1): a new session/prompt is
416 * accepted concurrently while a fire-and-forget subagent is still running,
417 * with no task_already_running. User Stop (`requestRuntimeRestart: true`)
418 * keeps the hard teardown; mid-prompt non-Stop interrupts go soft
419 * (`session/cancel` plus same-session re-prompt) unless
420 * `restartRuntimeOnEveryInterrupt` is set.
421 */
422 readonly preserveRuntimeOnSettledInterrupt?: boolean;
423 /**
424 * When true (with continuationRequests), post-settle root session/update traffic
425 * buffers and requests a provider continuation run instead of being dropped or
426 * only appended to loaded history.
427 */
428 readonly enablePostSettleContinuation?: boolean;
429 /**
430 * When true, send image attachment content blocks even if the ACP agent
431 * advertises `promptCapabilities.image: false`. Grok CLI currently accepts
432 * and vision-processes image blocks while still reporting the capability as
433 * false; without this override, screenshot turns fail before `session/prompt`.
434 */
435 readonly supportsImagePrompts?: boolean;
436}
437
438/** Whether image attachment blocks may be included in session/prompt. */
439export function acpSupportsImagePrompts(input: {
440 readonly flavorSupportsImagePrompts?: boolean | undefined;
441 readonly negotiatedImage?: boolean | undefined;
442}): boolean {
443 return input.flavorSupportsImagePrompts === true || input.negotiatedImage === true;
444}
445
446/** Keeps provider-derived ids distinct when two configured ACP instances reuse native ids. */
447export function acpScopedNativeId(instanceId: ProviderInstanceId, nativeId: string): string {
448 return `provider-instance:${encodeURIComponent(instanceId)}:${nativeId}`;
449}
450
451/** Keeps pre-v2 persisted ids stable while scoping ids for newly created threads. */
452export function acpProviderItemNativeId(input: {
453 readonly instanceId: ProviderInstanceId;
454 readonly itemIdentityVersion: 2 | undefined;
455 readonly nativeId: string;
456}): string {
457 return input.itemIdentityVersion === 2
458 ? acpScopedNativeId(input.instanceId, input.nativeId)
459 : input.nativeId;
460}
461
462export interface AcpAdapterV2SubagentUpdate {
463 readonly nativeTaskId: string;
464 readonly prompt: string;
465 readonly title: string | null;
466 readonly model: string | null;
467 readonly status:
468 | "pending"
469 | "running"
470 | "idle"
471 | "completed"
472 | "failed"
473 | "interrupted"
474 | "cancelled";
475 readonly childSessionId: string | null;
476 readonly result: string | null;
477 /**
478 * When false, still project a normal tool turn item after the subagent update
479 * (hydration tools like get_command_or_subagent_output). Defaults to true.
480 */
481 readonly suppressNormalTool?: boolean;
482}
483
484export interface AcpAdapterV2Options {
485 readonly instanceId: ProviderInstanceId;
486 readonly flavor: AcpAdapterV2Flavor;
487 /** How agents spawn this install's `acp-mcp-bridge`; see `resolveSelfInvocation`. */
488 readonly selfInvocation: SelfInvocation;
489 /**
490 * Opts the session into the ACP client `terminal` capability. Agents run
491 * commands themselves unless an adapter sets this; with it, sessions
492 * advertise `terminal: true` and run agent-created terminals through the
493 * adapter's spawner with the provider instance's environment. Devin sets it.
494 */
495 readonly clientTerminals?: {
496 readonly environment?: NodeJS.ProcessEnv;
497 readonly shellCommands?: boolean;
498 };
499 readonly nativeLogging?: (threadId: ThreadId) => AcpAdapterV2NativeLogging;
500 /**
501 * Shared with ProviderContinuationService so post-settle wake traffic can start
502 * a continuation run. Optional: adapters that omit it keep pre-continuation drop
503 * / history-only behavior for null-activeTurn updates.
504 */
505 readonly continuationRequests?: {
506 readonly offer: (
507 request: ProviderContinuationRequests.ProviderContinuationRequest,
508 ) => Effect.Effect<void>;
509 };
510 readonly testHooks?: {
511 /**
512 * A settled turn with no background work left armed its finish debounce
513 * ({@link ACP_DEFERRED_FINALIZE_DEBOUNCE}); replay advances its test clock
514 * by exactly that on this receipt.
515 */
516 readonly onDeferredFinalizeScheduled?: (debounce: Duration.Input) => Effect.Effect<void>;
517 readonly afterPromptSettledWithBackgroundWork?: () => Effect.Effect<void>;
518 readonly afterNativeResponseTransportClosed?: () => Effect.Effect<void>;
519 readonly afterHardTeardownTransportDrained?: () => Effect.Effect<void>;
520 readonly beforeNativeResponseAdmissionCheck?: (
521 generation: number,
522 requestId: string,
523 ) => Effect.Effect<void>;
524 readonly onNativeResponseLifecycle?: (event: {
525 readonly generation?: number;
526 readonly requestId?: string;
527 readonly type:
528 | "admission_rejected"
529 | "failed"
530 | "late_noop"
531 | "registered"
532 | "removed"
533 | "timer_exited"
534 | "timer_started"
535 | "watcher_exited"
536 | "watcher_started";
537 }) => Effect.Effect<void>;
538 };
539}
540
541export const AcpProviderCapabilitiesV2 = {
542 sessions: {
543 supportsMultipleProviderThreadsPerSession: false,
544 supportsModelSwitchInSession: false,
545 supportsProviderSwitchingViaHandoff: true,
546 supportsRuntimeModeSwitchInSession: false,
547 pendingRequestsSurviveRestart: false,
548 },
549 threads: {
550 canCreateEmptyThread: true,
551 canReadThreadSnapshot: false,
552 canRollbackThread: true,
553 canForkThread: false,
554 canForkFromTurn: false,
555 canForkFromSubagentThread: false,
556 exposesNativeThreadId: true,
557 },
558 turns: {
559 exposesNativeTurnId: false,
560 emitsTurnStarted: true,
561 emitsTurnCompleted: true,
562 supportsInterrupt: true,
563 supportsActiveSteering: false,
564 supportsSteeringByInterruptRestart: true,
565 supportsQueuedMessages: true,
566 terminalStatusQuality: "strong",
567 },
568 streaming: {
569 streamsAssistantText: true,
570 streamsReasoning: true,
571 streamsToolOutput: true,
572 streamsPlanText: false,
573 emitsMessageCompleted: true,
574 },
575 tools: {
576 exposesToolItemIds: true,
577 emitsToolStarted: true,
578 emitsToolCompleted: true,
579 emitsToolOutput: true,
580 supportsMcpTools: false,
581 supportsDynamicToolCallbacks: false,
582 },
583 approvals: {
584 supportsCommandApproval: true,
585 supportsFileReadApproval: true,
586 supportsFileChangeApproval: true,
587 supportsApplyPatchApproval: false,
588 approvalsHaveNativeRequestIds: false,
589 approvalCallbacksAreLiveOnly: true,
590 approvalsCanOriginateFromSubagents: false,
591 },
592 planning: {
593 emitsPlanUpdated: true,
594 emitsTodoList: true,
595 emitsProposedPlan: false,
596 supportsStructuredQuestions: true,
597 planDeltasHaveItemIds: false,
598 },
599 subagents: {
600 supportsSubagents: false,
601 exposesSubagentThreadIds: false,
602 emitsSubagentLifecycle: false,
603 canWaitForSubagents: false,
604 canCloseSubagents: false,
605 canForkSubagentThread: false,
606 },
607 context: {
608 acceptsSystemContext: false,
609 acceptsDeveloperContext: false,
610 acceptsSyntheticUserContext: true,
611 canGenerateSummaries: true,
612 canConsumeHandoffSummaries: true,
613 supportsDeltaHandoff: true,
614 supportsFullThreadHandoff: true,
615 maxRecommendedHandoffChars: null,
616 },
617 checkpointing: {
618 appCanCheckpointFilesystem: true,
619 supportsNestedCheckpointScopes: true,
620 // ACP defines no conversation truncation, so rollback resets the provider
621 // conversation: T3 restores checkpointed state and the next turn starts a
622 // fresh agent session without the rolled-back context.
623 providerCanRollbackConversation: true,
624 providerRollbackReturnsSnapshot: true,
625 providerCanReadConversationSnapshot: false,
626 },
627 identity: {
628 nativeThreadIds: "strong",
629 nativeTurnIds: "weak",
630 nativeItemIds: "weak",
631 nativeRequestIds: "weak",
632 },
633 runtimePolicy: {
634 // ACP agents run their own tools; T3 only answers their permission
635 // requests by policy.
636 enforcement: "client-boundary",
637 },
638} satisfies OrchestrationV2ProviderCapabilities;
639
640function negotiatedCapabilities(
641 base: OrchestrationV2ProviderCapabilities,
642 started: AcpSessionRuntime.AcpSessionRuntimeStartResult,
643): OrchestrationV2ProviderCapabilities {
644 const agent = started.initializeResult.agentCapabilities ?? {};
645 const session = agent.sessionCapabilities;
646 const setup = started.sessionSetupResult;
647 const hasModelConfig =
648 setup.configOptions?.some((option) => option.category === "model") === true;
649 const canLoad = agent.loadSession === true;
650 const canFork = session?.fork != null;
651 return {
652 ...base,
653 sessions: {
654 ...base.sessions,
655 supportsModelSwitchInSession: hasModelConfig,
656 },
657 threads: {
658 ...base.threads,
659 canReadThreadSnapshot: canLoad,
660 canForkThread: canFork,
661 canForkFromTurn: false,
662 },
663 tools: {
664 ...base.tools,
665 // The stdio bridge (`t3 acp-mcp-bridge`) makes the t3-code MCP toolkit
666 // available regardless of the agent's optional http/sse MCP support.
667 supportsMcpTools: true,
668 },
669 checkpointing: {
670 ...base.checkpointing,
671 providerCanReadConversationSnapshot: canLoad,
672 },
673 };
674}
675
676interface AcpMcpContext {
677 readonly servers: ReadonlyArray<EffectAcpSchema.McpServer>;
678 readonly acpServers: ReadonlyArray<EffectAcpSchema.McpServer>;
679 readonly processEnvironment?: NodeJS.ProcessEnv;
680 readonly endpoint?: string;
681 readonly authorization?: string;
682}
683
684function acpMcpContext(
685 session: McpProviderSession.McpProviderSessionConfig | undefined,
686 self: SelfInvocation,
687): AcpMcpContext {
688 if (session === undefined) {
689 return { servers: [], acpServers: [] };
690 }
691 // Stdio is ACP's required baseline MCP transport. Agents that advertise
692 // optional http support still routinely fail to wire injected http servers
693 // through to their backend (codex-acp 1.2.0 and pi-acp both drop them), so
694 // every ACP session gets the `t3 acp-mcp-bridge` stdio server, which
695 // forwards JSON-RPC to T3's authenticated MCP endpoint. The credential
696 // travels via environment variables, never the command line.
697 return {
698 servers: [
699 {
700 name: "t3-code",
701 command: self.command,
702 args: [...selfInvocationArgs(self, ["acp-mcp-bridge"])],
703 env: [
704 { name: "ELECTRON_RUN_AS_NODE", value: "1" },
705 { name: "T3_ACP_MCP_ENDPOINT", value: session.endpoint },
706 { name: "T3_ACP_MCP_AUTHORIZATION", value: session.authorizationHeader },
707 ],
708 },
709 ],
710 acpServers: [{ type: "acp", name: "t3-code", serverId: "t3-code" }],
711 endpoint: session.endpoint,
712 authorization: session.authorizationHeader,
713 processEnvironment: {
714 T3_ACP_MCP_ENDPOINT: session.endpoint,
715 T3_ACP_MCP_AUTHORIZATION: session.authorizationHeader,
716 T3_ACP_MCP_NODE: self.command,
717 ...(self.entrypoint === undefined ? {} : { T3_ACP_MCP_ENTRYPOINT: self.entrypoint }),
718 },
719 };
720}
721
722function acpMcpActivation(context: AcpMcpContext) {
723 return { mcpServers: context.servers, acpMcpServers: context.acpServers };
724}
725
726function nativeThreadId(
727 driver: ProviderDriverKind,
728 thread: OrchestrationV2ProviderThread,
729): Effect.Effect<string, ProviderAdapter.ProviderAdapterProtocolError> {
730 const id = thread.nativeThreadRef?.nativeId;
731 if (id === null || id === undefined || id.trim().length === 0) {
732 return Effect.fail(
733 new ProviderAdapter.ProviderAdapterProtocolError({
734 driver,
735 detail: `Provider thread ${thread.id} is missing its ACP session id`,
736 }),
737 );
738 }
739 return Effect.succeed(id);
740}
741
742function makeProviderThread(input: {
743 readonly driver: ProviderDriverKind;
744 readonly providerInstanceId: ProviderInstanceId;
745 readonly idAllocator: IdAllocator.IdAllocatorV2["Service"];
746 readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"];
747 readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"];
748 readonly nativeThreadId: string;
749 readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"];
750 readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"];
751 readonly itemIdentityVersion?: 2;
752 readonly now: DateTime.Utc;
753}): OrchestrationV2ProviderThread {
754 return {
755 id: input.idAllocator.derive.providerThread({
756 driver: input.driver,
757 providerInstanceId: input.providerInstanceId,
758 nativeThreadId: input.nativeThreadId,
759 }),
760 driver: input.driver,
761 providerInstanceId: input.providerInstanceId,
762 providerSessionId: input.providerSessionId,
763 appThreadId: input.appThreadId,
764 ownerNodeId: input.ownerNodeId ?? null,
765 nativeThreadRef: {
766 driver: input.driver,
767 nativeId: input.nativeThreadId,
768 strength: "strong",
769 },
770 nativeConversationHeadRef: null,
771 status: "idle",
772 firstRunOrdinal: null,
773 lastRunOrdinal: null,
774 handoffIds: [],
775 forkedFrom: input.forkedFrom ?? null,
776 contextUsage: null,
777 nativeMetadata:
778 input.itemIdentityVersion === undefined
779 ? null
780 : { itemIdentityVersion: input.itemIdentityVersion },
781 createdAt: input.now,
782 updatedAt: input.now,
783 };
784}
785
786/**
787 * Unwrap a codex-acp style MCP call result (`{ result, error }` around MCP
788 * `content`/`structuredContent`) the same way the native Codex adapter does,
789 * so recovered MCP items render identical output. Unknown shapes pass through.
790 */
791function acpMcpToolCallOutput(rawOutput: unknown): unknown {
792 const record = unknownRecord(rawOutput);
793 if (record === undefined) return rawOutput;
794 if (!("result" in record) && !("error" in record)) return rawOutput;
795 const result = unknownRecord(record.result);
796 const resultOutput =
797 result === undefined ? undefined : (result.structuredContent ?? result.content ?? undefined);
798 const errorMessage = unknownRecord(record.error)?.message;
799 if (typeof errorMessage !== "string") {
800 return resultOutput ?? rawOutput;
801 }
802 return resultOutput === undefined
803 ? { error: errorMessage }
804 : { error: errorMessage, result: resultOutput };
805}
806
807function nonEmptyText(value: unknown, fallback: string): string {
808 return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
809}
810
811function decodeByteText(value: unknown): string | undefined {
812 if (!Array.isArray(value) || value.length === 0) return undefined;
813 if (!value.every((entry) => typeof entry === "number" && Number.isInteger(entry))) {
814 return undefined;
815 }
816 try {
817 // Preserve leading/trailing whitespace like the string path in textFromUnknown.
818 const text = new TextDecoder().decode(Uint8Array.from(value as number[]));
819 return text.length > 0 ? text : undefined;
820 } catch {
821 return undefined;
822 }
823}
824
825function textFromUnknown(value: unknown): string | undefined {
826 if (typeof value === "string") {
827 return value;
828 }
829 const fromBytes = decodeByteText(value);
830 if (fromBytes !== undefined) {
831 return fromBytes;
832 }
833 if (Array.isArray(value)) {
834 const parts = value.flatMap((entry) => {
835 const text = textFromUnknown(entry);
836 return text === undefined || text.length === 0 ? [] : [text];
837 });
838 return parts.length === 0 ? undefined : parts.join("\n");
839 }
840 const record = unknownRecord(value);
841 if (record === undefined) {
842 return undefined;
843 }
844 // Prefer prompt-facing Grok fields before nested envelopes.
845 // Antigravity reports shell output as combinedOutput.
846 for (const key of [
847 "output_for_prompt",
848 "stdout",
849 "stderr",
850 "output",
851 "combinedOutput",
852 "combined_output",
853 "content",
854 "text",
855 "message",
856 ]) {
857 const direct = record[key];
858 if (typeof direct === "string" && direct.length > 0) {
859 return direct;
860 }
861 const decoded = decodeByteText(direct);
862 if (decoded !== undefined) {
863 return decoded;
864 }
865 const text = textFromUnknown(direct);
866 if (text !== undefined && text.length > 0) {
867 return text;
868 }
869 }
870 const result = unknownRecord(record.Result) ?? unknownRecord(record.result);
871 if (result !== undefined) {
872 return textFromUnknown(result);
873 }
874 return undefined;
875}
876
877function commandExitCode(value: unknown): number | undefined {
878 const record = unknownRecord(value);
879 for (const key of ["exitCode", "exit_code", "code"]) {
880 const candidate = record?.[key];
881 if (typeof candidate === "number" && Number.isInteger(candidate)) {
882 return candidate;
883 }
884 }
885 return undefined;
886}
887
888/**
889 * Project an exit code only when the tool has a terminal native status.
890 * Mid-stream Grok Bash re-reports carry exit_code 0 while still in progress;
891 * interrupted tools must not retain that stale success code.
892 */
893export function acpProjectedCommandExitCode(
894 status: "pending" | "running" | "waiting" | "completed" | "failed" | "interrupted",
895 rawOutput: unknown,
896): number | undefined {
897 if (status !== "completed" && status !== "failed") {
898 return undefined;
899 }
900 return commandExitCode(rawOutput);
901}
902
903function structuredFileChanges(toolCall: AcpToolCallState) {
904 const content = toolCall.data.content;
905 if (!Array.isArray(content)) return [];
906 return content.flatMap((entry) => {
907 const diff = unknownRecord(entry);
908 if (diff?.type !== "diff" || !Array.isArray(diff.changes)) return [];
909 return diff.changes.flatMap((candidate) => {
910 const change = unknownRecord(candidate);
911 const operation = typeof change?.operation === "string" ? change.operation.trim() : "";
912 const path = typeof change?.path === "string" ? change.path.trim() : "";
913 if (operation.length === 0 || path.length === 0) return [];
914 const oldPath = typeof change?.oldPath === "string" ? change.oldPath.trim() : "";
915 const fileType = typeof change?.fileType === "string" ? change.fileType.trim() : "";
916 const mimeType = typeof change?.mimeType === "string" ? change.mimeType.trim() : "";
917 return [
918 {
919 operation,
920 path,
921 ...(oldPath.length === 0 ? {} : { oldPath }),
922 ...(fileType.length === 0 ? {} : { fileType }),
923 ...(mimeType.length === 0 ? {} : { mimeType }),
924 },
925 ];
926 });
927 });
928}
929
930// Past this edit distance an edit keeps no patch text, so projecting a large
931// rewrite cannot stall the event loop in the diff search. A created or emptied
932// file has one empty side and needs no search, so it is never capped.
933const ACP_V1_DIFF_MAX_EDITS = 1_000;
934
935/**
936 * Patch text for a tool call's diff content. ACP v2 diffs carry it as
937 * `patch.text`. ACP v1 diffs carry `oldText`/`newText` instead (`oldText`
938 * null or absent for a new file), and agents that negotiate v1 still send
939 * that shape, so the patch is built from the two sides.
940 */
941export function acpToolCallDiffPatch(content: unknown): string | undefined {
942 if (!Array.isArray(content)) return undefined;
943 const diffs = content.flatMap((entry) => {
944 const diff = unknownRecord(entry);
945 return diff?.type === "diff" ? [diff] : [];
946 });
947 for (const diff of diffs) {
948 const patch = unknownRecord(diff.patch);
949 if (typeof patch?.text === "string") return patch.text;
950 }
951 const v1Patches = diffs.flatMap((diff) => {
952 if (typeof diff.path !== "string" || typeof diff.newText !== "string") return [];
953 const oldText = typeof diff.oldText === "string" ? diff.oldText : undefined;
954 const patch = structuredPatch(
955 oldText === undefined ? "/dev/null" : diff.path,
956 diff.path,
957 oldText ?? "",
958 diff.newText,
959 undefined,
960 undefined,
961 {
962 context: 3,
963 maxEditLength: oldText && diff.newText ? ACP_V1_DIFF_MAX_EDITS : Number.POSITIVE_INFINITY,
964 },
965 );
966 return patch === undefined || patch.hunks.length === 0 ? [] : [patch];
967 });
968 return v1Patches.length === 0 ? undefined : formatPatch(v1Patches, FILE_HEADERS_ONLY);
969}
970
971function pathFromToolCall(toolCall: AcpToolCallState): string | undefined {
972 const locations = toolCall.data.locations;
973 if (Array.isArray(locations)) {
974 for (const location of locations) {
975 const path = unknownRecord(location)?.path;
976 if (typeof path === "string" && path.trim().length > 0) {
977 return path.trim();
978 }
979 }
980 }
981 const rawInput = unknownRecord(toolCall.data.rawInput);
982 for (const key of ["path", "filePath", "file_path", "url", "query", "pattern"]) {
983 const candidate = rawInput?.[key];
984 if (typeof candidate === "string" && candidate.trim().length > 0) {
985 return candidate.trim();
986 }
987 }
988 return undefined;
989}
990
991/**
992 * Grok runs X and web searches server-side as `search` tools whose rawInput is
993 * only `{ variant: "XSearch" | "WebSearch", backend: true }`. The query arrives
994 * with completion: web searches report `action: { query, sources }`, X searches
995 * the backend call `{ name, input }` with JSON-encoded arguments.
996 */
997function acpBackendWebSearch(
998 rawInput: Record<string, unknown> | undefined,
999 rawOutput: Record<string, unknown> | undefined,
1000):
1001 | { readonly query: string | undefined; readonly results: OrchestrationV2WebSearchResult[] }
1002 | undefined {
1003 const variant = typeof rawInput?.variant === "string" ? rawInput.variant.toLowerCase() : "";
1004 const action = unknownRecord(rawOutput?.action);
1005 if (variant !== "xsearch" && variant !== "websearch" && action?.type !== "search") {
1006 return undefined;
1007 }
1008 let args: Record<string, unknown> | undefined;
1009 if (typeof rawOutput?.input === "string") {
1010 try {
1011 args = unknownRecord(JSON.parse(rawOutput.input));
1012 } catch {
1013 args = undefined;
1014 }
1015 }
1016 const argsText = Object.entries(args ?? {})
1017 .filter(([, value]) => typeof value === "string" || typeof value === "number")
1018 .map(([key, value]) => `${key}: ${value}`)
1019 .join(", ");
1020 const query = [action?.query, args?.query, argsText]
1021 .find((value): value is string => typeof value === "string" && value.trim().length > 0)
1022 ?.trim();
1023 const urls = new Set<string>();
1024 for (const source of Array.isArray(action?.sources) ? action.sources : []) {
1025 const url = unknownRecord(source)?.url;
1026 if (typeof url === "string" && url.trim().length > 0) urls.add(url.trim());
1027 }
1028 return { query, results: [...urls].map((url) => ({ url })) };
1029}
1030
1031function providerRequestKind(kind: string | "unknown"): ProviderRequestKind {
1032 switch (kind) {
1033 case "execute":
1034 return "command";
1035 case "read":
1036 case "search":
1037 case "fetch":
1038 return "file-read";
1039 case "edit":
1040 case "delete":
1041 case "move":
1042 return "file-change";
1043 default:
1044 return "command";
1045 }
1046}
1047
1048function toolStatus(
1049 status: AcpToolCallState["status"],
1050): "pending" | "running" | "waiting" | "completed" | "failed" {
1051 switch (status) {
1052 case "completed":
1053 return "completed";
1054 case "failed":
1055 return "failed";
1056 case "pending":
1057 return "pending";
1058 case "requiresAction":
1059 return "waiting";
1060 default:
1061 return "running";
1062 }
1063}
1064
1065type ProjectedToolStatus = ReturnType<typeof toolStatus> | "interrupted";
1066
1067function nodeStatus(status: ProjectedToolStatus): OrchestrationV2ExecutionNode["status"] {
1068 return status === "pending" ? "running" : status;
1069}
1070
1071function completedAtForStatus(status: ProjectedToolStatus, now: DateTime.Utc): DateTime.Utc | null {
1072 return status === "completed" || status === "failed" || status === "interrupted" ? now : null;
1073}
1074
1075function selectPermissionOptionId(
1076 request: EffectAcpSchema.RequestPermissionRequest,
1077 decision: Exclude<ProviderApprovalDecision, "cancel">,
1078): string | undefined {
1079 const kind =
1080 decision === "acceptForSession"
1081 ? "allow_always"
1082 : decision === "accept"
1083 ? "allow_once"
1084 : "reject_once";
1085 return request.options.find((option) => option.kind === kind)?.optionId.trim() || undefined;
1086}
1087
1088/**
1089 * The runtime policy approves one request, so answer with the agent's
1090 * allow-once option. Its allow-always option can outlive the session (Grok
1091 * saves it for the whole project); use it only when no allow-once exists.
1092 */
1093function selectAutoApprovedPermissionOption(
1094 request: EffectAcpSchema.RequestPermissionRequest,
1095): string | undefined {
1096 return (
1097 selectPermissionOptionId(request, "accept") ??
1098 selectPermissionOptionId(request, "acceptForSession")
1099 );
1100}
1101
1102function elicitationContent(
1103 answers: ProviderUserInputAnswers,
1104 allowedKeys: ReadonlySet<string>,
1105): Record<string, EffectAcpSchema.ElicitationContentValue> {
1106 const content: Record<string, EffectAcpSchema.ElicitationContentValue> = {};
1107 for (const [key, value] of Object.entries(answers)) {
1108 if (!allowedKeys.has(key)) continue;
1109 if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
1110 content[key] = value;
1111 } else if (Array.isArray(value)) {
1112 content[key] = value.filter((entry): entry is string => typeof entry === "string");
1113 }
1114 }
1115 return content;
1116}
1117
1118interface ActiveTextSegment {
1119 readonly nativeItemId: string;
1120 readonly startedAt: DateTime.Utc;
1121 sourceMessageId: string | null;
1122 text: string;
1123}
1124
1125interface ActiveTextStream {
1126 current: ActiveTextSegment | null;
1127 nextSegment: number;
1128}
1129
1130interface AcpNativeBuildConfiguration {
1131 readonly modeId?: string;
1132 readonly configOptions: ReadonlyArray<{ readonly id: string; readonly value: string }>;
1133}
1134
1135interface ActiveAcpTurn {
1136 readonly input: ProviderAdapter.ProviderAdapterV2TurnInput;
1137 readonly providerTurnId: OrchestrationV2ProviderTurn["id"];
1138 readonly nativeThreadId: string;
1139 readonly nativeTurnId: string;
1140 readonly startedAt: DateTime.Utc;
1141 readonly completed: Deferred.Deferred<void, never>;
1142 // Root item ordinals allocated in this turn. Subagent child items keep
1143 // theirs on the subagent, which carries over into later turns.
1144 readonly itemOrdinals: Map<string, number>;
1145 readonly user: ActiveTextStream;
1146 readonly assistant: ActiveTextStream;
1147 readonly reasoning: ActiveTextStream;
1148 providerRetry?:
1149 | {
1150 readonly failure: OrchestrationV2ProviderFailure;
1151 readonly retry: OrchestrationV2ProviderRetry;
1152 readonly startedAt: DateTime.Utc;
1153 readonly itemOrdinal: number;
1154 }
1155 | undefined;
1156 contextUsage: ThreadTokenUsageSnapshot | null;
1157 nativeMetadata: OrchestrationV2ProviderThreadNativeMetadata | null;
1158 readonly tools: Map<string, AcpToolCallState>;
1159 /** Streamed tool updates skipped since the last persisted one; see `shouldPersistToolUpdate`. */
1160 readonly toolUpdatesSkipped: Map<string, number>;
1161 readonly toolStartedAt: Map<string, DateTime.Utc>;
1162 readonly subagents: Map<string, ActiveAcpSubagent>;
1163 readonly subagentsBySessionId: Map<string, ActiveAcpSubagent>;
1164 readonly pendingSubagentNotifications: Map<string, Array<EffectAcpSchema.SessionNotification>>;
1165 /** Background monitor/task id → toolCallId for synthetic root text updates. */
1166 readonly toolCallIdsByBackgroundTaskId: Map<string, string>;
1167 /**
1168 * Persistent monitors registered this turn. Excluded from deferred-finalize
1169 * holds so the root turn can settle while they keep streaming post-settle.
1170 */
1171 readonly persistentBackgroundTaskIds: Set<string>;
1172 /**
1173 * Monitor end events often only say "use get_command…"; keep the turn open
1174 * until TaskOutput hydration arrives (or the safety timeout elapses).
1175 */
1176 readonly awaitingBackgroundHydration: Set<string>;
1177 /**
1178 * A monitor end notice landed after the prompt settled: the CLI runs an
1179 * injected turn whose report never gets a turn_completed marker, so the
1180 * report chunk races the deferred-finalize debounce (thread a8e8b0a9 run 5
1181 * dropped the listing this way). Hold finalize until the report streams or
1182 * the safety timeout elapses.
1183 */
1184 readonly pendingInjectedReport: Set<string>;
1185 /**
1186 * An injected assistant report can race ahead of its monitor end notice.
1187 * Remember that unmatched report so the later notice consumes, rather than
1188 * re-arming, the task's pre-settle completion marker.
1189 */
1190 earlyInjectedReportObserved: boolean;
1191 readonly plans: Map<
1192 string,
1193 {
1194 readonly id: OrchestrationV2PlanArtifact["id"];
1195 readonly startedAt: DateTime.Utc;
1196 latest: OrchestrationV2PlanArtifact | null;
1197 }
1198 >;
1199 interrupted: boolean;
1200 finalized: boolean;
1201 finalizedStatus: "completed" | "interrupted" | "failed" | "cancelled" | null;
1202 /** session/prompt already returned; finalize deferred for background work. */
1203 promptSettled: boolean;
1204 promptSettledStatus: "completed" | "interrupted" | "failed" | "cancelled" | null;
1205 /**
1206 * Completed the moment `runtime.prompt` resolves on the wire, before the
1207 * completion callback requests `runtimeCallbackPermit`. Failure does not
1208 * complete this; settled-soft classification ORs it with `promptSettled`.
1209 */
1210 readonly promptWireSettled: Deferred.Deferred<void, never>;
1211 backgroundFinalizeGeneration: number;
1212}
1213
1214type AcpRuntimeTeardownState =
1215 | { readonly _tag: "Idle" }
1216 | {
1217 readonly _tag: "InProgress";
1218 readonly completed: Deferred.Deferred<void, ProviderAdapter.ProviderAdapterProtocolError>;
1219 }
1220 | { readonly _tag: "Failed"; readonly error: ProviderAdapter.ProviderAdapterProtocolError };
1221
1222/** True when a root session/update carries ingestible turn output, not keepalive noise. */
1223function acpRootSessionUpdateIngestsOutput(
1224 notification: EffectAcpSchema.SessionNotification,
1225): boolean {
1226 const update = notification.update;
1227 switch (update.sessionUpdate) {
1228 case "agent_message_chunk":
1229 case "agent_thought_chunk":
1230 return (acpContentBlockDisplayText(update.content)?.length ?? 0) > 0;
1231 case "agent_message":
1232 case "agent_thought":
1233 return (update.content ?? []).some(
1234 (content) => (acpContentBlockDisplayText(content)?.length ?? 0) > 0,
1235 );
1236 case "tool_call":
1237 case "tool_call_update":
1238 case "plan":
1239 case "plan_update":
1240 case "plan_removed":
1241 case "compaction_update":
1242 case "compaction_summary_chunk":
1243 return parseSessionUpdateEvent(notification).events.some(
1244 (event) => event._tag === "ToolCallUpdated" || event._tag === "PlanUpdated",
1245 );
1246 default:
1247 return false;
1248 }
1249}
1250
1251/**
1252 * Post-settle traffic that should be *buffered* for a continuation attach.
1253 * Excludes monitor end/event chatter that must not become ghost history.
1254 * Broader than {@link acpPostSettleContinuationOfferEvidence}: incremental
1255 * tool progress may still need replay once a real completion offers a run.
1256 */
1257export function acpPostSettleWakeEvidence(
1258 notification: EffectAcpSchema.SessionNotification,
1259 flavor: Pick<AcpAdapterV2Flavor, "extractBackgroundToolMutation"> = {},
1260): boolean {
1261 if (!acpRootSessionUpdateIngestsOutput(notification)) return false;
1262 const update = notification.update;
1263 if (
1264 (update.sessionUpdate === "user_message_chunk" ||
1265 update.sessionUpdate === "agent_message_chunk") &&
1266 update.content.type === "text"
1267 ) {
1268 const text = update.content.text;
1269 if ((flavor.extractBackgroundToolMutation?.(text) ?? []).length > 0) return false;
1270 if (update.sessionUpdate === "agent_message_chunk" && text.trim().length === 0) return false;
1271 if (/<monitor-event\b/i.test(text) || /Monitor\s+["']?[0-9a-f-]{8,}["']?\s+ended/i.test(text)) {
1272 return false;
1273 }
1274 }
1275 return true;
1276}
1277
1278/**
1279 * Whether buffered post-settle traffic should *offer* a continuation run now.
1280 * Still-running tool streams often land farther apart than the deferred-finalize
1281 * quiet window; treating every tool_call_update as offer evidence re-opens a
1282 * synthetic "Background task completed." run on each chunk. Only completion-like
1283 * frames (real agent text, or a terminal tool status) should open a new run.
1284 */
1285export function acpPostSettleContinuationOfferEvidence(
1286 notification: EffectAcpSchema.SessionNotification,
1287 flavor: Pick<AcpAdapterV2Flavor, "extractBackgroundToolMutation" | "normalizeToolCall"> = {},
1288): boolean {
1289 if (!acpPostSettleWakeEvidence(notification, flavor)) {
1290 return false;
1291 }
1292 const update = notification.update;
1293 // Assistant text only. Thought/reasoning bursts alone must not open synthetic
1294 // "Background task completed." runs (duplicate-run spam after monitors).
1295 if (update.sessionUpdate === "agent_message_chunk") {
1296 return (acpContentBlockDisplayText(update.content)?.trim().length ?? 0) > 0;
1297 }
1298 if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
1299 return parseSessionUpdateEvent(notification).events.some((event) => {
1300 if (event._tag !== "ToolCallUpdated") return false;
1301 // Normalize first: a Grok monitor start ACK arrives with raw status
1302 // "completed" but is a still-running background task, not completion.
1303 const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall;
1304 return toolCall.status === "completed" || toolCall.status === "failed";
1305 });
1306 }
1307 return false;
1308}
1309
1310export function acpPostSettleWakeShouldBuffer(
1311 notification: EffectAcpSchema.SessionNotification,
1312 backgroundWorkRunning: boolean,
1313): boolean {
1314 if (!backgroundWorkRunning) return true;
1315 const update = notification.update;
1316 return (
1317 update.sessionUpdate !== "agent_message_chunk" && update.sessionUpdate !== "agent_thought_chunk"
1318 );
1319}
1320
1321/**
1322 * An app-owned wake (a delegated child finishing) is injected by the
1323 * orchestrator, not typed by the user. It reports on a sibling child and says
1324 * nothing about this session's own pending wake frames, so it must not discard
1325 * them the way a real user turn does. ClaudeAdapterV2 already leaves its buffer
1326 * alone on non-continuation turns; this keeps ACP consistent.
1327 */
1328export function acpIsAppOwnedWakeTurn(message: {
1329 readonly createdBy: string;
1330 readonly creationSource: string;
1331}): boolean {
1332 return message.createdBy === "agent" && message.creationSource === "server";
1333}
1334
1335/** A continuation run attaches to wake traffic the agent produced on its own. */
1336function acpIsProviderContinuationMessage(message: {
1337 readonly createdBy: string;
1338 readonly creationSource: string;
1339}): boolean {
1340 return message.createdBy === "agent" && message.creationSource === "provider";
1341}
1342
1343export function acpCarryoverTerminalShouldClearContinuation(input: {
1344 readonly continuationOffered: boolean;
1345 readonly wakeBufferLength: number;
1346}): boolean {
1347 return !input.continuationOffered && input.wakeBufferLength === 0;
1348}
1349
1350export function acpTurnStartShouldPreserveContinuation(input: {
1351 readonly continuationRequested: boolean;
1352 readonly isContinuationTurn: boolean;
1353 readonly wakeBufferLength: number;
1354}): boolean {
1355 return !input.isContinuationTurn && input.continuationRequested && input.wakeBufferLength > 0;
1356}
1357
1358export function acpPostSettleMonitorPromptShouldSuppress(
1359 mutation:
1360 | {
1361 readonly taskId: string;
1362 readonly status: "running" | "completed" | "failed";
1363 }
1364 | undefined,
1365): boolean {
1366 return mutation?.status === "running";
1367}
1368
1369export function acpCompletedTurnShouldTerminalizeTool(
1370 tool: AcpToolCallState,
1371 flavor: Pick<AcpAdapterV2Flavor, "extractBackgroundTaskId" | "extractSubagentUpdate">,
1372): boolean {
1373 const status = toolStatus(tool.status);
1374 if (status !== "pending" && status !== "running") return false;
1375 if (flavor.extractBackgroundTaskId?.(tool) !== undefined) return false;
1376 return flavor.extractSubagentUpdate?.(tool) === undefined;
1377}
1378
1379interface ActiveAcpSubagent {
1380 task: OrchestrationV2Subagent;
1381 readonly childThreadId: ThreadId;
1382 readonly childRootNodeId: OrchestrationV2ExecutionNode["id"];
1383 readonly turnItemId: OrchestrationV2TurnItem["id"];
1384 readonly turnItemOrdinal: number;
1385 /** Turn that spawned the subagent; carryover updates keep this lineage. */
1386 readonly providerTurnId: OrchestrationV2ProviderTurn["id"];
1387 readonly parentProviderThreadId: ProviderThreadId;
1388 childSessionId: string | null;
1389 assistantText: string;
1390 readonly assistantMessages: Map<string, string>;
1391 readonly childItemOrdinals: Map<string, number>;
1392 nextChildOrdinal: number;
1393 /**
1394 * Whether a terminal carryover status has been projected to events.
1395 * Completed roots project post-settle terminals immediately while their
1396 * subscriber remains observable. Non-completed roots retain terminals in
1397 * memory until the next attach. A later project:true path for the same entry
1398 * must still project once; this flag prevents double emission and pins
1399 * hasPendingBackgroundWork until projection lands.
1400 */
1401 terminalStatusProjected: boolean;
1402}
1403
1404function acpSubagentStatusIsTerminal(status: OrchestrationV2Subagent["status"]): boolean {
1405 return (
1406 status === "completed" ||
1407 status === "failed" ||
1408 status === "interrupted" ||
1409 status === "cancelled" ||
1410 status === "idle"
1411 );
1412}
1413
1414export function acpSubagentStatusBlocksTurnSettlement(
1415 status: OrchestrationV2Subagent["status"],
1416): boolean {
1417 return status === "running" || status === "pending";
1418}
1419
1420function acpSubagentHasPendingBackgroundWork(subagent: ActiveAcpSubagent): boolean {
1421 return (
1422 acpSubagentStatusBlocksTurnSettlement(subagent.task.status) || !subagent.terminalStatusProjected
1423 );
1424}
1425
1426type AcpCarryoverSubagents = {
1427 readonly sessionId: string;
1428 readonly rootTerminalStatus: "completed" | "interrupted" | "failed" | "cancelled";
1429 readonly subagents: ReadonlyArray<ActiveAcpSubagent>;
1430};
1431
1432type PendingRuntimeRequest = {
1433 readonly generation: number;
1434 readonly nativeResponseAcknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>;
1435 readonly transportRequestId: string;
1436 readonly requestId: RuntimeRequestId;
1437 readonly runtimeRequest: OrchestrationV2RuntimeRequest;
1438 readonly node: OrchestrationV2ExecutionNode;
1439 readonly turnItem: OrchestrationV2TurnItem;
1440} & (
1441 | {
1442 readonly type: "approval";
1443 readonly decision: Deferred.Deferred<ProviderApprovalDecision>;
1444 }
1445 | {
1446 readonly type: "user_input";
1447 readonly answers: Deferred.Deferred<ProviderUserInputAnswers | null>;
1448 }
1449);
1450
1451interface SnapshotMessageState {
1452 readonly order: Array<string>;
1453 readonly messages: Map<string, OrchestrationV2ConversationMessage>;
1454 loadingRole: "user" | "assistant" | "thought" | null;
1455 loadingMessageId: string | null;
1456 loadingIndex: number;
1457}
1458
1459const TOOL_UPDATE_PERSIST_EVERY = 10;
1460
1461/**
1462 * Some agents stream a tool's arguments (a file write's diff, `rawInput`) and
1463 * resend the whole call each time. Persist every 10th of those. Status, title,
1464 * and output the user watches live always persist, as does the agent's own
1465 * completed/failed when a flavor normalizes it to a non-terminal status.
1466 */
1467function shouldPersistToolUpdate(
1468 context: ActiveAcpTurn,
1469 key: string,
1470 previous: AcpToolCallState | undefined,
1471 next: AcpToolCallState,
1472 reportedStatus: AcpToolCallState["status"],
1473): boolean {
1474 const skipped = context.toolUpdatesSkipped.get(key) ?? 0;
1475 const persist =
1476 reportedStatus === "completed" ||
1477 reportedStatus === "failed" ||
1478 previous === undefined ||
1479 previous.status !== next.status ||
1480 previous.title !== next.title ||
1481 toolCallVisibleOutputChanged(previous, next) ||
1482 skipped + 1 >= TOOL_UPDATE_PERSIST_EVERY;
1483 context.toolUpdatesSkipped.set(key, persist ? 0 : skipped + 1);
1484 return persist;
1485}
1486
1487export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
1488 options: AcpAdapterV2Options,
1489) {
1490 const crypto = yield* Crypto.Crypto;
1491 const fileSystem = yield* FileSystem.FileSystem;
1492 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1493 const agentScope = yield* AgentScope;
1494 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1495 const host = yield* ProviderHost.ProviderHost;
1496 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
1497 const scriptProgress = yield* ScriptProgress.ScriptProgress;
1498 const { flavor, selfInvocation: self } = options;
1499 const readMcpContext = (threadId: ThreadId | null) =>
1500 threadId === null
1501 ? Effect.succeed(acpMcpContext(undefined, self))
1502 : Effect.gen(function* () {
1503 const context = acpMcpContext(yield* mcpSessions.read(threadId), self);
1504 const environment = yield* scriptProgress.environmentForThread(threadId);
1505 if (Object.keys(environment).length === 0) return context;
1506 return {
1507 ...context,
1508 processEnvironment: ScriptProgress.withScriptProgressEnvironment(
1509 context.processEnvironment ?? {},
1510 environment,
1511 ),
1512 };
1513 });
1514 const driver = flavor.driver;
1515 const continuationRequests = options.continuationRequests;
1516 const postSettleContinuationEnabled =
1517 flavor.enablePostSettleContinuation === true && continuationRequests !== undefined;
1518
1519 return ProviderAdapter.ProviderAdapterV2.of({
1520 instanceId: options.instanceId,
1521 driver,
1522 getCapabilities: () => Effect.succeed(flavor.capabilities),
1523 planSelectionTransition: (input) => Effect.succeed(acpSelectionTransition(input)),
1524 openSession: Effect.fn("AcpAdapterV2.openSession")(
1525 function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) {
1526 const sessionScope = yield* Effect.scope;
1527 // Persisted ACP threads from before item identity v2 retain their old
1528 // deterministic ids. Fresh threads scope native ids by instance so
1529 // separately configured agents cannot collide.
1530 let itemIdentityVersion: 2 | undefined =
1531 input.initialProviderItemIdentityVersion ??
1532 (input.initialNativeThreadId === undefined ? 2 : undefined);
1533 const providerNativeId = (nativeId: string) =>
1534 acpProviderItemNativeId({
1535 instanceId: options.instanceId,
1536 itemIdentityVersion,
1537 nativeId,
1538 });
1539 const providerNodeId = (nativeItemId: string) =>
1540 idAllocator.derive.nodeFromProviderItem({
1541 driver,
1542 nativeItemId: providerNativeId(nativeItemId),
1543 });
1544 const providerMessageId = (nativeItemId: string) =>
1545 idAllocator.derive.messageFromProviderItem({
1546 driver,
1547 nativeItemId: providerNativeId(nativeItemId),
1548 });
1549 const providerTurnItemId = (nativeItemId: string) =>
1550 idAllocator.derive.turnItemFromProviderItem({
1551 driver,
1552 nativeItemId: providerNativeId(nativeItemId),
1553 });
1554 const deriveProviderTurnId = (nativeTurnId: string) =>
1555 idAllocator.derive.providerTurn({
1556 driver,
1557 nativeTurnId: providerNativeId(nativeTurnId),
1558 });
1559 const useProviderThreadIdentity = (thread: OrchestrationV2ProviderThread): void => {
1560 itemIdentityVersion = thread.nativeMetadata?.itemIdentityVersion;
1561 };
1562 const terminalEnvironmentBySessionId = new Map<string, NodeJS.ProcessEnv>();
1563 interface PendingTerminalEnvironment {
1564 readonly environment: NodeJS.ProcessEnv | undefined;
1565 readonly claimUnknownSession: boolean;
1566 readonly sessionId: string | null;
1567 }
1568 let pendingTerminalEnvironment: PendingTerminalEnvironment | null = {
1569 environment: (yield* readMcpContext(input.threadId)).processEnvironment,
1570 claimUnknownSession: input.initialNativeThreadId === undefined,
1571 sessionId: input.initialNativeThreadId ?? null,
1572 };
1573 const prepareTerminalEnvironment = (
1574 mcpContext: AcpMcpContext,
1575 sessionId?: string,
1576 ): void => {
1577 pendingTerminalEnvironment = {
1578 environment: mcpContext.processEnvironment,
1579 claimUnknownSession: false,
1580 sessionId: sessionId ?? null,
1581 };
1582 };
1583 const prepareClaimableTerminalEnvironment = (mcpContext: AcpMcpContext): void => {
1584 pendingTerminalEnvironment = {
1585 environment: mcpContext.processEnvironment,
1586 claimUnknownSession: true,
1587 sessionId: null,
1588 };
1589 };
1590 const rememberTerminalEnvironment = (
1591 sessionId: string,
1592 mcpContext: AcpMcpContext,
1593 ): void => {
1594 const environment = mcpContext.processEnvironment;
1595 pendingTerminalEnvironment = null;
1596 if (environment === undefined) {
1597 terminalEnvironmentBySessionId.delete(sessionId);
1598 } else {
1599 terminalEnvironmentBySessionId.set(sessionId, environment);
1600 }
1601 };
1602 const clientTerminals: AcpClientTerminals | undefined =
1603 options.clientTerminals === undefined
1604 ? undefined
1605 : yield* makeAcpClientTerminals({
1606 defaultCwd: input.runtimePolicy.cwd ?? process.cwd(),
1607 environment: options.clientTerminals.environment,
1608 shellCommands: options.clientTerminals.shellCommands,
1609 environmentForSession: (sessionId) => {
1610 const remembered = terminalEnvironmentBySessionId.get(sessionId);
1611 if (remembered !== undefined) return remembered;
1612 if (pendingTerminalEnvironment === null) return undefined;
1613 if (
1614 pendingTerminalEnvironment.sessionId === null &&
1615 pendingTerminalEnvironment.claimUnknownSession
1616 ) {
1617 pendingTerminalEnvironment = {
1618 ...pendingTerminalEnvironment,
1619 sessionId,
1620 };
1621 }
1622 return pendingTerminalEnvironment.sessionId === sessionId
1623 ? pendingTerminalEnvironment.environment
1624 : undefined;
1625 },
1626 }).pipe(
1627 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
1628 );
1629 if (clientTerminals !== undefined) {
1630 yield* Scope.addFinalizer(sessionScope, clientTerminals.disposeAll);
1631 }
1632 // Terminal ids embedded in raw tool_call updates, remembered before the
1633 // content rewrite so emitTool can recover MCP-fallback command lines.
1634 const sessionScopedId = (sessionId: string, nativeId: string): string =>
1635 JSON.stringify([sessionId, nativeId]);
1636 const embeddedTerminalsByToolCallId = new Map<
1637 string,
1638 {
1639 readonly sessionId: string;
1640 readonly terminalIds: ReadonlyArray<string>;
1641 readonly toolCallId: string;
1642 }
1643 >();
1644 const toolCallIdsByAgentTerminalId = new Map<string, Set<string>>();
1645 const agentTerminalsById = new Map<string, AcpAgentTerminalState>();
1646 const rememberEmbeddedTerminals = (input: {
1647 readonly sessionId: string;
1648 readonly toolCallId: string;
1649 readonly terminalIds: ReadonlyArray<string>;
1650 }): void => {
1651 const toolCallKey = sessionScopedId(input.sessionId, input.toolCallId);
1652 for (const terminalId of embeddedTerminalsByToolCallId.get(toolCallKey)?.terminalIds ??
1653 []) {
1654 const terminalKey = sessionScopedId(input.sessionId, terminalId);
1655 const toolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey);
1656 toolCallIds?.delete(input.toolCallId);
1657 if (toolCallIds?.size === 0) toolCallIdsByAgentTerminalId.delete(terminalKey);
1658 }
1659 embeddedTerminalsByToolCallId.delete(toolCallKey);
1660 embeddedTerminalsByToolCallId.set(toolCallKey, {
1661 sessionId: input.sessionId,
1662 terminalIds: input.terminalIds,
1663 toolCallId: input.toolCallId,
1664 });
1665 for (const terminalId of input.terminalIds) {
1666 const terminalKey = sessionScopedId(input.sessionId, terminalId);
1667 const toolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey) ?? new Set<string>();
1668 toolCallIds.add(input.toolCallId);
1669 toolCallIdsByAgentTerminalId.set(terminalKey, toolCallIds);
1670 }
1671 for (const oldest of embeddedTerminalsByToolCallId.keys()) {
1672 if (embeddedTerminalsByToolCallId.size <= 256) break;
1673 const remembered = embeddedTerminalsByToolCallId.get(oldest);
1674 if (remembered === undefined) continue;
1675 for (const terminalId of remembered.terminalIds) {
1676 const terminalKey = sessionScopedId(remembered.sessionId, terminalId);
1677 const toolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey);
1678 toolCallIds?.delete(remembered.toolCallId);
1679 if (toolCallIds?.size === 0) toolCallIdsByAgentTerminalId.delete(terminalKey);
1680 }
1681 embeddedTerminalsByToolCallId.delete(oldest);
1682 }
1683 };
1684 // Command lines of the terminals embedded in a tool call, so MCP calls
1685 // made through the acp-mcp-call terminal fallback keep their identity.
1686 const embeddedTerminalCommands = (
1687 sessionId: string,
1688 toolCallId: string,
1689 ): ReadonlyArray<string> =>
1690 (
1691 embeddedTerminalsByToolCallId.get(sessionScopedId(sessionId, toolCallId))
1692 ?.terminalIds ?? []
1693 ).flatMap((terminalId) => {
1694 const command =
1695 clientTerminals?.readCommandLine(terminalId) ??
1696 agentTerminalsById.get(sessionScopedId(sessionId, terminalId))?.command;
1697 return command === undefined ? [] : [command];
1698 });
1699 // Client terminals (Devin) run with the T3 server's privileges, so they
1700 // are policy-checked against the active turn policy; a command the user
1701 // already approved satisfies an "ask" disposition.
1702 const clientPolicyGrants = makeAcpClientPolicyGrants();
1703 let latestRuntimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy =
1704 input.runtimePolicy;
1705 const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
1706 const activeTurn = yield* Ref.make<ActiveAcpTurn | null>(null);
1707 const activeSessionId = yield* Ref.make<string | null>(null);
1708 const contextUsageBySessionId = yield* Ref.make(
1709 new Map<string, ThreadTokenUsageSnapshot>(),
1710 );
1711 const nativeMetadataBySessionId = yield* Ref.make(
1712 new Map<string, OrchestrationV2ProviderThreadNativeMetadata>(),
1713 );
1714 const providerThreadByNativeSessionId = yield* Ref.make(
1715 new Map<string, OrchestrationV2ProviderThread>(),
1716 );
1717 // T3 only owns the temporary Plan override. Remember the agent's
1718 // effective native configuration on entry and restore it on Build.
1719 const nativeBuildConfigurationBySessionId = new Map<string, AcpNativeBuildConfiguration>();
1720 const initialSessionActivationFailure = yield* Ref.make<{
1721 readonly sessionId: string;
1722 readonly error: EffectAcpErrors.AcpError;
1723 } | null>(null);
1724 const activeSessionSetup =
1725 yield* Ref.make<AcpSessionRuntime.AcpSessionRuntimeStartResult | null>(null);
1726 const activeSelection = yield* Ref.make<ModelSelection | null>(null);
1727 const activeInteractionMode = yield* Ref.make<ProviderInteractionMode | null>(null);
1728 const promptInstructionStates = yield* Ref.make(new Map<string, T3AcpInstructionState>());
1729 const runtimeRestartRequired = yield* Ref.make(false);
1730 const runtimeTeardownState = yield* Ref.make<AcpRuntimeTeardownState>({ _tag: "Idle" });
1731 const runtimeCallbackGeneration = yield* Ref.make(0);
1732 const runtimeCallbackGenerationCounter = yield* Ref.make(0);
1733 const allocateRuntimeCallbackGeneration = Ref.updateAndGet(
1734 runtimeCallbackGenerationCounter,
1735 (generation) => generation + 1,
1736 );
1737 const advanceRuntimeCallbackGeneration = allocateRuntimeCallbackGeneration.pipe(
1738 Effect.tap((generation) => Ref.set(runtimeCallbackGeneration, generation)),
1739 );
1740 const runtimeCallbackPermit = yield* Semaphore.make(1);
1741 const runtimeTransitionPermit = yield* Semaphore.make(1);
1742 const nativeResponseAcknowledgements = yield* Ref.make(
1743 new Map<
1744 string,
1745 {
1746 readonly acknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>;
1747 readonly generation: number;
1748 }
1749 >(),
1750 );
1751 const pendingRuntimeRequests = yield* Ref.make(new Map<string, PendingRuntimeRequest>());
1752 const emitNativeResponseLifecycle =
1753 options.testHooks?.onNativeResponseLifecycle ?? (() => Effect.void);
1754 const nextElicitationOrdinal = yield* Ref.make(0);
1755 const providerTurns = yield* Ref.make(new Map<string, OrchestrationV2ProviderTurn>());
1756 const snapshot = yield* Ref.make<SnapshotMessageState>({
1757 order: [],
1758 messages: new Map(),
1759 loadingRole: null,
1760 loadingMessageId: null,
1761 loadingIndex: 0,
1762 });
1763
1764 const awaitRuntimeTeardown = Effect.fnUntraced(function* () {
1765 const state = yield* Ref.get(runtimeTeardownState);
1766 if (state._tag === "InProgress") {
1767 yield* Deferred.await(state.completed);
1768 } else if (state._tag === "Failed") {
1769 return yield* state.error;
1770 }
1771 });
1772 const runRuntimeCallbackAtGeneration = <A, E, R>(
1773 generation: number,
1774 effect: Effect.Effect<A, E, R>,
1775 ) =>
1776 runtimeCallbackPermit.withPermit(
1777 Effect.gen(function* () {
1778 if ((yield* Ref.get(runtimeTeardownState))._tag !== "Idle") {
1779 return Option.none<A>();
1780 }
1781 if ((yield* Ref.get(runtimeCallbackGeneration)) !== generation) {
1782 return Option.none<A>();
1783 }
1784 return Option.some(yield* effect);
1785 }),
1786 );
1787 const registerNativeResponseAcknowledgement = (
1788 generation: number,
1789 transportRequestId: string,
1790 acknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>,
1791 ) =>
1792 Effect.gen(function* () {
1793 yield* Ref.update(nativeResponseAcknowledgements, (current) => {
1794 const updated = new Map(current);
1795 updated.set(transportRequestId, { acknowledgement, generation });
1796 return updated;
1797 });
1798 yield* emitNativeResponseLifecycle({
1799 type: "registered",
1800 generation,
1801 requestId: transportRequestId,
1802 });
1803 if (!(yield* Deferred.isDone(acknowledgement))) return;
1804 yield* Ref.update(nativeResponseAcknowledgements, (current) => {
1805 if (current.get(transportRequestId)?.acknowledgement !== acknowledgement) {
1806 return current;
1807 }
1808 const updated = new Map(current);
1809 updated.delete(transportRequestId);
1810 return updated;
1811 });
1812 });
1813 const acknowledgeNativeResponse = (generation: number, transportRequestId: string) =>
1814 (
1815 options.testHooks?.beforeNativeResponseAdmissionCheck?.(
1816 generation,
1817 transportRequestId,
1818 ) ?? Effect.void
1819 ).pipe(
1820 Effect.andThen(runRuntimeCallbackAtGeneration(generation, Effect.void)),
1821 Effect.flatMap((registered) =>
1822 Option.isSome(registered)
1823 ? Effect.void
1824 : emitNativeResponseLifecycle({
1825 type: "admission_rejected",
1826 generation,
1827 requestId: transportRequestId,
1828 }).pipe(
1829 Effect.andThen(
1830 new EffectAcpErrors.AcpTransportError({
1831 detail: "The ACP runtime closed before its response could be admitted",
1832 cause: "Native response registration rejected during teardown",
1833 }),
1834 ),
1835 ),
1836 ),
1837 );
1838 const awaitNativeResponseAcknowledgements = Effect.fnUntraced(function* (
1839 acknowledgements: ReadonlyArray<
1840 readonly [string | undefined, Deferred.Deferred<void, EffectAcpErrors.AcpError>]
1841 >,
1842 ) {
1843 if (acknowledgements.length === 0) return true;
1844 const completed = yield* Deferred.make<"settled" | "timeout">();
1845 yield* emitNativeResponseLifecycle({ type: "watcher_started" });
1846 const acknowledgementFiber = yield* Effect.forEach(
1847 acknowledgements,
1848 ([, acknowledgement]) => Deferred.await(acknowledgement).pipe(Effect.exit),
1849 { concurrency: "unbounded", discard: true },
1850 ).pipe(
1851 Effect.andThen(Deferred.succeed(completed, "settled")),
1852 Effect.interruptible,
1853 Effect.ensuring(emitNativeResponseLifecycle({ type: "watcher_exited" })),
1854 Effect.forkDetach,
1855 );
1856 yield* emitNativeResponseLifecycle({ type: "timer_started" });
1857 const timerFiber = yield* Effect.sleep("2 seconds").pipe(
1858 Effect.andThen(Deferred.succeed(completed, "timeout")),
1859 Effect.interruptible,
1860 Effect.ensuring(emitNativeResponseLifecycle({ type: "timer_exited" })),
1861 Effect.forkDetach,
1862 );
1863 const outcome = yield* Deferred.await(completed);
1864 yield* Fiber.interrupt(acknowledgementFiber);
1865 yield* Fiber.interrupt(timerFiber);
1866 if (outcome === "settled") return true;
1867
1868 yield* Ref.update(nativeResponseAcknowledgements, (current) => {
1869 const updated = new Map(current);
1870 for (const [requestId, acknowledgement] of acknowledgements) {
1871 if (
1872 requestId !== undefined &&
1873 updated.get(requestId)?.acknowledgement === acknowledgement
1874 ) {
1875 updated.delete(requestId);
1876 }
1877 }
1878 return updated;
1879 });
1880 yield* Effect.forEach(
1881 acknowledgements,
1882 ([requestId]) =>
1883 emitNativeResponseLifecycle({
1884 type: "removed",
1885 ...(requestId === undefined ? {} : { requestId }),
1886 }),
1887 { concurrency: "unbounded", discard: true },
1888 );
1889 const timeoutError = new EffectAcpErrors.AcpTransportError({
1890 detail: "Timed out waiting for an admitted ACP response to reach the transport queue",
1891 cause: "Native response acknowledgement timed out",
1892 });
1893 yield* Effect.forEach(
1894 acknowledgements,
1895 ([requestId, acknowledgement]) =>
1896 Deferred.fail(acknowledgement, timeoutError).pipe(
1897 Effect.andThen(
1898 emitNativeResponseLifecycle({
1899 type: "failed",
1900 ...(requestId === undefined ? {} : { requestId }),
1901 }),
1902 ),
1903 ),
1904 { concurrency: "unbounded", discard: true },
1905 );
1906 return false;
1907 });
1908 const awaitAdmittedNativeResponses = Ref.get(nativeResponseAcknowledgements).pipe(
1909 Effect.flatMap((current) =>
1910 awaitNativeResponseAcknowledgements(
1911 [...current.entries()].map(
1912 ([requestId, entry]) => [requestId, entry.acknowledgement] as const,
1913 ),
1914 ),
1915 ),
1916 );
1917 const quarantineNativeTransportAtGeneration = Effect.fnUntraced(function* (
1918 generation: number,
1919 ) {
1920 const quarantined = yield* Ref.modify(nativeResponseAcknowledgements, (current) => {
1921 const updated = new Map(current);
1922 const acknowledgements: Array<Deferred.Deferred<void, EffectAcpErrors.AcpError>> = [];
1923 for (const [requestId, entry] of updated) {
1924 if (entry.generation !== generation) continue;
1925 updated.delete(requestId);
1926 acknowledgements.push(entry.acknowledgement);
1927 }
1928 return [acknowledgements, updated] as const;
1929 });
1930 const error = new EffectAcpErrors.AcpTransportError({
1931 detail: "The ACP runtime was replaced before its response reached the transport queue",
1932 cause: "ACP runtime transport was quarantined during teardown",
1933 });
1934 yield* Effect.forEach(
1935 quarantined,
1936 (acknowledgement) => Deferred.fail(acknowledgement, error),
1937 { concurrency: "unbounded", discard: true },
1938 );
1939 });
1940 const closeNativeTransport = runtimeCallbackPermit.withPermit(
1941 Effect.gen(function* () {
1942 yield* advanceRuntimeCallbackGeneration;
1943 const acknowledgements = yield* Ref.getAndSet(
1944 nativeResponseAcknowledgements,
1945 new Map(),
1946 );
1947 const error = new EffectAcpErrors.AcpTransportError({
1948 detail: "The ACP session closed before its admitted response reached the transport",
1949 cause: "ACP session transport closed",
1950 });
1951 yield* Effect.forEach(
1952 acknowledgements,
1953 ([requestId, entry]) =>
1954 Deferred.fail(entry.acknowledgement, error).pipe(
1955 Effect.andThen(
1956 emitNativeResponseLifecycle({
1957 type: "removed",
1958 generation: entry.generation,
1959 requestId,
1960 }),
1961 ),
1962 Effect.andThen(
1963 emitNativeResponseLifecycle({
1964 type: "failed",
1965 generation: entry.generation,
1966 requestId,
1967 }),
1968 ),
1969 ),
1970 { concurrency: "unbounded", discard: true },
1971 );
1972 return acknowledgements.size > 0;
1973 }),
1974 );
1975 // Post-settle wake support (Grok async subagent/monitor follow-up). After
1976 // the root turn finalizes, later root session/update traffic buffers here
1977 // until a provider continuation run attaches and drains it.
1978 const lastTurnRoute = yield* Ref.make<{
1979 readonly threadId: ThreadId;
1980 readonly providerThreadId: ProviderThreadId;
1981 } | null>(null);
1982 const wakeBuffer = yield* Ref.make<Array<EffectAcpSchema.SessionNotification>>([]);
1983 // Background work that ended after the prompt settled, keyed by task or
1984 // child session id. The next continuation offer names it for the user;
1985 // `offered` holds the keys that offer named. Work that ends while the
1986 // offer waits for its turn is named by the offer after it.
1987 const noWakeReports = {
1988 reports: new Map<string, BackgroundWorkReport>(),
1989 offered: new Set<string>(),
1990 };
1991 const wakeReports = yield* Ref.make<{
1992 readonly reports: ReadonlyMap<string, BackgroundWorkReport>;
1993 readonly offered: ReadonlySet<string>;
1994 }>(noWakeReports);
1995 const recordWakeReport = Effect.fnUntraced(function* (
1996 key: string,
1997 report: BackgroundWorkReport,
1998 ) {
1999 const context = yield* Ref.get(activeTurn);
2000 // An open prompt reports the work itself; no continuation follows.
2001 if (context !== null && !context.promptSettled && !context.finalized) return;
2002 yield* Ref.update(wakeReports, ({ reports, offered }) => ({
2003 reports: new Map(reports).set(key, report),
2004 offered,
2005 }));
2006 });
2007 const continuationRequested = yield* Ref.make(false);
2008 const continuationGeneration = yield* Ref.make(0);
2009 const continuationPermit = yield* Semaphore.make(1);
2010 const continuationClosed = yield* Ref.make(false);
2011 // Direct Stop (requestRuntimeRestart) quarantines residual events from the
2012 // stopped run so they cannot wake or attach to a later prompt/run.
2013 const stoppedRunQuarantine = yield* Ref.make(false);
2014 // A steering restart (or any interrupt) can finalize a turn while its
2015 // spawned subagents are still running natively. Carry the live
2016 // lineages into the next turn on the same session so their terminal
2017 // signals can still flip the original turn items instead of leaving
2018 // them running forever.
2019 const carryoverSubagents = yield* Ref.make<AcpCarryoverSubagents | null>(null);
2020 const handledBackgroundTaskIdsInActiveTurn = yield* Ref.make<ReadonlySet<string>>(
2021 new Set(),
2022 );
2023 // Background tasks that reached a genuine terminal mutation while a root
2024 // turn was still streaming and were not yet marked handled in-turn. The
2025 // mid-turn offer is suppressed (active turn owns the work); on finalize
2026 // these ids re-arm a single continuation when the agent never hydrated
2027 // or reported them before settle (regression: mid-turn complete + no
2028 // get_command must still wake after finalize).
2029 const midTurnUnreportedCompletedTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set());
2030 // A monitor-event can arrive after its task and the user-facing provider
2031 // turn already completed. Grok starts another internal prompt for that
2032 // stale notification; suppress its agent output until a genuine terminal
2033 // mutation or the next app turn so it cannot create a redundant app
2034 // continuation. Tool frames continue through normal hydration.
2035 const suppressPostSettleMonitorPrompt = yield* Ref.make(false);
2036 // Background tasks (Grok monitors) known to still run at session level.
2037 // Turn contexts are too short-lived to carry this: a continuation run
2038 // finalizes between monitor events, and the next commentary burst must
2039 // not reopen a run while the monitor is still streaming.
2040 const runningBackgroundTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set());
2041 // Task ids with a GENUINE end signal (monitor-ended reminder or
2042 // TaskOutput completion). Normalized tool statuses are not genuine:
2043 // Grok Bash re-reports carry exit_code 0 mid-stream. A straggler
2044 // monitor-event can land after the real end (the CLI keeps streaming
2045 // while the agent already consumed the output via
2046 // get_command_or_subagent_output); without the tombstone it would
2047 // resurrect the running set and pin offers/idle-release forever.
2048 // A tool-level failed get_command tombstones too: failing open to a
2049 // single continuation beats failing closed to a dead thread.
2050 const endedBackgroundTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set());
2051 const endedBackgroundTaskIdLimit = 128;
2052
2053 const setBackgroundTaskRunning = (taskId: string, running: boolean) =>
2054 Effect.gen(function* () {
2055 if (running && (yield* Ref.get(endedBackgroundTaskIds)).has(taskId)) {
2056 return;
2057 }
2058 yield* Ref.update(runningBackgroundTaskIds, (current) => {
2059 if (current.has(taskId) === running) return current;
2060 const next = new Set(current);
2061 if (running) {
2062 next.add(taskId);
2063 } else {
2064 next.delete(taskId);
2065 }
2066 return next;
2067 });
2068 });
2069
2070 const markBackgroundTaskEnded = (taskId: string) =>
2071 Ref.update(endedBackgroundTaskIds, (current) => {
2072 if (current.has(taskId)) return current;
2073 const next = new Set(current).add(taskId);
2074 for (const oldest of next) {
2075 if (next.size <= endedBackgroundTaskIdLimit) break;
2076 next.delete(oldest);
2077 }
2078 return next;
2079 }).pipe(Effect.andThen(setBackgroundTaskRunning(taskId, false)));
2080
2081 const applyBackgroundTaskMutationRunning = (mutation: {
2082 readonly taskId: string;
2083 readonly status: "running" | "completed" | "failed";
2084 }) =>
2085 mutation.status === "running"
2086 ? setBackgroundTaskRunning(mutation.taskId, true)
2087 : markBackgroundTaskEnded(mutation.taskId);
2088
2089 const trackRunningBackgroundTools = (
2090 notification: EffectAcpSchema.SessionNotification,
2091 ): Effect.Effect<void> =>
2092 Effect.gen(function* () {
2093 if (flavor.extractBackgroundTaskId === undefined) return;
2094 for (const event of parseSessionUpdateEvent(notification).events) {
2095 if (event._tag !== "ToolCallUpdated") continue;
2096 const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall;
2097 const taskId = flavor.extractBackgroundTaskId(toolCall);
2098 if (taskId === undefined) continue;
2099 const status = toolStatus(toolCall.status);
2100 yield* setBackgroundTaskRunning(taskId, status === "pending" || status === "running");
2101 }
2102 });
2103
2104 const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) =>
2105 Queue.offer(events, event).pipe(Effect.asVoid);
2106 let scheduleDeferredFinalize: (context: ActiveAcpTurn) => Effect.Effect<void> = () =>
2107 Effect.void;
2108
2109 const nativeLogging = options.nativeLogging?.(input.threadId);
2110 const handleRuntimeTerminationAtGeneration = (runtimeGeneration: number) =>
2111 runRuntimeCallbackAtGeneration(
2112 runtimeGeneration,
2113 Ref.set(runtimeRestartRequired, true),
2114 ).pipe(Effect.asVoid);
2115 const makeRuntimeInput = (
2116 runtimeGeneration: number,
2117 mcpContext: AcpMcpContext,
2118 resumeSessionId?: string,
2119 onTermination: AcpAdapterV2RuntimeInput["onTermination"] = () =>
2120 handleRuntimeTerminationAtGeneration(runtimeGeneration),
2121 ): AcpAdapterV2RuntimeInput => {
2122 return {
2123 cwd: input.runtimePolicy.cwd ?? process.cwd(),
2124 runtimePolicy: input.runtimePolicy,
2125 mcpServers: mcpContext.servers,
2126 acpMcpServers: mcpContext.acpServers,
2127 ...(mcpContext.processEnvironment === undefined
2128 ? {}
2129 : { processEnvironment: mcpContext.processEnvironment }),
2130 ...(resumeSessionId === undefined ? {} : { resumeSessionId }),
2131 interruptPromptOnCancel: flavor.interruptPromptOnCancel ?? false,
2132 clientCapabilities: {
2133 fs: {
2134 readTextFile: flavor.clientFileSystem !== undefined,
2135 writeTextFile: flavor.clientFileSystem !== undefined,
2136 },
2137 terminal: clientTerminals !== undefined,
2138 elicitation: { form: {}, ...(flavor.onUrlElicitation ? { url: {} } : {}) },
2139 ...(flavor.clientCapabilitiesMeta ? { _meta: flavor.clientCapabilitiesMeta } : {}),
2140 },
2141 clientInfo: { name: "t3-code", version: "0.0.0" },
2142 onTermination,
2143 onOutgoingResponseFailure: (requestId, error) =>
2144 Ref.modify(nativeResponseAcknowledgements, (current) => {
2145 const entry = current.get(requestId);
2146 if (entry === undefined || entry.generation !== runtimeGeneration) {
2147 return [
2148 emitNativeResponseLifecycle({
2149 type: "late_noop",
2150 generation: runtimeGeneration,
2151 requestId,
2152 }),
2153 current,
2154 ] as const;
2155 }
2156 const updated = new Map(current);
2157 updated.delete(requestId);
2158 return [
2159 Deferred.fail(entry.acknowledgement, error).pipe(
2160 Effect.andThen(
2161 emitNativeResponseLifecycle({
2162 type: "removed",
2163 generation: runtimeGeneration,
2164 requestId,
2165 }),
2166 ),
2167 Effect.asVoid,
2168 ),
2169 updated,
2170 ] as const;
2171 }).pipe(Effect.flatten),
2172 onOutgoingResponse: (requestId) =>
2173 Ref.modify(nativeResponseAcknowledgements, (current) => {
2174 const entry = current.get(requestId);
2175 if (entry === undefined || entry.generation !== runtimeGeneration) {
2176 return [
2177 emitNativeResponseLifecycle({
2178 type: "late_noop",
2179 generation: runtimeGeneration,
2180 requestId,
2181 }),
2182 current,
2183 ] as const;
2184 }
2185 const updated = new Map(current);
2186 updated.delete(requestId);
2187 return [
2188 Deferred.succeed(entry.acknowledgement, undefined).pipe(
2189 Effect.andThen(
2190 emitNativeResponseLifecycle({
2191 type: "removed",
2192 generation: runtimeGeneration,
2193 requestId,
2194 }),
2195 ),
2196 Effect.asVoid,
2197 ),
2198 updated,
2199 ] as const;
2200 }).pipe(Effect.flatten),
2201 ...(nativeLogging?.requestLogger === undefined
2202 ? {}
2203 : { requestLogger: nativeLogging.requestLogger }),
2204 protocolLogging: nativeLogging?.protocolLogging ?? {
2205 logIncoming: true,
2206 logOutgoing: true,
2207 logger: () => Effect.void,
2208 },
2209 };
2210 };
2211 let runtimeScope: Scope.Closeable | undefined;
2212 let runtime!: AcpSessionRuntime.AcpSessionRuntime["Service"];
2213 let runtimeMcpBridge: AcpMcpOverAcpBridge | undefined;
2214 yield* Effect.addFinalizer(() =>
2215 runtimeScope === undefined
2216 ? Effect.void
2217 : Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore),
2218 );
2219
2220 const resolveItemOrdinal = (context: ActiveAcpTurn, nativeItemId: string) =>
2221 Effect.sync(() => {
2222 const existing = context.itemOrdinals.get(nativeItemId);
2223 if (existing !== undefined) return existing;
2224 const ordinal = context.input.providerTurnOrdinal * 100 + context.itemOrdinals.size + 1;
2225 context.itemOrdinals.set(nativeItemId, ordinal);
2226 return ordinal;
2227 });
2228
2229 const resolveSubagentChildOrdinal = (subagent: ActiveAcpSubagent, nativeItemId: string) => {
2230 const existing = subagent.childItemOrdinals.get(nativeItemId);
2231 if (existing !== undefined) return existing;
2232 const ordinal = subagent.nextChildOrdinal++;
2233 subagent.childItemOrdinals.set(nativeItemId, ordinal);
2234 return ordinal;
2235 };
2236
2237 const emitProviderRetry = Effect.fnUntraced(function* (
2238 context: ActiveAcpTurn,
2239 status: "running" | "completed" | "interrupted" | "cancelled",
2240 ) {
2241 const state = context.providerRetry;
2242 if (state === undefined) return;
2243 yield* emitProviderEvent({
2244 type: "turn_item.updated",
2245 driver,
2246 turnItem: makeProviderRetryTurnItem({
2247 driver,
2248 threadId: context.input.threadId,
2249 runId: context.input.runId,
2250 nodeId: context.input.rootNodeId,
2251 providerThreadId: context.input.providerThread.id,
2252 providerTurnId: context.providerTurnId,
2253 ...state,
2254 status,
2255 updatedAt: yield* DateTime.now,
2256 }),
2257 });
2258 if (status !== "running") context.providerRetry = undefined;
2259 });
2260
2261 const reportProviderRetry = Effect.fnUntraced(function* (notice: {
2262 readonly sessionId: string;
2263 readonly failure: OrchestrationV2ProviderFailure;
2264 }) {
2265 const context = yield* Ref.get(activeTurn);
2266 if (
2267 context === null ||
2268 context.finalized ||
2269 context.interrupted ||
2270 context.nativeThreadId !== notice.sessionId ||
2271 (yield* Ref.get(stoppedRunQuarantine))
2272 )
2273 return;
2274 const previous = context.providerRetry;
2275 context.providerRetry = {
2276 failure: notice.failure,
2277 retry: {
2278 attempt: (previous?.retry.attempt ?? 0) + 1,
2279 maxAttempts: null,
2280 retryDelayMs: null,
2281 },
2282 startedAt: previous?.startedAt ?? (yield* DateTime.now),
2283 itemOrdinal:
2284 previous?.itemOrdinal ??
2285 (yield* resolveItemOrdinal(context, `terminal-failure:${context.providerTurnId}`)),
2286 };
2287 yield* emitProviderRetry(context, "running");
2288 });
2289
2290 const lastCapturedProposedPlan = yield* Ref.make<{
2291 readonly nativeTurnId: string;
2292 readonly markdown: string;
2293 readonly planId: OrchestrationV2PlanArtifact["id"];
2294 } | null>(null);
2295 /**
2296 * Emits a completed proposed-plan artifact for the active turn. Same
2297 * plan id per turn so repeated captures (plan.md rewrites, the exit
2298 * gate) update one card; identical markdown within a turn is a no-op.
2299 */
2300 const captureProposedPlan = Effect.fnUntraced(function* (input: {
2301 readonly planMarkdown: string;
2302 }) {
2303 const context = yield* Ref.get(activeTurn);
2304 if (context === null) return;
2305 const markdown = input.planMarkdown.trim();
2306 if (markdown.length === 0) return;
2307 const previous = yield* Ref.get(lastCapturedProposedPlan);
2308 if (
2309 previous !== null &&
2310 previous.nativeTurnId === context.nativeTurnId &&
2311 previous.markdown === markdown
2312 ) {
2313 return;
2314 }
2315 const nativeItemId = `${context.nativeTurnId}:proposed-plan`;
2316 const planId =
2317 previous !== null && previous.nativeTurnId === context.nativeTurnId
2318 ? previous.planId
2319 : yield* idAllocator.allocate
2320 .plan({
2321 threadId: context.input.threadId,
2322 runId: context.input.runId,
2323 driver,
2324 })
2325 .pipe(Effect.orDie);
2326 yield* Ref.set(lastCapturedProposedPlan, {
2327 nativeTurnId: context.nativeTurnId,
2328 markdown,
2329 planId,
2330 });
2331 const now = yield* DateTime.now;
2332 const nodeId = idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId });
2333 const turnItemId = idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId });
2334 const ordinal = yield* resolveItemOrdinal(context, nativeItemId);
2335 const nativeItemRef = { driver, nativeId: nativeItemId, strength: "weak" as const };
2336 yield* emitProviderEvent({
2337 type: "node.updated",
2338 driver,
2339 node: {
2340 id: nodeId,
2341 threadId: context.input.threadId,
2342 runId: context.input.runId,
2343 parentNodeId: context.input.rootNodeId,
2344 rootNodeId: context.input.rootNodeId,
2345 kind: "plan",
2346 status: "completed",
2347 countsForRun: false,
2348 providerThreadId: context.input.providerThread.id,
2349 providerTurnId: context.providerTurnId,
2350 nativeItemRef,
2351 runtimeRequestId: null,
2352 checkpointScopeId: null,
2353 startedAt: context.startedAt,
2354 completedAt: now,
2355 },
2356 });
2357 yield* emitProviderEvent({
2358 type: "plan.updated",
2359 driver,
2360 plan: {
2361 id: planId,
2362 threadId: context.input.threadId,
2363 runId: context.input.runId,
2364 nodeId,
2365 kind: "proposed_plan",
2366 status: "completed",
2367 markdown,
2368 },
2369 });
2370 yield* emitProviderEvent({
2371 type: "turn_item.updated",
2372 driver,
2373 turnItem: {
2374 id: turnItemId,
2375 threadId: context.input.threadId,
2376 runId: context.input.runId,
2377 nodeId,
2378 providerThreadId: context.input.providerThread.id,
2379 providerTurnId: context.providerTurnId,
2380 nativeItemRef,
2381 parentItemId: null,
2382 ordinal,
2383 status: "completed",
2384 title: null,
2385 startedAt: context.startedAt,
2386 completedAt: now,
2387 updatedAt: now,
2388 type: "proposed_plan",
2389 planId,
2390 markdown,
2391 streaming: false,
2392 },
2393 });
2394 });
2395 const lastProposedPlanMarkdown = Effect.gen(function* () {
2396 const context = yield* Ref.get(activeTurn);
2397 const previous = yield* Ref.get(lastCapturedProposedPlan);
2398 return context !== null && previous?.nativeTurnId === context.nativeTurnId
2399 ? previous.markdown
2400 : undefined;
2401 });
2402
2403 const rememberSnapshotMessage = (message: OrchestrationV2ConversationMessage) =>
2404 Ref.update(snapshot, (current) => {
2405 const key = String(message.id);
2406 const exists = current.messages.has(key);
2407 const messages = new Map(current.messages);
2408 messages.set(key, message);
2409 return {
2410 ...current,
2411 order: exists ? current.order : [...current.order, key],
2412 messages,
2413 };
2414 });
2415
2416 const textStreamFor = (
2417 context: ActiveAcpTurn,
2418 kind: "user" | "assistant" | "reasoning",
2419 ): ActiveTextStream =>
2420 kind === "user"
2421 ? context.user
2422 : kind === "assistant"
2423 ? context.assistant
2424 : context.reasoning;
2425
2426 const emitTextSegment = Effect.fnUntraced(function* (
2427 context: ActiveAcpTurn,
2428 kind: "user" | "assistant" | "reasoning",
2429 completed: boolean,
2430 ) {
2431 const stream = textStreamFor(context, kind);
2432 const segment = stream.current;
2433 if (segment === null) return;
2434 const now = yield* DateTime.now;
2435 const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId);
2436 const nodeId = providerNodeId(segment.nativeItemId);
2437 const turnItemId = providerTurnItemId(segment.nativeItemId);
2438 const nativeItemRef = {
2439 driver,
2440 nativeId: segment.nativeItemId,
2441 strength: "weak" as const,
2442 };
2443 if (kind !== "user") {
2444 yield* emitProviderEvent({
2445 type: "node.updated",
2446 driver,
2447 node: {
2448 id: nodeId,
2449 threadId: context.input.threadId,
2450 runId: context.input.runId,
2451 parentNodeId: context.input.rootNodeId,
2452 rootNodeId: context.input.rootNodeId,
2453 kind: kind === "assistant" ? "assistant_message" : "reasoning",
2454 status: completed ? "completed" : "running",
2455 countsForRun: false,
2456 providerThreadId: context.input.providerThread.id,
2457 providerTurnId: context.providerTurnId,
2458 nativeItemRef,
2459 runtimeRequestId: null,
2460 checkpointScopeId: null,
2461 startedAt: segment.startedAt,
2462 completedAt: completed ? now : null,
2463 },
2464 });
2465 }
2466 if (kind !== "reasoning") {
2467 const messageId = providerMessageId(segment.nativeItemId);
2468 const messageNodeId = kind === "user" ? context.input.rootNodeId : nodeId;
2469 const message: OrchestrationV2ConversationMessage = {
2470 createdBy: kind === "user" ? "user" : "agent",
2471 creationSource: "provider",
2472 id: messageId,
2473 threadId: context.input.threadId,
2474 runId: context.input.runId,
2475 nodeId: messageNodeId,
2476 role: kind,
2477 text: segment.text,
2478 attachments: [],
2479 streaming: !completed,
2480 createdAt: segment.startedAt,
2481 updatedAt: now,
2482 };
2483 yield* emitProviderEvent({ type: "message.updated", driver, message });
2484 yield* emitProviderEvent(
2485 kind === "user"
2486 ? {
2487 type: "turn_item.updated",
2488 driver,
2489 turnItem: {
2490 createdBy: "user",
2491 creationSource: "provider",
2492 id: turnItemId,
2493 threadId: context.input.threadId,
2494 runId: context.input.runId,
2495 nodeId: messageNodeId,
2496 providerThreadId: context.input.providerThread.id,
2497 providerTurnId: context.providerTurnId,
2498 nativeItemRef,
2499 parentItemId: null,
2500 ordinal,
2501 status: completed ? "completed" : "running",
2502 title: null,
2503 startedAt: segment.startedAt,
2504 completedAt: completed ? now : null,
2505 updatedAt: now,
2506 type: "user_message",
2507 messageId,
2508 inputIntent: "turn_start",
2509 text: segment.text,
2510 attachments: [],
2511 },
2512 }
2513 : {
2514 type: "turn_item.updated",
2515 driver,
2516 turnItem: {
2517 id: turnItemId,
2518 threadId: context.input.threadId,
2519 runId: context.input.runId,
2520 nodeId,
2521 providerThreadId: context.input.providerThread.id,
2522 providerTurnId: context.providerTurnId,
2523 nativeItemRef,
2524 parentItemId: null,
2525 ordinal,
2526 status: completed ? "completed" : "running",
2527 title: null,
2528 startedAt: segment.startedAt,
2529 completedAt: completed ? now : null,
2530 updatedAt: now,
2531 type: "assistant_message",
2532 messageId,
2533 text: segment.text,
2534 streaming: !completed,
2535 },
2536 },
2537 );
2538 if (completed) yield* rememberSnapshotMessage(message);
2539 return;
2540 }
2541 yield* emitProviderEvent({
2542 type: "turn_item.updated",
2543 driver,
2544 turnItem: {
2545 id: turnItemId,
2546 threadId: context.input.threadId,
2547 runId: context.input.runId,
2548 nodeId,
2549 providerThreadId: context.input.providerThread.id,
2550 providerTurnId: context.providerTurnId,
2551 nativeItemRef,
2552 parentItemId: null,
2553 ordinal,
2554 status: completed ? "completed" : "running",
2555 title: null,
2556 startedAt: segment.startedAt,
2557 completedAt: completed ? now : null,
2558 updatedAt: now,
2559 type: "reasoning",
2560 text: segment.text,
2561 streaming: !completed,
2562 },
2563 });
2564 });
2565
2566 const closeTextStream = Effect.fnUntraced(function* (
2567 context: ActiveAcpTurn,
2568 kind: "user" | "assistant" | "reasoning",
2569 ) {
2570 const stream = textStreamFor(context, kind);
2571 if (stream.current === null) return;
2572 yield* emitTextSegment(context, kind, true);
2573 stream.current = null;
2574 });
2575
2576 const closeTextStreams = Effect.fnUntraced(function* (context: ActiveAcpTurn) {
2577 yield* closeTextStream(context, "user");
2578 yield* closeTextStream(context, "reasoning");
2579 yield* closeTextStream(context, "assistant");
2580 });
2581
2582 const appendText = Effect.fnUntraced(function* (
2583 context: ActiveAcpTurn,
2584 kind: "user" | "assistant" | "reasoning",
2585 text: string,
2586 messageId?: string | null,
2587 ) {
2588 if (text.length === 0) return;
2589 for (const other of ["user", "reasoning", "assistant"] as const) {
2590 if (other !== kind) yield* closeTextStream(context, other);
2591 }
2592 const stream = textStreamFor(context, kind);
2593 const sourceMessageId = messageId?.trim() || null;
2594 if (
2595 stream.current !== null &&
2596 sourceMessageId !== null &&
2597 stream.current.sourceMessageId !== null &&
2598 stream.current.sourceMessageId !== sourceMessageId
2599 ) {
2600 yield* closeTextStream(context, kind);
2601 }
2602 if (stream.current === null) {
2603 const now = yield* DateTime.now;
2604 stream.current = {
2605 nativeItemId:
2606 sourceMessageId === null
2607 ? `${context.nativeTurnId}:${kind}:${stream.nextSegment}`
2608 : `${context.nativeTurnId}:${kind}:message:${sourceMessageId}`,
2609 startedAt: now,
2610 sourceMessageId,
2611 text: "",
2612 };
2613 stream.nextSegment += 1;
2614 } else if (stream.current.sourceMessageId === null && sourceMessageId !== null) {
2615 // Some agents omit messageId on the first chunk. Keep the already
2616 // projected identity and use the first later id as its boundary.
2617 stream.current.sourceMessageId = sourceMessageId;
2618 }
2619 stream.current.text += text;
2620 yield* emitTextSegment(context, kind, false);
2621 });
2622
2623 const replaceText = Effect.fnUntraced(function* (
2624 context: ActiveAcpTurn,
2625 kind: "user" | "assistant" | "reasoning",
2626 text: string,
2627 messageId: string,
2628 ) {
2629 for (const other of ["user", "reasoning", "assistant"] as const) {
2630 if (other !== kind) yield* closeTextStream(context, other);
2631 }
2632 const stream = textStreamFor(context, kind);
2633 if (stream.current?.sourceMessageId !== messageId) {
2634 yield* closeTextStream(context, kind);
2635 const now = yield* DateTime.now;
2636 stream.current = {
2637 nativeItemId: `${context.nativeTurnId}:${kind}:message:${messageId}`,
2638 startedAt: now,
2639 sourceMessageId: messageId,
2640 text,
2641 };
2642 stream.nextSegment += 1;
2643 } else {
2644 stream.current.text = text;
2645 }
2646 yield* emitTextSegment(context, kind, false);
2647 });
2648
2649 const emitSubagentAssistant = Effect.fnUntraced(function* (
2650 subagent: ActiveAcpSubagent,
2651 text: string,
2652 mode: "append" | "replace" = "append",
2653 messageId?: string | null,
2654 ) {
2655 if (text.length === 0 && mode === "append") return;
2656 const nativeItemId = `${subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id}:message:${messageId ?? "result"}`;
2657 const previous = subagent.assistantMessages.get(nativeItemId) ?? "";
2658 const messageText = mode === "replace" ? text : `${previous}${text}`;
2659 subagent.assistantMessages.set(nativeItemId, messageText);
2660 subagent.assistantText = messageText;
2661 const now = yield* DateTime.now;
2662 const ordinal = resolveSubagentChildOrdinal(subagent, nativeItemId);
2663 const artifacts = makeSubagentConversationArtifacts({
2664 messageId: providerMessageId(nativeItemId),
2665 turnItemId: providerTurnItemId(nativeItemId),
2666 threadId: subagent.childThreadId,
2667 rootNodeId: subagent.childRootNodeId,
2668 providerThreadId: subagent.task.providerThreadId,
2669 providerTurnId: null,
2670 nativeItemRef: { driver, nativeId: nativeItemId, strength: "weak" },
2671 role: "assistant",
2672 text: subagent.assistantText,
2673 ordinal,
2674 now,
2675 });
2676 yield* emitProviderEvent({ type: "message.updated", driver, message: artifacts.message });
2677 yield* emitProviderEvent({
2678 type: "turn_item.updated",
2679 driver,
2680 turnItem: artifacts.turnItem,
2681 });
2682 });
2683
2684 const projectSubagentNotification = Effect.fnUntraced(function* (
2685 subagent: ActiveAcpSubagent,
2686 notification: EffectAcpSchema.SessionNotification,
2687 ) {
2688 const update = notification.update;
2689 if (update.sessionUpdate === "agent_message_chunk") {
2690 const text = acpContentBlockDisplayText(update.content);
2691 if (text !== undefined) {
2692 yield* emitSubagentAssistant(subagent, text, "append", update.messageId);
2693 }
2694 } else if (update.sessionUpdate === "agent_message") {
2695 if (update.content === undefined) return;
2696 const text = (update.content ?? [])
2697 .flatMap((content) => {
2698 const display = acpContentBlockDisplayText(content);
2699 return display === undefined ? [] : [display];
2700 })
2701 .join("\n");
2702 yield* emitSubagentAssistant(subagent, text, "replace", update.messageId);
2703 }
2704 });
2705
2706 const emitSubagent = Effect.fnUntraced(function* (
2707 context: ActiveAcpTurn,
2708 update: AcpAdapterV2SubagentUpdate,
2709 ) {
2710 // Hydration tools (get_command_or_subagent_output) use a new toolCallId
2711 // but reference the child via subagent_id / task id.
2712 const existing =
2713 context.subagents.get(update.nativeTaskId) ??
2714 (update.childSessionId !== null
2715 ? context.subagentsBySessionId.get(update.childSessionId)
2716 : undefined);
2717 const updateIsTerminal = acpSubagentStatusIsTerminal(update.status);
2718 if (
2719 existing !== undefined &&
2720 acpSubagentStatusIsTerminal(existing.task.status) &&
2721 !updateIsTerminal
2722 ) {
2723 return;
2724 }
2725 if (
2726 existing !== undefined &&
2727 existing.task.status === update.status &&
2728 updateIsTerminal &&
2729 existing.terminalStatusProjected
2730 ) {
2731 return;
2732 }
2733 // get_command_or_subagent_output may target monitors/bash tasks. Only
2734 // hydrate when we already have a matching subagent lineage. Spawn ACKs
2735 // (non-empty prompt) may create a new lineage; empty-prompt hydration
2736 // with suppressNormalTool must not invent a phantom subagent.
2737 if (existing === undefined) {
2738 const isHydrationOnly = update.prompt === "" && update.title === null;
2739 if (isHydrationOnly || update.suppressNormalTool === false) {
2740 return;
2741 }
2742 }
2743 const now = yield* DateTime.now;
2744 const nativeTaskId = existing?.task.nativeTaskRef?.nativeId ?? update.nativeTaskId;
2745 const nativeItemRef = {
2746 driver,
2747 nativeId: nativeTaskId,
2748 strength: "strong" as const,
2749 };
2750 const nodeId = existing?.task.id ?? providerNodeId(nativeTaskId);
2751 const childThreadId =
2752 existing?.childThreadId ??
2753 idAllocator.derive.threadFromProviderThread({
2754 driver,
2755 providerInstanceId: context.input.modelSelection.instanceId,
2756 nativeThreadId: `${context.nativeThreadId}:task:${nativeTaskId}`,
2757 });
2758 const childRootNodeId =
2759 existing?.childRootNodeId ?? providerNodeId(`${nativeTaskId}:child-root`);
2760 const turnItemId = existing?.turnItemId ?? providerTurnItemId(nativeTaskId);
2761 const turnItemOrdinal =
2762 existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(context, nativeTaskId));
2763 const taskStatus = update.status;
2764 const task: OrchestrationV2Subagent = {
2765 ...(existing?.task ?? {
2766 id: nodeId,
2767 threadId: context.input.threadId,
2768 runId: context.input.runId,
2769 parentNodeId: context.input.rootNodeId,
2770 origin: "provider_native" as const,
2771 createdBy: "agent" as const,
2772 driver,
2773 providerInstanceId: context.input.modelSelection.instanceId,
2774 providerThreadId: null,
2775 childThreadId,
2776 nativeTaskRef: nativeItemRef,
2777 prompt: update.prompt,
2778 title: update.title,
2779 result: null,
2780 startedAt: now,
2781 }),
2782 model: update.model?.trim() || existing?.task.model || null,
2783 status: taskStatus,
2784 result: update.result ?? existing?.task.result ?? null,
2785 completedAt: acpSubagentStatusIsTerminal(taskStatus) ? now : null,
2786 updatedAt: now,
2787 };
2788 const subagent: ActiveAcpSubagent = existing ?? {
2789 task,
2790 childThreadId,
2791 childRootNodeId,
2792 turnItemId,
2793 turnItemOrdinal,
2794 providerTurnId: context.providerTurnId,
2795 parentProviderThreadId: context.input.providerThread.id,
2796 childSessionId: null,
2797 assistantText: "",
2798 assistantMessages: new Map(),
2799 childItemOrdinals: new Map(),
2800 nextChildOrdinal: 101,
2801 terminalStatusProjected: false,
2802 };
2803 subagent.task = task;
2804 context.subagents.set(nativeTaskId, subagent);
2805
2806 if (existing === undefined) {
2807 yield* emitProviderEvent({
2808 type: "app_thread.created",
2809 driver,
2810 appThread: makeSubagentChildThread({
2811 parentThread: context.input.appThread,
2812 childThreadId,
2813 parentNodeId: nodeId,
2814 activeProviderThreadId: null,
2815 providerInstanceId: context.input.modelSelection.instanceId,
2816 modelSelection: {
2817 ...context.input.modelSelection,
2818 model: task.model ?? context.input.modelSelection.model,
2819 },
2820 title: subagentThreadTitle({
2821 parentTitle: context.input.appThread.title,
2822 title: update.title,
2823 prompt: update.prompt,
2824 ordinal: context.subagents.size,
2825 }),
2826 now,
2827 createdBy: "agent",
2828 creationSource: "provider",
2829 }),
2830 });
2831 const promptNativeItemId = `${nativeTaskId}:prompt`;
2832 const promptArtifacts = makeSubagentConversationArtifacts({
2833 senderThreadId: context.input.threadId,
2834 messageId: providerMessageId(promptNativeItemId),
2835 turnItemId: providerTurnItemId(promptNativeItemId),
2836 threadId: childThreadId,
2837 rootNodeId: childRootNodeId,
2838 providerThreadId: null,
2839 providerTurnId: null,
2840 nativeItemRef: { driver, nativeId: promptNativeItemId, strength: "weak" },
2841 role: "user",
2842 text: update.prompt,
2843 ordinal: 100,
2844 now,
2845 });
2846 yield* emitProviderEvent({
2847 type: "message.updated",
2848 driver,
2849 message: promptArtifacts.message,
2850 });
2851 yield* emitProviderEvent({
2852 type: "turn_item.updated",
2853 driver,
2854 turnItem: promptArtifacts.turnItem,
2855 });
2856 }
2857
2858 const childSessionId = update.childSessionId;
2859 if (childSessionId !== null && subagent.childSessionId === null) {
2860 subagent.childSessionId = childSessionId;
2861 context.subagentsBySessionId.set(childSessionId, subagent);
2862 const providerThread = makeProviderThread({
2863 driver,
2864 providerInstanceId: context.input.modelSelection.instanceId,
2865 idAllocator,
2866 appThreadId: childThreadId,
2867 providerSessionId: input.providerSessionId,
2868 nativeThreadId: childSessionId,
2869 ...(itemIdentityVersion === undefined ? {} : { itemIdentityVersion }),
2870 forkedFrom: {
2871 providerThreadId: context.input.providerThread.id,
2872 providerTurnId: context.providerTurnId,
2873 },
2874 now,
2875 });
2876 subagent.task = { ...subagent.task, providerThreadId: providerThread.id };
2877 yield* Ref.update(providerThreadByNativeSessionId, (current) =>
2878 new Map(current).set(childSessionId, providerThread),
2879 );
2880 yield* emitProviderEvent({
2881 type: "provider_thread.updated",
2882 driver,
2883 providerThread: { ...providerThread, status: "idle" },
2884 });
2885 const buffered = context.pendingSubagentNotifications.get(childSessionId) ?? [];
2886 context.pendingSubagentNotifications.delete(childSessionId);
2887 yield* Effect.forEach(
2888 buffered,
2889 (notification) => projectSubagentNotification(subagent, notification),
2890 { concurrency: 1, discard: true },
2891 );
2892 }
2893
2894 if (
2895 taskStatus !== "running" &&
2896 subagent.assistantText.length === 0 &&
2897 update.result !== null
2898 ) {
2899 yield* emitSubagentAssistant(subagent, update.result);
2900 }
2901 const result = update.result ?? subagent.task.result ?? (subagent.assistantText || null);
2902 subagent.task = {
2903 ...subagent.task,
2904 status: taskStatus,
2905 result,
2906 completedAt: acpSubagentStatusIsTerminal(taskStatus) ? now : null,
2907 updatedAt: now,
2908 };
2909 const providerThreadId = subagent.task.providerThreadId;
2910 yield* emitProviderEvent({
2911 type: "node.updated",
2912 driver,
2913 node: {
2914 id: nodeId,
2915 threadId: context.input.threadId,
2916 runId: subagent.task.runId,
2917 parentNodeId: subagent.task.parentNodeId,
2918 rootNodeId: subagent.task.parentNodeId,
2919 kind: "subagent",
2920 status: taskStatus,
2921 countsForRun: false,
2922 providerThreadId: context.input.providerThread.id,
2923 providerTurnId: subagent.providerTurnId,
2924 nativeItemRef,
2925 runtimeRequestId: null,
2926 checkpointScopeId: null,
2927 startedAt: subagent.task.startedAt,
2928 completedAt: subagent.task.completedAt,
2929 },
2930 });
2931 yield* emitProviderEvent({
2932 type: "node.updated",
2933 driver,
2934 node: {
2935 id: childRootNodeId,
2936 threadId: childThreadId,
2937 runId: null,
2938 parentNodeId: null,
2939 rootNodeId: childRootNodeId,
2940 kind: "root_turn",
2941 status: taskStatus,
2942 countsForRun: false,
2943 providerThreadId,
2944 providerTurnId: null,
2945 nativeItemRef,
2946 runtimeRequestId: null,
2947 checkpointScopeId: null,
2948 startedAt: subagent.task.startedAt,
2949 completedAt: subagent.task.completedAt,
2950 },
2951 });
2952 yield* emitProviderEvent({ type: "subagent.updated", driver, subagent: subagent.task });
2953 yield* emitProviderEvent({
2954 type: "turn_item.updated",
2955 driver,
2956 turnItem: {
2957 id: turnItemId,
2958 threadId: context.input.threadId,
2959 runId: subagent.task.runId,
2960 nodeId,
2961 providerThreadId: context.input.providerThread.id,
2962 providerTurnId: subagent.providerTurnId,
2963 nativeItemRef,
2964 parentItemId: null,
2965 ordinal: turnItemOrdinal,
2966 status: taskStatus,
2967 title: subagent.task.title,
2968 startedAt: subagent.task.startedAt,
2969 completedAt: subagent.task.completedAt,
2970 updatedAt: now,
2971 type: "subagent",
2972 subagentId: subagent.task.id,
2973 origin: "provider_native",
2974 driver,
2975 providerInstanceId: context.input.modelSelection.instanceId,
2976 childThreadId,
2977 prompt: subagent.task.prompt,
2978 result,
2979 },
2980 });
2981 if (acpSubagentStatusIsTerminal(taskStatus)) {
2982 subagent.terminalStatusProjected = true;
2983 }
2984 });
2985
2986 const toolOutputText = (toolCall: AcpToolCallState): string => {
2987 if (typeof toolCall.data.rawOutput === "string") {
2988 return toolCall.data.rawOutput;
2989 }
2990 if (
2991 typeof (toolCall.data.rawOutput as { text?: unknown } | undefined)?.text === "string"
2992 ) {
2993 return String((toolCall.data.rawOutput as { text: string }).text);
2994 }
2995 return textFromUnknown(toolCall.data.rawOutput) ?? "";
2996 };
2997
2998 const setToolOutputText = (toolCall: AcpToolCallState, text: string): AcpToolCallState => ({
2999 ...toolCall,
3000 data: {
3001 ...toolCall.data,
3002 rawOutput: {
3003 type: "Text",
3004 text,
3005 },
3006 },
3007 });
3008
3009 const appendToolOutputText = (
3010 toolCall: AcpToolCallState,
3011 appendOutput: string,
3012 ): AcpToolCallState => {
3013 if (appendOutput.length === 0) return toolCall;
3014 return setToolOutputText(toolCall, `${toolOutputText(toolCall)}${appendOutput}`);
3015 };
3016
3017 const isMonitorEndNoticeText = (text: string): boolean =>
3018 /Monitor\s+["']?[0-9a-f-]{8,}/i.test(text) && /ended/i.test(text);
3019
3020 const hasDeferredBackgroundWork = (context: ActiveAcpTurn): boolean => {
3021 if (context.awaitingBackgroundHydration.size > 0) return true;
3022 if (context.pendingInjectedReport.size > 0) return true;
3023 for (const [taskId, toolCallId] of context.toolCallIdsByBackgroundTaskId) {
3024 // Persistent monitors stream after root settle; do not pin finalize.
3025 if (context.persistentBackgroundTaskIds.has(taskId)) continue;
3026 const tool = context.tools.get(toolCallId);
3027 if (tool === undefined) continue;
3028 const status = toolStatus(tool.status);
3029 if (status === "pending" || status === "running") return true;
3030 }
3031 for (const subagent of context.subagents.values()) {
3032 if (acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) {
3033 return true;
3034 }
3035 }
3036 return false;
3037 };
3038
3039 // scheduleDeferredFinalize is declared above openSession body start and
3040 // assigned after finalizeTurn so forked finalize Effect typing stays clean.
3041 const rearmDeferredFinalize = (context: ActiveAcpTurn) =>
3042 Effect.gen(function* () {
3043 if (!flavor.deferFinalizeForBackgroundWork) return;
3044 if (!context.promptSettled || context.finalized) return;
3045 if (hasDeferredBackgroundWork(context)) return;
3046 yield* scheduleDeferredFinalize(context);
3047 });
3048
3049 // `let` breaks circular inference from monitor hydration re-entry.
3050 let emitTool: (
3051 context: ActiveAcpTurn,
3052 incoming: AcpToolCallState,
3053 projectedStatus?: ProjectedToolStatus,
3054 ) => Effect.Effect<void> = () => Effect.void;
3055
3056 const markAwaitingBackgroundHydration = (context: ActiveAcpTurn, taskId: string) =>
3057 Effect.gen(function* () {
3058 if (context.awaitingBackgroundHydration.has(taskId)) return;
3059 context.awaitingBackgroundHydration.add(taskId);
3060 // Safety: do not hold the root turn forever if the agent never hydrates.
3061 yield* Effect.gen(function* () {
3062 yield* Effect.sleep("60000 millis");
3063 if (context.finalized || !context.awaitingBackgroundHydration.has(taskId)) return;
3064 context.awaitingBackgroundHydration.delete(taskId);
3065 // End notices keep the tool running until hydration; force-complete so
3066 // deferred finalize can proceed when get_command never arrives.
3067 const toolCallId = context.toolCallIdsByBackgroundTaskId.get(taskId);
3068 const tool = toolCallId !== undefined ? context.tools.get(toolCallId) : undefined;
3069 if (tool !== undefined) {
3070 const status = toolStatus(tool.status);
3071 if (status === "pending" || status === "running") {
3072 yield* emitTool(context, { ...tool, status: "completed" });
3073 }
3074 }
3075 yield* rearmDeferredFinalize(context);
3076 }).pipe(Effect.forkIn(sessionScope), Effect.asVoid);
3077 });
3078
3079 const markPendingInjectedReport = (context: ActiveAcpTurn, taskId: string) =>
3080 Effect.gen(function* () {
3081 // Only the settled-and-held window has the race; mid-turn reports
3082 // are protected by the prompt RPC still being open.
3083 if (!context.promptSettled) return;
3084 if (context.pendingInjectedReport.has(taskId)) return;
3085 context.pendingInjectedReport.add(taskId);
3086 // Safety: the injected turn may end without a report chunk.
3087 yield* Effect.gen(function* () {
3088 yield* Effect.sleep("25000 millis");
3089 if (context.finalized || !context.pendingInjectedReport.has(taskId)) return;
3090 context.pendingInjectedReport.delete(taskId);
3091 yield* rearmDeferredFinalize(context);
3092 }).pipe(Effect.forkIn(sessionScope), Effect.asVoid);
3093 });
3094
3095 const clearMidTurnUnreportedTaskIds = (taskIds: ReadonlyArray<string>) =>
3096 Ref.update(midTurnUnreportedCompletedTaskIds, (current) => {
3097 let next: Set<string> | null = null;
3098 for (const taskId of taskIds) {
3099 if (!current.has(taskId)) continue;
3100 if (next === null) next = new Set(current);
3101 next.delete(taskId);
3102 }
3103 return next ?? current;
3104 });
3105
3106 emitTool = Effect.fnUntraced(function* (
3107 context: ActiveAcpTurn,
3108 incoming: AcpToolCallState,
3109 projectedStatus?: ProjectedToolStatus,
3110 ) {
3111 // An identified message can stream concurrently with tool updates.
3112 // Keep its identity until the provider starts another message.
3113 if (context.assistant.current?.sourceMessageId == null)
3114 yield* closeTextStream(context, "assistant");
3115 if (context.reasoning.current?.sourceMessageId == null)
3116 yield* closeTextStream(context, "reasoning");
3117 yield* closeTextStream(context, "user");
3118 const previous = context.tools.get(incoming.toolCallId);
3119 const merged = mergeToolCallState(previous, incoming);
3120 const toolCall = flavor.normalizeToolCall?.(merged) ?? merged;
3121 context.tools.set(toolCall.toolCallId, toolCall);
3122 const backgroundTaskId = flavor.extractBackgroundTaskId?.(toolCall);
3123 if (backgroundTaskId !== undefined) {
3124 context.toolCallIdsByBackgroundTaskId.set(backgroundTaskId, toolCall.toolCallId);
3125 if (flavor.isPersistentBackgroundTool?.(toolCall) === true) {
3126 context.persistentBackgroundTaskIds.add(backgroundTaskId);
3127 }
3128 const backgroundStatus = projectedStatus ?? toolStatus(toolCall.status);
3129 yield* setBackgroundTaskRunning(
3130 backgroundTaskId,
3131 backgroundStatus === "pending" || backgroundStatus === "running",
3132 );
3133 // Background tool that reaches a terminal status while the root
3134 // prompt is still open (completed, failed, or interrupted) was
3135 // consumed in-turn. Mark handled so late CLI re-reports and residual
3136 // agent chatter do not open synthetic "Background task completed."
3137 // runs. get_command TaskOutput still marks handled below.
3138 // Only before promptSettled: after STARTED the deferred-finalize
3139 // hold can let a monitor finish while the turn is still active;
3140 // marking then would suppress the legitimate post-settle TaskOutput
3141 // continuation (live: grok-post-settle-continuation-poll).
3142 // Terminal statuses only after normalizeToolCall (start ACKs stay
3143 // inProgress/running).
3144 //
3145 // Tradeoff: a pre-settle false "completed" normalization (Bash-
3146 // shaped re-report with exit_code 0, or the hydration safety
3147 // force-complete firing pre-settle) would permanently mark the task
3148 // handled and suppress its injected report chatter. The deleted
3149 // handled-id removal on late monitor-event mutations used to rescue
3150 // that case; likelihood is low because mid-turn monitor ends
3151 // normally arrive as reminder mutations that force inProgress, and
3152 // re-reports are documented post-settle traffic.
3153 if (
3154 !context.promptSettled &&
3155 backgroundStatus !== "pending" &&
3156 backgroundStatus !== "running"
3157 ) {
3158 yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) =>
3159 new Set(current).add(backgroundTaskId),
3160 );
3161 yield* Ref.update(midTurnUnreportedCompletedTaskIds, (current) => {
3162 if (!current.has(backgroundTaskId)) return current;
3163 const next = new Set(current);
3164 next.delete(backgroundTaskId);
3165 return next;
3166 });
3167 }
3168 }
3169
3170 // get_command TaskOutput for registered monitor(s): hydrate those tools.
3171 const backgroundCompletions =
3172 projectedStatus === undefined
3173 ? (flavor.extractBackgroundTaskCompletion?.(toolCall) ?? [])
3174 : [];
3175 let hydratedRegisteredMonitor = false;
3176 for (const backgroundCompletion of backgroundCompletions) {
3177 // Genuine end signal when terminal: tombstone so straggler
3178 // monitor-event chatter cannot resurrect the running set after the
3179 // task truly ended. A still-running poll keeps the id running.
3180 yield* applyBackgroundTaskMutationRunning(backgroundCompletion);
3181 if (backgroundCompletion.status !== "running") {
3182 yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) =>
3183 new Set(current).add(backgroundCompletion.taskId),
3184 );
3185 yield* Ref.update(midTurnUnreportedCompletedTaskIds, (current) => {
3186 if (!current.has(backgroundCompletion.taskId)) return current;
3187 const next = new Set(current);
3188 next.delete(backgroundCompletion.taskId);
3189 return next;
3190 });
3191 }
3192 // A still-running fetch must keep the hydration hold (and its
3193 // safety timer) alive until output actually lands.
3194 if (backgroundCompletion.status !== "running") {
3195 context.awaitingBackgroundHydration.delete(backgroundCompletion.taskId);
3196 }
3197 const targetToolCallId = context.toolCallIdsByBackgroundTaskId.get(
3198 backgroundCompletion.taskId,
3199 );
3200 // Known background-task id (monitor registration) — never open a
3201 // phantom subagent for the same get_output poll.
3202 if (targetToolCallId !== undefined) {
3203 hydratedRegisteredMonitor = true;
3204 }
3205 const target =
3206 targetToolCallId !== undefined ? context.tools.get(targetToolCallId) : undefined;
3207 if (target !== undefined && target.toolCallId !== toolCall.toolCallId) {
3208 const nextStatus =
3209 backgroundCompletion.status === "running"
3210 ? ("inProgress" as const)
3211 : backgroundCompletion.status === "failed"
3212 ? ("failed" as const)
3213 : ("completed" as const);
3214 const hydrated =
3215 backgroundCompletion.appendOutput.length > 0
3216 ? // TaskOutput is the real stdout; replace end-notice boilerplate
3217 // so the timeline shows the listing, not only "Monitor ended…".
3218 isMonitorEndNoticeText(toolOutputText(target)) ||
3219 toolOutputText(target).trim().length === 0
3220 ? setToolOutputText(
3221 { ...target, status: nextStatus },
3222 backgroundCompletion.appendOutput,
3223 )
3224 : appendToolOutputText(
3225 { ...target, status: nextStatus },
3226 backgroundCompletion.appendOutput,
3227 )
3228 : { ...target, status: nextStatus };
3229 yield* emitTool(context, hydrated);
3230 }
3231 }
3232
3233 // Monitor TaskOutput shares the get_command tool shape with subagent
3234 // hydration; do not spawn a phantom subagent for a registered monitor.
3235 const subagentUpdate = hydratedRegisteredMonitor
3236 ? undefined
3237 : flavor.extractSubagentUpdate?.(toolCall);
3238 if (subagentUpdate !== undefined) {
3239 yield* emitSubagent(context, subagentUpdate);
3240 if (subagentUpdate.suppressNormalTool !== false) {
3241 yield* rearmDeferredFinalize(context);
3242 return;
3243 }
3244 }
3245 if (
3246 projectedStatus === undefined &&
3247 !shouldPersistToolUpdate(
3248 context,
3249 toolCall.toolCallId,
3250 previous,
3251 toolCall,
3252 merged.status,
3253 )
3254 ) {
3255 yield* rearmDeferredFinalize(context);
3256 return;
3257 }
3258 const status = projectedStatus ?? toolStatus(toolCall.status);
3259 const now = yield* DateTime.now;
3260 const nativeItemId = `${context.nativeThreadId}:tool:${toolCall.toolCallId}`;
3261 const ordinal = yield* resolveItemOrdinal(context, nativeItemId);
3262 const nodeId = providerNodeId(nativeItemId);
3263 const turnItemId = providerTurnItemId(nativeItemId);
3264 const nativeItemRef = {
3265 driver,
3266 nativeId: toolCall.toolCallId,
3267 strength: "strong" as const,
3268 };
3269 const startedAt = context.toolStartedAt.get(toolCall.toolCallId) ?? now;
3270 context.toolStartedAt.set(toolCall.toolCallId, startedAt);
3271 const completedAt = completedAtForStatus(status, now);
3272 const title = toolCall.title ?? null;
3273 yield* emitProviderEvent({
3274 type: "node.updated",
3275 driver,
3276 node: {
3277 id: nodeId,
3278 threadId: context.input.threadId,
3279 runId: context.input.runId,
3280 parentNodeId: context.input.rootNodeId,
3281 rootNodeId: context.input.rootNodeId,
3282 kind: "tool_call",
3283 status: nodeStatus(status),
3284 countsForRun: true,
3285 providerThreadId: context.input.providerThread.id,
3286 providerTurnId: context.providerTurnId,
3287 nativeItemRef,
3288 runtimeRequestId: null,
3289 checkpointScopeId: null,
3290 startedAt,
3291 completedAt,
3292 },
3293 });
3294
3295 const base = {
3296 id: turnItemId,
3297 threadId: context.input.threadId,
3298 runId: context.input.runId,
3299 nodeId,
3300 providerThreadId: context.input.providerThread.id,
3301 providerTurnId: context.providerTurnId,
3302 nativeItemRef,
3303 parentItemId: null,
3304 ordinal,
3305 status,
3306 title,
3307 startedAt,
3308 completedAt,
3309 updatedAt: now,
3310 } as const;
3311 const rawInput = toolCall.data.rawInput;
3312 const rawOutput = toolCall.data.rawOutput ?? toolCall.data.content;
3313 const changes = structuredFileChanges(toolCall);
3314 const path = changes[0]?.path ?? pathFromToolCall(toolCall);
3315 const diffText =
3316 acpToolCallDiffPatch(toolCall.data.content) ?? textFromUnknown(rawOutput);
3317 const rawInputRecord = unknownRecord(rawInput);
3318 const inputVariant =
3319 typeof rawInputRecord?.variant === "string"
3320 ? rawInputRecord.variant.trim().toLowerCase()
3321 : "";
3322 const rawOutputRecord = unknownRecord(rawOutput);
3323 const outputCommand =
3324 typeof rawOutputRecord?.command === "string" &&
3325 rawOutputRecord.command.trim().length > 0
3326 ? rawOutputRecord.command.trim()
3327 : undefined;
3328 const monitorCommand =
3329 (typeof rawInputRecord?.command === "string" && rawInputRecord.command.trim().length > 0
3330 ? rawInputRecord.command.trim()
3331 : undefined) ?? outputCommand;
3332 const outputIsBashResult =
3333 typeof rawOutputRecord?.type === "string" &&
3334 rawOutputRecord.type.trim().toLowerCase() === "bash" &&
3335 commandExitCode(rawOutput) !== undefined;
3336 // Grok Monitor tools arrive as generic kind + variant; project like shell
3337 // so stdout is plain text in the timeline (not JSON {type:Text,text:...}).
3338 // Post-settle wake re-reports of a finished monitor carry no rawInput at
3339 // all, only a structured Bash result; project those as commands too.
3340 const projectAsCommandExecution = inputVariant === "monitor" || outputIsBashResult;
3341 // ACP has no typed MCP item, so recover MCP identity from the
3342 // agent-specific shape and project the same branded dynamic_tool
3343 // item native providers produce (e.g. the T3 orchestration tools).
3344 const mcpIdentity = extractMcpToolCallIdentity(toolCall, {
3345 embeddedTerminalCommands: embeddedTerminalCommands(
3346 context.nativeThreadId,
3347 toolCall.toolCallId,
3348 ),
3349 });
3350 let turnItem: OrchestrationV2TurnItem;
3351 if (toolCall.toolCallId.startsWith("acp-compaction:")) {
3352 const summary = textFromUnknown(rawOutput);
3353 turnItem = {
3354 ...base,
3355 type: "compaction",
3356 driver,
3357 ...(summary === undefined ? {} : { summary }),
3358 ...(context.contextUsage?.usedTokens === undefined
3359 ? {}
3360 : { beforeTokenCount: context.contextUsage.usedTokens }),
3361 };
3362 if (status === "completed") {
3363 context.contextUsage = null;
3364 yield* Ref.update(contextUsageBySessionId, (current) => {
3365 const updated = new Map(current);
3366 updated.delete(context.nativeThreadId);
3367 return updated;
3368 });
3369 }
3370 } else if (mcpIdentity !== undefined) {
3371 turnItem = {
3372 ...base,
3373 title: null,
3374 ...mcpToolPresentation({
3375 serverName: mcpIdentity.server,
3376 toolName: mcpIdentity.tool,
3377 source: unknownRecord(
3378 (unknownRecord(rawOutputRecord?.result) ?? rawOutputRecord)?._meta,
3379 )?.source,
3380 }),
3381 type: "dynamic_tool",
3382 toolName: `${mcpIdentity.server}.${mcpIdentity.tool}`,
3383 input:
3384 mcpIdentity.input ??
3385 unknownRecord(rawInputRecord?.arguments) ??
3386 rawInputRecord ??
3387 {},
3388 ...(rawOutput === undefined ? {} : { output: acpMcpToolCallOutput(rawOutput) }),
3389 };
3390 yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem });
3391 yield* rearmDeferredFinalize(context);
3392 return;
3393 } else if (changes.length > 0) {
3394 turnItem = {
3395 ...base,
3396 type: "file_change",
3397 fileName: changes[0]!.path,
3398 changes,
3399 ...(diffText === undefined ? {} : { diffStr: diffText }),
3400 };
3401 } else {
3402 switch (toolCall.kind) {
3403 case "read":
3404 turnItem = {
3405 ...base,
3406 title: path ? formatReadToolLabel(path) : (title ?? "Read file"),
3407 type: "dynamic_tool",
3408 toolName: "Read",
3409 input:
3410 path === undefined ||
3411 ["path", "filePath", "file_path"].some((key) => rawInputRecord?.[key] === path)
3412 ? (rawInputRecord ?? {})
3413 : { ...rawInputRecord, path },
3414 ...(rawOutput === undefined ? {} : { output: rawOutput }),
3415 };
3416 break;
3417 case "search": {
3418 const backendSearch = acpBackendWebSearch(rawInputRecord, rawOutputRecord);
3419 if (backendSearch !== undefined) {
3420 // Grok titles these "X search:" / "Web search:" awaiting the query.
3421 const label = nonEmptyText(toolCall.data.title, title ?? "Web search").replace(
3422 /:\s*$/u,
3423 "",
3424 );
3425 turnItem = {
3426 ...base,
3427 title:
3428 backendSearch.query === undefined
3429 ? label
3430 : `${label}: ${backendSearch.query}`,
3431 type: "web_search",
3432 ...(backendSearch.query === undefined
3433 ? {}
3434 : { patterns: [backendSearch.query] }),
3435 ...(backendSearch.results.length === 0
3436 ? {}
3437 : { results: backendSearch.results }),
3438 };
3439 break;
3440 }
3441 turnItem = {
3442 ...base,
3443 title:
3444 formatSearchToolLabel({
3445 rawInput: rawInputRecord,
3446 input: rawInputRecord,
3447 ...(path === undefined ? {} : { pattern: path }),
3448 }) ?? title,
3449 type: "file_search",
3450 ...(path === undefined ? {} : { pattern: path }),
3451 ...(path === undefined
3452 ? {}
3453 : {
3454 results: [
3455 {
3456 fileName: path,
3457 ...(textFromUnknown(rawOutput) === undefined
3458 ? {}
3459 : { preview: textFromUnknown(rawOutput) }),
3460 },
3461 ],
3462 }),
3463 };
3464 break;
3465 }
3466 case "execute": {
3467 const exitCode = acpProjectedCommandExitCode(status, rawOutput);
3468 turnItem = {
3469 ...base,
3470 type: "command_execution",
3471 input: toolCall.command ?? monitorCommand ?? toolCall.title ?? "Command",
3472 ...(textFromUnknown(rawOutput) === undefined
3473 ? {}
3474 : { output: textFromUnknown(rawOutput) }),
3475 ...(exitCode === undefined ? {} : { exitCode }),
3476 };
3477 break;
3478 }
3479 case "edit":
3480 case "delete":
3481 case "move":
3482 turnItem = {
3483 ...base,
3484 type: "file_change",
3485 fileName: path ?? toolCall.title ?? "File change",
3486 ...(diffText === undefined ? {} : { diffStr: diffText }),
3487 };
3488 break;
3489 case "fetch": {
3490 // Grok nests the page under rawOutput.Content, which textFromUnknown
3491 // cannot read; the (bounded) content blocks carry the same text.
3492 const snippet =
3493 textFromUnknown(toolCall.data.content) ?? textFromUnknown(rawOutput);
3494 turnItem = {
3495 ...base,
3496 type: "web_search",
3497 ...(path === undefined ? {} : { patterns: [path] }),
3498 ...(path === undefined
3499 ? {}
3500 : {
3501 results: [
3502 {
3503 url: path,
3504 ...(snippet === undefined ? {} : { snippet }),
3505 },
3506 ],
3507 }),
3508 };
3509 break;
3510 }
3511 default:
3512 if (projectAsCommandExecution) {
3513 const exitCode = acpProjectedCommandExitCode(status, rawOutput);
3514 turnItem = {
3515 ...base,
3516 type: "command_execution",
3517 input:
3518 toolCall.command ??
3519 monitorCommand ??
3520 toolCall.title ??
3521 (inputVariant === "monitor" ? "Monitor" : "Command"),
3522 ...(textFromUnknown(rawOutput) === undefined
3523 ? {}
3524 : { output: textFromUnknown(rawOutput) }),
3525 ...(exitCode === undefined ? {} : { exitCode }),
3526 };
3527 } else {
3528 turnItem = {
3529 ...base,
3530 type: "dynamic_tool",
3531 toolName: toolCall.title ?? toolCall.kind ?? null,
3532 input: rawInput ?? {},
3533 ...(rawOutput === undefined ? {} : { output: rawOutput }),
3534 };
3535 }
3536 }
3537 }
3538 yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem });
3539 yield* rearmDeferredFinalize(context);
3540 });
3541
3542 const emitPlan = Effect.fnUntraced(function* (
3543 context: ActiveAcpTurn,
3544 update: AcpPlanUpdate,
3545 ) {
3546 yield* closeTextStreams(context);
3547 const existing = context.plans.get(update.nativePlanId);
3548 if (update.kind === "removed" && existing === undefined) return;
3549 const nativeItemId = `${context.nativeTurnId}:plan:${encodeURIComponent(update.nativePlanId)}`;
3550 const ordinal = yield* resolveItemOrdinal(context, nativeItemId);
3551 const now = yield* DateTime.now;
3552 const nodeId = providerNodeId(nativeItemId);
3553 const turnItemId = providerTurnItemId(nativeItemId);
3554 const planState = existing ?? {
3555 id: yield* idAllocator.allocate.plan({
3556 threadId: context.input.threadId,
3557 runId: context.input.runId,
3558 driver,
3559 }),
3560 startedAt: now,
3561 latest: null,
3562 };
3563 const planId = planState.id;
3564 const nativeItemRef = { driver, nativeId: nativeItemId, strength: "weak" as const };
3565 const base = {
3566 id: planId,
3567 threadId: context.input.threadId,
3568 runId: context.input.runId,
3569 nodeId,
3570 } as const;
3571 let plan: OrchestrationV2PlanArtifact;
3572 if (update.kind === "removed") {
3573 if (planState.latest === null) return;
3574 plan = { ...planState.latest, status: "superseded" };
3575 } else if (update.kind === "items") {
3576 const steps: ReadonlyArray<OrchestrationV2PlanStep> = update.plan.map(
3577 (step, index) => ({
3578 id: `acp-step-${index + 1}`,
3579 text: nonEmptyText(step.step, `Step ${index + 1}`),
3580 status:
3581 step.status === "inProgress"
3582 ? "running"
3583 : step.status === "completed"
3584 ? "completed"
3585 : "pending",
3586 }),
3587 );
3588 const completed =
3589 steps.length > 0 && steps.every((step) => step.status === "completed");
3590 plan = {
3591 ...base,
3592 status: completed ? "completed" : "active",
3593 kind: "todo_list",
3594 steps,
3595 ...(update.explanation == null ? {} : { explanation: update.explanation }),
3596 };
3597 } else {
3598 const markdown =
3599 update.kind === "markdown"
3600 ? update.markdown
3601 : update.kind === "file"
3602 ? `Plan file: ${update.uri}`
3603 : `[Unsupported ACP plan content: ${update.contentType}]`;
3604 plan = {
3605 ...base,
3606 status: "active",
3607 kind: "proposed_plan",
3608 markdown,
3609 };
3610 }
3611 planState.latest = plan;
3612 context.plans.set(update.nativePlanId, planState);
3613 const completed = plan.status === "completed" || plan.status === "superseded";
3614 const nodeKind = plan.kind === "todo_list" ? "todo_list" : "plan";
3615 yield* emitProviderEvent({
3616 type: "node.updated",
3617 driver,
3618 node: {
3619 id: nodeId,
3620 threadId: context.input.threadId,
3621 runId: context.input.runId,
3622 parentNodeId: context.input.rootNodeId,
3623 rootNodeId: context.input.rootNodeId,
3624 kind: nodeKind,
3625 status: completed ? "completed" : "running",
3626 countsForRun: false,
3627 providerThreadId: context.input.providerThread.id,
3628 providerTurnId: context.providerTurnId,
3629 nativeItemRef,
3630 runtimeRequestId: null,
3631 checkpointScopeId: null,
3632 startedAt: planState.startedAt,
3633 completedAt: completed ? now : null,
3634 },
3635 });
3636 yield* emitProviderEvent({ type: "plan.updated", driver, plan });
3637 yield* emitProviderEvent({
3638 type: "turn_item.updated",
3639 driver,
3640 turnItem: {
3641 id: turnItemId,
3642 threadId: context.input.threadId,
3643 runId: context.input.runId,
3644 nodeId,
3645 providerThreadId: context.input.providerThread.id,
3646 providerTurnId: context.providerTurnId,
3647 nativeItemRef,
3648 parentItemId: null,
3649 ordinal,
3650 status: completed ? "completed" : "running",
3651 title: null,
3652 startedAt: planState.startedAt,
3653 completedAt: completed ? now : null,
3654 updatedAt: now,
3655 ...(plan.kind === "todo_list"
3656 ? {
3657 type: "todo_list" as const,
3658 planId,
3659 steps: plan.steps,
3660 ...(plan.explanation === undefined ? {} : { explanation: plan.explanation }),
3661 }
3662 : {
3663 type: "proposed_plan" as const,
3664 planId,
3665 markdown: plan.markdown,
3666 streaming: !completed,
3667 }),
3668 },
3669 });
3670 });
3671
3672 const appendLoadedHistory = (
3673 notification: EffectAcpSchema.SessionNotification,
3674 role: "user" | "assistant" | "thought",
3675 text: string,
3676 replace = false,
3677 ) =>
3678 Effect.gen(function* () {
3679 if (text.length === 0 && !replace) return;
3680 const now = yield* DateTime.now;
3681 yield* Ref.update(snapshot, (current) => {
3682 const update = notification.update;
3683 const sourceMessageId =
3684 (update.sessionUpdate === "user_message_chunk" ||
3685 update.sessionUpdate === "agent_message_chunk" ||
3686 update.sessionUpdate === "agent_thought_chunk" ||
3687 update.sessionUpdate === "user_message" ||
3688 update.sessionUpdate === "agent_message" ||
3689 update.sessionUpdate === "agent_thought") &&
3690 update.messageId
3691 ? update.messageId
3692 : null;
3693 const startsNew =
3694 current.loadingRole !== role ||
3695 (sourceMessageId !== null && current.loadingMessageId !== sourceMessageId);
3696 const loadingIndex = startsNew ? current.loadingIndex + 1 : current.loadingIndex;
3697 const nativeItemId =
3698 sourceMessageId === null
3699 ? `${notification.sessionId}:history:${role}:${loadingIndex}`
3700 : `${notification.sessionId}:history:${role}:message:${sourceMessageId}`;
3701 const messageId = providerMessageId(nativeItemId);
3702 const key = String(messageId);
3703 const previous = current.messages.get(key);
3704 const messages = new Map(current.messages);
3705 messages.set(key, {
3706 createdBy: previous?.createdBy ?? (role === "user" ? "user" : "agent"),
3707 creationSource: previous?.creationSource ?? "provider",
3708 id: messageId,
3709 threadId: input.threadId,
3710 runId: null,
3711 nodeId: null,
3712 role: role === "user" ? "user" : "assistant",
3713 text: replace ? text : `${previous?.text ?? ""}${text}`,
3714 attachments: [],
3715 streaming: false,
3716 createdAt: previous?.createdAt ?? now,
3717 updatedAt: now,
3718 });
3719 return {
3720 order: current.order.includes(key) ? current.order : [...current.order, key],
3721 messages,
3722 loadingRole: role,
3723 loadingMessageId: sourceMessageId ?? (startsNew ? null : current.loadingMessageId),
3724 loadingIndex,
3725 };
3726 });
3727 });
3728
3729 const offerContinuationRun = Effect.fnUntraced(function* (_sessionId: string) {
3730 if (continuationRequests === undefined) {
3731 return false;
3732 }
3733 const pending = yield* continuationPermit.withPermit(
3734 Effect.gen(function* () {
3735 if (yield* Ref.get(continuationClosed)) return Option.none();
3736 if (yield* Ref.get(stoppedRunQuarantine)) return Option.none();
3737 if (yield* Ref.get(continuationRequested)) return Option.none();
3738 const route = yield* Ref.get(lastTurnRoute);
3739 if (route === null) return Option.none();
3740 yield* Ref.set(continuationRequested, true);
3741 const generation = yield* Ref.updateAndGet(
3742 continuationGeneration,
3743 (value) => value + 1,
3744 );
3745 const reports = yield* Ref.modify(wakeReports, ({ reports }) => [
3746 reports,
3747 { reports, offered: new Set(reports.keys()) },
3748 ]);
3749 return Option.some({ route, generation, reports });
3750 }),
3751 );
3752 if (Option.isNone(pending)) return false;
3753 const { route, generation, reports } = pending.value;
3754 const notification = backgroundWorkNotification([...reports.values()]);
3755 yield* Effect.logInfo("orchestration-v2.acp-wake-turn-detected", {
3756 driver,
3757 providerSessionId: input.providerSessionId,
3758 threadId: route.threadId,
3759 providerThreadId: route.providerThreadId,
3760 });
3761 yield* continuationRequests.offer({
3762 threadId: route.threadId,
3763 providerThreadId: route.providerThreadId,
3764 driver,
3765 detail: null,
3766 ...(notification === null ? {} : { notification }),
3767 clearIfCurrent: () =>
3768 continuationPermit.withPermit(
3769 Effect.gen(function* () {
3770 if ((yield* Ref.get(continuationGeneration)) === generation) {
3771 yield* Ref.set(continuationRequested, false);
3772 }
3773 }),
3774 ),
3775 dispatchIfCurrent: (effect) =>
3776 continuationPermit.withPermit(
3777 Effect.gen(function* () {
3778 const clearIfOwner = Effect.gen(function* () {
3779 if ((yield* Ref.get(continuationGeneration)) === generation) {
3780 yield* Ref.set(continuationRequested, false);
3781 }
3782 });
3783 if (yield* Ref.get(stoppedRunQuarantine)) {
3784 yield* clearIfOwner;
3785 return Option.none();
3786 }
3787 if ((yield* Ref.get(continuationGeneration)) !== generation) {
3788 // Superseded by a newer offer; do not clear its flag.
3789 return Option.none();
3790 }
3791 if (!(yield* Ref.get(continuationRequested))) return Option.none();
3792 // A successful durable dispatch owns the sticky flag until its
3793 // continuation turn starts. Clearing here opens a dispatch-to-
3794 // start race where every late ACP frame can enqueue another
3795 // synthetic continuation. Failures clear immediately because
3796 // no turn will arrive to do so.
3797 const exit = yield* Effect.exit(effect);
3798 if (Exit.isFailure(exit)) {
3799 yield* clearIfOwner;
3800 return yield* Effect.failCause(exit.cause);
3801 }
3802 return Option.some(exit.value);
3803 }),
3804 ),
3805 });
3806 return true;
3807 });
3808
3809 const applyLateBackgroundMutation = Effect.fnUntraced(function* (
3810 sessionId: string,
3811 mutation: {
3812 readonly taskId: string;
3813 readonly status: "running" | "completed" | "failed";
3814 },
3815 ) {
3816 const taskAlreadyEnded = (yield* Ref.get(endedBackgroundTaskIds)).has(mutation.taskId);
3817 yield* applyBackgroundTaskMutationRunning(mutation);
3818 if (mutation.status === "running") {
3819 // Straggler running notice after a genuine end: suppress residual
3820 // monitor-prompt agent chatter so it cannot open a synthetic wake.
3821 if (taskAlreadyEnded && acpPostSettleMonitorPromptShouldSuppress(mutation)) {
3822 yield* Ref.set(suppressPostSettleMonitorPrompt, true);
3823 }
3824 return;
3825 }
3826 // First genuine terminal: allow subsequent agent output. Re-delivery
3827 // of an already-ended terminal must not un-suppress residual ack
3828 // chatter from the injected monitor-event turn.
3829 if (!taskAlreadyEnded) {
3830 yield* Ref.set(suppressPostSettleMonitorPrompt, false);
3831 }
3832 // Keep handledBackgroundTaskIdsInActiveTurn intact. Erasing the mark
3833 // on a late monitor-event mutation defeated the in-turn chatter guard
3834 // and let injected-turn acks arm wakeBuffer for a later spurious
3835 // "Background task completed." run (multiturn live repro). Monitors
3836 // the agent settled without reporting never enter that set, so their
3837 // injected report still streams via pendingInjectedReport / hold.
3838 const activeContext = yield* Ref.get(activeTurn);
3839 // Completions that land while a root turn is still streaming are
3840 // owned by in-turn machinery (emitTool handled marks, deferred
3841 // finalize, injected-report hold). Do not open a synthetic wake mid-
3842 // turn; finalizeTurn re-checks wakeBuffer / midTurnUnreported once
3843 // the turn leaves the active slot so legitimate unhandled evidence
3844 // still offers after finalize.
3845 if (activeContext !== null && !activeContext.finalized) {
3846 const handled = yield* Ref.get(handledBackgroundTaskIdsInActiveTurn);
3847 // Only arm deferred wake when this turn registered the task (monitor
3848 // tool started in-turn) and the root prompt is still open. Completions
3849 // that land in the settled-held window (promptSettled, deferred
3850 // finalize holding for injected report) fall back to the pre-existing
3851 // post-finalize injected-turn path; arming here would spuriously
3852 // offer "Background task completed." after the report streams into
3853 // the held turn. Residual cancel-backgrounded completions from a
3854 // prior interrupt must not open a synthetic continuation.
3855 if (
3856 !activeContext.promptSettled &&
3857 !handled.has(mutation.taskId) &&
3858 activeContext.toolCallIdsByBackgroundTaskId.has(mutation.taskId)
3859 ) {
3860 yield* Ref.update(midTurnUnreportedCompletedTaskIds, (current) =>
3861 new Set(current).add(mutation.taskId),
3862 );
3863 }
3864 return;
3865 }
3866 if (
3867 postSettleContinuationEnabled &&
3868 (yield* Ref.get(activeSessionId)) === sessionId &&
3869 (yield* Ref.get(runningBackgroundTaskIds)).size === 0 &&
3870 ((yield* Ref.get(wakeBuffer)).length > 0 ||
3871 (yield* Ref.get(midTurnUnreportedCompletedTaskIds)).size > 0)
3872 ) {
3873 yield* offerContinuationRun(sessionId);
3874 }
3875 });
3876
3877 // A structured task end (Grok `task_completed`) is authoritative for the
3878 // tool that registered the task: its own updates only ever say running.
3879 // Finish the row while deferred finalize holds a settled root turn open
3880 // for it, so the turn can settle. While the prompt is still open the
3881 // agent reports the end itself (TaskOutput hydration), and an unreported
3882 // end must keep its post-finalize continuation offer.
3883 const finishRegisteredBackgroundTool = Effect.fnUntraced(function* (mutation: {
3884 readonly taskId: string;
3885 readonly status: "completed" | "failed";
3886 readonly output?: string;
3887 }) {
3888 const context = yield* Ref.get(activeTurn);
3889 if (context === null || context.finalized || !context.promptSettled) return;
3890 const toolCallId = context.toolCallIdsByBackgroundTaskId.get(mutation.taskId);
3891 const tool = toolCallId === undefined ? undefined : context.tools.get(toolCallId);
3892 if (tool === undefined) return;
3893 const status = toolStatus(tool.status);
3894 if (status !== "pending" && status !== "running") return;
3895 context.awaitingBackgroundHydration.delete(mutation.taskId);
3896 const finished = { ...tool, status: mutation.status };
3897 yield* emitTool(
3898 context,
3899 mutation.output === undefined ? finished : setToolOutputText(finished, mutation.output),
3900 mutation.status,
3901 );
3902 yield* rearmDeferredFinalize(context);
3903 });
3904
3905 const bufferPostSettleWake = Effect.fnUntraced(function* (
3906 notification: EffectAcpSchema.SessionNotification,
3907 ) {
3908 if (!postSettleContinuationEnabled || continuationRequests === undefined) {
3909 return {
3910 buffered: false,
3911 offerContinuation: false,
3912 stopProcessing: false,
3913 };
3914 }
3915 // Direct Stop quarantine: drop residual wake evidence instead of
3916 // buffering it for a later continuation or follow-up run.
3917 if (yield* Ref.get(stoppedRunQuarantine)) {
3918 return {
3919 buffered: false,
3920 offerContinuation: false,
3921 stopProcessing: true,
3922 };
3923 }
3924 const rootSessionId = yield* Ref.get(activeSessionId);
3925 if (rootSessionId === null || notification.sessionId !== rootSessionId) {
3926 return {
3927 buffered: false,
3928 offerContinuation: false,
3929 stopProcessing: false,
3930 };
3931 }
3932 if (!acpPostSettleWakeEvidence(notification, flavor)) {
3933 return {
3934 buffered: false,
3935 offerContinuation: false,
3936 stopProcessing: false,
3937 };
3938 }
3939 const update = notification.update;
3940 let alreadyHandledToolUpdate = false;
3941 if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") {
3942 yield* trackRunningBackgroundTools(notification);
3943 // When the agent consumes the monitor output itself via
3944 // get_command_or_subagent_output (long timeout), the TaskOutput
3945 // completion is the ONLY end signal: no "Monitor ended" reminder
3946 // follows. Without applying it here the running set never clears,
3947 // every offer stays suppressed, and the wake buffer never drains
3948 // (observed live 2026-07-12, thread 8dbe607f). The completion
3949 // frame itself is offer evidence, so clearing the id before the
3950 // gate below lets it open the single continuation run.
3951 for (const event of parseSessionUpdateEvent(notification).events) {
3952 if (event._tag !== "ToolCallUpdated") continue;
3953 const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall;
3954 const proposedPlanMarkdown = flavor.extractProposedPlanMarkdown?.(toolCall);
3955 if (proposedPlanMarkdown !== undefined && proposedPlanMarkdown.length > 0) {
3956 yield* captureProposedPlan({ planMarkdown: proposedPlanMarkdown });
3957 }
3958 const toolTaskId = flavor.extractBackgroundTaskId?.(toolCall);
3959 if (
3960 toolTaskId !== undefined &&
3961 (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(toolTaskId)
3962 ) {
3963 alreadyHandledToolUpdate = true;
3964 }
3965 if (flavor.extractBackgroundTaskCompletion !== undefined) {
3966 for (const completion of flavor.extractBackgroundTaskCompletion(toolCall)) {
3967 alreadyHandledToolUpdate =
3968 alreadyHandledToolUpdate ||
3969 (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(completion.taskId);
3970 yield* applyBackgroundTaskMutationRunning(completion);
3971 }
3972 }
3973 }
3974 }
3975 const backgroundWorkRunning = (yield* Ref.get(runningBackgroundTaskIds)).size > 0;
3976 // Residual Grok agent/thought chatter after in-turn-handled background
3977 // work must not be retained as wake evidence. Tool-path alreadyHandled
3978 // covers re-reports with a task id; this covers agent_message_chunk /
3979 // agent_thought_chunk frames that carry no task id (live:
3980 // grok-in-turn-monitor-no-wake, multiturn stale-buffer arm). Check
3981 // before buffering so the frames cannot dirty wakeBuffer and later
3982 // arm a mid-turn offer when a second monitor completes.
3983 const handledInTurnCount = (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size;
3984 const isInTurnHandledAgentChatter =
3985 handledInTurnCount > 0 &&
3986 (update.sessionUpdate === "agent_message_chunk" ||
3987 update.sessionUpdate === "agent_thought_chunk");
3988 // Grok prompts itself for every monitor event after the root turn
3989 // settles. Its assistant/reasoning replies are progress chatter, not
3990 // separate wake results. Retaining them would replay the entire burst
3991 // into the single continuation once the monitor finishes. Keep tool
3992 // state so the final command card still hydrates, then begin retaining
3993 // agent output again after the genuine end signal clears the running
3994 // set. With multiple tasks, text remains best-effort until every task
3995 // ends; their retained tool cards are the authoritative results.
3996 //
3997 // Skip retaining frames for tasks already hydrated in the root turn
3998 // (`handledBackgroundTaskIdsInActiveTurn`). Those re-reports must not
3999 // pin `hasPendingBackgroundWork` via a wake buffer that never drains
4000 // (the already-handled gate below intentionally skips
4001 // `offerContinuationRun` to avoid synthetic "Background task
4002 // completed." spam).
4003 let buffered = false;
4004 if (
4005 !alreadyHandledToolUpdate &&
4006 !isInTurnHandledAgentChatter &&
4007 acpPostSettleWakeShouldBuffer(notification, backgroundWorkRunning)
4008 ) {
4009 yield* Ref.update(wakeBuffer, (current) => [...current, notification]);
4010 buffered = true;
4011 }
4012 // Buffer progress without offering; only completion-like frames open a run.
4013 if (!acpPostSettleContinuationOfferEvidence(notification, flavor)) {
4014 return {
4015 buffered,
4016 offerContinuation: false,
4017 stopProcessing: true,
4018 };
4019 }
4020 // While a monitor is still streaming, tool re-reports buffer without
4021 // offering and per-event agent commentary is consumed without being
4022 // retained. Grok re-reports a running monitor as Bash frames that
4023 // already carry exit_code 0 mid-stream, so a "terminal" normalized
4024 // status is not evidence the task ended; each burst would otherwise
4025 // reopen a synthetic "Background task completed." run. Retained tool
4026 // frames drain into the single continuation offered once the monitor
4027 // actually ends (end-notice mutation below, or the first frame after
4028 // it).
4029 if (backgroundWorkRunning) {
4030 return {
4031 buffered,
4032 offerContinuation: false,
4033 stopProcessing: true,
4034 };
4035 }
4036 if (alreadyHandledToolUpdate) {
4037 // Drop leftover wake noise for in-turn-handled work so idle release
4038 // is not pinned forever. Leave the buffer alone when a continuation
4039 // is already outstanding: its startTurn will drain legitimate frames
4040 // from other tasks.
4041 if (!(yield* Ref.get(continuationRequested))) {
4042 yield* Ref.set(wakeBuffer, []);
4043 }
4044 return {
4045 buffered,
4046 offerContinuation: false,
4047 stopProcessing: true,
4048 };
4049 }
4050 // Same in-turn-handled agent chatter: do not open a synthetic wake.
4051 // Do not clear wakeBuffer here: frames for other still-tracked tasks
4052 // must remain drainable when a real (tool) completion later offers.
4053 if (isInTurnHandledAgentChatter) {
4054 return {
4055 buffered,
4056 offerContinuation: false,
4057 stopProcessing: true,
4058 };
4059 }
4060 return {
4061 buffered,
4062 offerContinuation: true,
4063 stopProcessing: true,
4064 };
4065 });
4066
4067 let applyFinalizedActiveTurnSubagentTerminal: (
4068 context: ActiveAcpTurn,
4069 notification: EffectAcpSchema.SessionNotification,
4070 ) => Effect.Effect<boolean> = () => Effect.succeed(false);
4071
4072 const projectAgentTerminalUpdate = Effect.fnUntraced(function* (
4073 notification: EffectAcpSchema.SessionNotification,
4074 context: ActiveAcpTurn | null,
4075 ) {
4076 const update = notification.update;
4077 if (
4078 update.sessionUpdate !== "terminal_update" &&
4079 update.sessionUpdate !== "terminal_output_chunk"
4080 ) {
4081 return false;
4082 }
4083 const terminalKey = sessionScopedId(notification.sessionId, update.terminalId);
4084 const next = applyAcpAgentTerminalUpdate(agentTerminalsById.get(terminalKey), update);
4085 agentTerminalsById.set(terminalKey, next);
4086 if (context === null || notification.sessionId !== context.nativeThreadId) return true;
4087 const status =
4088 next.exitStatus === undefined
4089 ? ("inProgress" as const)
4090 : next.exitStatus.exitCode === 0
4091 ? ("completed" as const)
4092 : ("failed" as const);
4093 const embeddedToolCallIds = toolCallIdsByAgentTerminalId.get(terminalKey);
4094 const projectedToolCallIds =
4095 embeddedToolCallIds === undefined || embeddedToolCallIds.size === 0
4096 ? [`acp-agent-terminal:${update.terminalId}`]
4097 : [...embeddedToolCallIds];
4098 for (const toolCallId of projectedToolCallIds) {
4099 const existing = context.tools.get(toolCallId);
4100 const seeded: AcpToolCallState =
4101 existing ??
4102 ({
4103 toolCallId,
4104 kind: "execute",
4105 title: next.command ?? "Terminal",
4106 status,
4107 ...(next.command === undefined ? {} : { command: next.command }),
4108 data: { toolCallId },
4109 } satisfies AcpToolCallState);
4110 yield* emitTool(
4111 context,
4112 setToolOutputText(
4113 {
4114 ...seeded,
4115 status,
4116 ...(next.command === undefined ? {} : { command: next.command }),
4117 },
4118 next.output,
4119 ),
4120 );
4121 }
4122 return true;
4123 });
4124
4125 const handleSessionUpdate = Effect.fnUntraced(function* (
4126 notification: EffectAcpSchema.SessionNotification,
4127 ) {
4128 const context = yield* Ref.get(activeTurn);
4129 const update = notification.update;
4130 if (yield* projectAgentTerminalUpdate(notification, context)) return;
4131 if (
4132 update.sessionUpdate === "usage_update" ||
4133 update.sessionUpdate === "session_info_update" ||
4134 (update.sessionUpdate === "state_update" &&
4135 update.state === "idle" &&
4136 update.usage != null)
4137 ) {
4138 const stateEvent = parseSessionUpdateEvent(notification).events.find(
4139 (event) => event._tag === "UsageUpdated" || event._tag === "SessionInfoUpdated",
4140 );
4141 if (stateEvent === undefined) return;
4142 if (stateEvent._tag === "UsageUpdated") {
4143 yield* Ref.update(contextUsageBySessionId, (current) =>
4144 new Map(current).set(notification.sessionId, stateEvent.usage),
4145 );
4146 if (context?.nativeThreadId === notification.sessionId) {
4147 context.contextUsage = stateEvent.usage;
4148 }
4149 } else {
4150 const metadata = yield* Ref.modify(nativeMetadataBySessionId, (current) => {
4151 const merged = {
4152 ...current.get(notification.sessionId),
4153 ...stateEvent.metadata,
4154 };
4155 return [merged, new Map(current).set(notification.sessionId, merged)] as const;
4156 });
4157 if (context?.nativeThreadId === notification.sessionId) {
4158 context.nativeMetadata = metadata;
4159 }
4160 }
4161 const knownProviderThread = (yield* Ref.get(providerThreadByNativeSessionId)).get(
4162 notification.sessionId,
4163 );
4164 if (knownProviderThread !== undefined) {
4165 const now = yield* DateTime.now;
4166 const providerThread: OrchestrationV2ProviderThread = {
4167 ...knownProviderThread,
4168 contextUsage:
4169 (yield* Ref.get(contextUsageBySessionId)).get(notification.sessionId) ??
4170 knownProviderThread.contextUsage ??
4171 null,
4172 nativeMetadata:
4173 (yield* Ref.get(nativeMetadataBySessionId)).get(notification.sessionId) ??
4174 knownProviderThread.nativeMetadata ??
4175 null,
4176 updatedAt: now,
4177 };
4178 yield* Ref.update(providerThreadByNativeSessionId, (current) =>
4179 new Map(current).set(notification.sessionId, providerThread),
4180 );
4181 yield* emitProviderEvent({
4182 type: "provider_thread.updated",
4183 driver,
4184 providerThread,
4185 });
4186 }
4187 return;
4188 }
4189 if (
4190 context?.finalized === true &&
4191 (yield* applyFinalizedActiveTurnSubagentTerminal(context, notification))
4192 ) {
4193 return;
4194 }
4195 // Only while a finalized turn is still the active context. When
4196 // activeTurn is null, post-settle agent frames must reach
4197 // bufferPostSettleWake so continuation can attach (context?.finalized
4198 // !== false incorrectly treated null as finalized and dropped them).
4199 if (
4200 context !== null &&
4201 context.finalized &&
4202 (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size > 0 &&
4203 (update.sessionUpdate === "agent_message_chunk" ||
4204 update.sessionUpdate === "agent_thought_chunk")
4205 ) {
4206 yield* Ref.set(suppressPostSettleMonitorPrompt, true);
4207 return;
4208 }
4209 if (
4210 context !== null &&
4211 (yield* Ref.get(suppressPostSettleMonitorPrompt)) &&
4212 (update.sessionUpdate === "agent_message_chunk" ||
4213 update.sessionUpdate === "agent_thought_chunk")
4214 ) {
4215 return;
4216 }
4217 if (
4218 context?.finalized === true &&
4219 update.sessionUpdate === "user_message_chunk" &&
4220 update.content.type === "text"
4221 ) {
4222 const mutations = flavor.extractBackgroundToolMutation?.(update.content.text) ?? [];
4223 for (const mutation of mutations) {
4224 yield* applyLateBackgroundMutation(notification.sessionId, mutation);
4225 }
4226 return;
4227 }
4228 if (context === null) {
4229 // Direct Stop: quarantine residual events from the stopped run so
4230 // they cannot become history, wake buffers, or a later run attach.
4231 if (yield* Ref.get(stoppedRunQuarantine)) {
4232 return;
4233 }
4234 const bufferOutcome = yield* bufferPostSettleWake(notification);
4235 // Post-settle carryover sync: keep in-memory carryover accurate so
4236 // hasPendingBackgroundWork reasons correctly after root settle.
4237 // A completed root keeps its subscriber open while background items
4238 // remain, so project its terminals immediately even when the wake
4239 // frame is buffered for a continuation. Non-completed roots stay
4240 // memory-only: durable ingest requires a subscriber that owns the
4241 // original runId, which the interrupted path does not guarantee.
4242 const carryover = yield* Ref.get(carryoverSubagents);
4243 const rootTerminalCanStillProject =
4244 carryover !== null &&
4245 carryover.sessionId === (yield* Ref.get(activeSessionId)) &&
4246 carryover.rootTerminalStatus === "completed";
4247 const projectCarryover = rootTerminalCanStillProject;
4248 let carryoverTerminalized = false;
4249 if (
4250 flavor.extractSubagentUpdate !== undefined &&
4251 (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update")
4252 ) {
4253 for (const event of parseSessionUpdateEvent(notification).events) {
4254 if (event._tag !== "ToolCallUpdated") continue;
4255 const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall;
4256 const subagentUpdate = flavor.extractSubagentUpdate(toolCall);
4257 if (subagentUpdate === undefined) continue;
4258 if (!acpSubagentStatusIsTerminal(subagentUpdate.status)) {
4259 continue;
4260 }
4261 if (
4262 yield* updateCarryoverSubagentStatus(
4263 subagentUpdate.nativeTaskId,
4264 subagentUpdate.status,
4265 subagentUpdate.result,
4266 { project: projectCarryover },
4267 )
4268 ) {
4269 carryoverTerminalized = true;
4270 }
4271 if (
4272 subagentUpdate.childSessionId !== null &&
4273 (yield* updateCarryoverSubagentStatus(
4274 subagentUpdate.childSessionId,
4275 subagentUpdate.status,
4276 subagentUpdate.result,
4277 { project: projectCarryover },
4278 ))
4279 ) {
4280 carryoverTerminalized = true;
4281 }
4282 }
4283 }
4284 if (
4285 update.sessionUpdate === "user_message_chunk" &&
4286 update.content.type === "text" &&
4287 flavor.extractSubagentEndNotice !== undefined
4288 ) {
4289 const notice = flavor.extractSubagentEndNotice(update.content.text);
4290 if (
4291 notice !== undefined &&
4292 (yield* updateCarryoverSubagentStatus(
4293 notice.childSessionId,
4294 notice.status,
4295 undefined,
4296 {
4297 project: projectCarryover,
4298 },
4299 ))
4300 ) {
4301 carryoverTerminalized = true;
4302 }
4303 }
4304 // Synchronize carryover before an eager continuation can attach and
4305 // drain the frame.
4306 const continuationOffered = bufferOutcome.offerContinuation
4307 ? yield* offerContinuationRun(notification.sessionId)
4308 : false;
4309 const wakeOutcome = { ...bufferOutcome, continuationOffered };
4310 // Frames that will not buffer never reach the continuation drain for
4311 // this traffic. Once carryover is terminalized, skip history append /
4312 // residual handling (and avoid double-projecting on child re-entry).
4313 if (carryoverTerminalized && !wakeOutcome.buffered) {
4314 // Projected above. Never clear a non-empty wakeBuffer. A sticky
4315 // continuationRequested with an empty buffer is safe to drop so
4316 // idle release is not wed on a pin with nothing to deliver.
4317 if (
4318 acpCarryoverTerminalShouldClearContinuation({
4319 continuationOffered: wakeOutcome.continuationOffered,
4320 wakeBufferLength: (yield* Ref.get(wakeBuffer)).length,
4321 })
4322 ) {
4323 yield* Ref.set(continuationRequested, false);
4324 }
4325 return;
4326 }
4327 // Prefer continuation buffering over history append so the same
4328 // frames are not double-counted once a continuation run attaches.
4329 if (wakeOutcome.stopProcessing) {
4330 return;
4331 }
4332 if (
4333 update.sessionUpdate === "user_message_chunk" ||
4334 update.sessionUpdate === "agent_message_chunk" ||
4335 update.sessionUpdate === "agent_thought_chunk"
4336 ) {
4337 // Late monitor end/event reminders must not become ghost user/assistant
4338 // history (or OS-facing chatter) after the root turn already finalized.
4339 const text = acpContentBlockDisplayText(update.content);
4340 if (text === undefined) return;
4341 const lateBackgroundMutations =
4342 update.content.type === "text"
4343 ? (flavor.extractBackgroundToolMutation?.(text) ?? [])
4344 : [];
4345 for (const lateBackgroundMutation of lateBackgroundMutations) {
4346 yield* applyLateBackgroundMutation(notification.sessionId, lateBackgroundMutation);
4347 }
4348 const lateMonitorChatter =
4349 /<monitor-event\b/i.test(text) ||
4350 /Monitor\s+["']?[0-9a-f-]{8,}["']?\s+ended/i.test(text);
4351 if (
4352 text.trim().length > 0 &&
4353 lateBackgroundMutations.length === 0 &&
4354 !lateMonitorChatter
4355 ) {
4356 yield* appendLoadedHistory(
4357 notification,
4358 update.sessionUpdate === "user_message_chunk"
4359 ? "user"
4360 : update.sessionUpdate === "agent_thought_chunk"
4361 ? "thought"
4362 : "assistant",
4363 text,
4364 );
4365 }
4366 } else if (
4367 update.sessionUpdate === "user_message" ||
4368 update.sessionUpdate === "agent_message" ||
4369 update.sessionUpdate === "agent_thought"
4370 ) {
4371 if (update.content === undefined) return;
4372 const text = (update.content ?? [])
4373 .flatMap((content) => {
4374 const display = acpContentBlockDisplayText(content);
4375 return display === undefined ? [] : [display];
4376 })
4377 .join("\n");
4378 yield* appendLoadedHistory(
4379 notification,
4380 update.sessionUpdate === "user_message"
4381 ? "user"
4382 : update.sessionUpdate === "agent_thought"
4383 ? "thought"
4384 : "assistant",
4385 text,
4386 true,
4387 );
4388 } else if (
4389 update.sessionUpdate === "tool_call" ||
4390 update.sessionUpdate === "tool_call_update" ||
4391 update.sessionUpdate === "plan" ||
4392 update.sessionUpdate === "plan_update" ||
4393 update.sessionUpdate === "plan_removed"
4394 ) {
4395 yield* Ref.update(snapshot, (current) => ({
4396 ...current,
4397 loadingRole: null,
4398 loadingMessageId: null,
4399 }));
4400 }
4401 return;
4402 }
4403 if (context.finalized) return;
4404 const rootSessionId = yield* Ref.get(activeSessionId);
4405 if (notification.sessionId !== rootSessionId) {
4406 // Finalize may have completed during the activeSessionId yield.
4407 if (context.finalized) return;
4408 if (flavor.extractSubagentUpdate === undefined) return;
4409 const subagent = context.subagentsBySessionId.get(notification.sessionId);
4410 if (
4411 update.sessionUpdate === "tool_call" ||
4412 update.sessionUpdate === "tool_call_update"
4413 ) {
4414 if (subagent === undefined) return;
4415 const nativeTaskId =
4416 subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id);
4417 for (const event of parseSessionUpdateEvent(notification).events) {
4418 if (event._tag !== "ToolCallUpdated") continue;
4419 const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall;
4420 const subagentUpdate = flavor.extractSubagentUpdate(toolCall);
4421 if (subagentUpdate !== undefined) {
4422 if (
4423 subagentUpdate.nativeTaskId === nativeTaskId ||
4424 subagentUpdate.childSessionId === notification.sessionId
4425 ) {
4426 yield* emitSubagent(context, subagentUpdate);
4427 }
4428 continue;
4429 }
4430 const key = `${nativeTaskId}:tool:${toolCall.toolCallId}`;
4431 const previous = context.tools.get(key);
4432 const merged = mergeToolCallState(previous, toolCall);
4433 context.tools.set(key, merged);
4434 if (!shouldPersistToolUpdate(context, key, previous, merged, merged.status))
4435 continue;
4436 // Terminals are remembered under the raw session id: the child's
4437 // own session, or the root one when the flavor routes child
4438 // updates out of it (Devin).
4439 const mcpIdentity = extractMcpToolCallIdentity(merged, {
4440 embeddedTerminalCommands: [
4441 ...embeddedTerminalCommands(notification.sessionId, toolCall.toolCallId),
4442 ...(rootSessionId === null
4443 ? []
4444 : embeddedTerminalCommands(rootSessionId, toolCall.toolCallId)),
4445 ],
4446 });
4447 const now = yield* DateTime.now;
4448 const status = toolStatus(merged.status);
4449 const startedAt = context.toolStartedAt.get(key) ?? now;
4450 context.toolStartedAt.set(key, startedAt);
4451 const ordinal = resolveSubagentChildOrdinal(subagent, key);
4452 yield* emitProviderEvent({
4453 type: "turn_item.updated",
4454 driver,
4455 turnItem: {
4456 id: providerTurnItemId(key),
4457 threadId: subagent.childThreadId,
4458 runId: null,
4459 nodeId: subagent.childRootNodeId,
4460 providerThreadId: subagent.task.providerThreadId,
4461 providerTurnId: null,
4462 nativeItemRef: { driver, nativeId: key, strength: "strong" },
4463 parentItemId: null,
4464 ordinal,
4465 status,
4466 title: merged.title ?? merged.kind ?? "Tool",
4467 startedAt,
4468 completedAt: completedAtForStatus(status, now),
4469 updatedAt: now,
4470 type: "dynamic_tool",
4471 ...(mcpIdentity === undefined
4472 ? {}
4473 : mcpToolPresentation({
4474 serverName: mcpIdentity.server,
4475 toolName: mcpIdentity.tool,
4476 source: unknownRecord(
4477 (
4478 unknownRecord(unknownRecord(merged.data.rawOutput)?.result) ??
4479 unknownRecord(merged.data.rawOutput)
4480 )?._meta,
4481 )?.source,
4482 })),
4483 toolName:
4484 mcpIdentity === undefined
4485 ? (merged.title ?? merged.kind ?? "Tool")
4486 : `${mcpIdentity.server}.${mcpIdentity.tool}`,
4487 input: merged.data.rawInput ?? null,
4488 output: merged.data.rawOutput ?? merged.data.content ?? null,
4489 },
4490 });
4491 }
4492 return;
4493 }
4494 const isDisplayableAssistantUpdate =
4495 (update.sessionUpdate === "agent_message_chunk" &&
4496 acpContentBlockDisplayText(update.content) !== undefined) ||
4497 (update.sessionUpdate === "agent_message" && update.content !== undefined);
4498 if (!isDisplayableAssistantUpdate) {
4499 return;
4500 }
4501 if (subagent !== undefined) {
4502 yield* projectSubagentNotification(subagent, notification);
4503 return;
4504 }
4505 const buffered = context.pendingSubagentNotifications.get(notification.sessionId) ?? [];
4506 buffered.push(notification);
4507 context.pendingSubagentNotifications.set(notification.sessionId, buffered);
4508 return;
4509 }
4510 // Re-check after the activeSessionId yield: idle/prompt settle can
4511 // finalize the same context object while we waited.
4512 if (context.finalized) return;
4513 // The agent started its own turn about finished background work while
4514 // this settled root turn was still held for it. That turn is the
4515 // agent's reply, not more of the root: settle the root first so the
4516 // frame reaches the wake path and opens a continuation run.
4517 if (
4518 context.promptSettled &&
4519 !acpIsProviderContinuationMessage(context.input.message) &&
4520 flavor.isProviderWakeNotification?.(notification) === true
4521 ) {
4522 if (hasDeferredBackgroundWork(context)) {
4523 yield* Ref.update(wakeBuffer, (current) => [...current, notification]);
4524 return;
4525 }
4526 yield* finalizeTurn(context, context.promptSettledStatus ?? "completed");
4527 const wake = yield* bufferPostSettleWake(notification);
4528 if (wake.offerContinuation) {
4529 yield* offerContinuationRun(notification.sessionId);
4530 }
4531 return;
4532 }
4533 // Only fresh model output proves a retry recovered; progress on
4534 // tools and plans that started earlier can arrive mid-retry.
4535 if (
4536 update.sessionUpdate !== "tool_call_update" &&
4537 update.sessionUpdate !== "plan" &&
4538 update.sessionUpdate !== "plan_update" &&
4539 update.sessionUpdate !== "plan_removed" &&
4540 acpRootSessionUpdateIngestsOutput(notification)
4541 ) {
4542 yield* emitProviderRetry(context, "completed");
4543 }
4544 switch (update.sessionUpdate) {
4545 case "state_update": {
4546 const toolCallId = `${context.nativeTurnId}:requires-action`;
4547 const existing = context.tools.get(toolCallId);
4548 if (update.state === "requires_action") {
4549 yield* emitTool(context, {
4550 toolCallId,
4551 kind: "other",
4552 title: "Action required",
4553 status: "requiresAction",
4554 data: { state: "requires_action" },
4555 });
4556 } else if (existing?.status === "requiresAction") {
4557 yield* emitTool(context, {
4558 ...existing,
4559 status: "completed",
4560 data: { ...existing.data, state: update.state },
4561 });
4562 }
4563 break;
4564 }
4565 case "agent_message_chunk": {
4566 const text = acpContentBlockDisplayText(update.content);
4567 if (text !== undefined) {
4568 const startsNewAssistantSegment = context.assistant.current === null;
4569 // The injected-turn report is streaming; the normal debounce
4570 // after the last chunk takes over from here. Drop matching
4571 // mid-turn armed ids so finalize does not open a duplicate
4572 // wake after the report already projected into this turn.
4573 if (context.pendingInjectedReport.size > 0) {
4574 const reportedTaskIds = [...context.pendingInjectedReport];
4575 context.pendingInjectedReport.clear();
4576 yield* clearMidTurnUnreportedTaskIds(reportedTaskIds);
4577 } else if (
4578 startsNewAssistantSegment &&
4579 context.promptSettled &&
4580 (yield* Ref.get(midTurnUnreportedCompletedTaskIds)).size > 0
4581 ) {
4582 // Some ACP implementations deliver the injected assistant
4583 // report before the synthetic monitor end notice. Correlation
4584 // arrives with that notice, so remember the unmatched report
4585 // and consume its task id when the notice follows.
4586 context.earlyInjectedReportObserved = true;
4587 }
4588 yield* appendText(context, "assistant", text, update.messageId);
4589 }
4590 break;
4591 }
4592 case "agent_thought_chunk": {
4593 const text = acpContentBlockDisplayText(update.content);
4594 if (text !== undefined) {
4595 yield* appendText(context, "reasoning", text, update.messageId);
4596 }
4597 break;
4598 }
4599 case "user_message":
4600 case "agent_message":
4601 case "agent_thought": {
4602 if (update.content === undefined) break;
4603 const text = (update.content ?? [])
4604 .flatMap((content) => {
4605 const display = acpContentBlockDisplayText(content);
4606 return display === undefined ? [] : [display];
4607 })
4608 .join("\n");
4609 yield* replaceText(
4610 context,
4611 update.sessionUpdate === "user_message"
4612 ? "user"
4613 : update.sessionUpdate === "agent_message"
4614 ? "assistant"
4615 : "reasoning",
4616 text,
4617 update.messageId,
4618 );
4619 break;
4620 }
4621 case "tool_call_content_chunk": {
4622 const text =
4623 update.content.type === "content"
4624 ? acpContentBlockDisplayText(update.content.content)
4625 : update.content.type === "diff"
4626 ? "changes" in update.content
4627 ? update.content.patch?.text
4628 : update.content.newText
4629 : undefined;
4630 if (text !== undefined) {
4631 const previous = context.tools.get(update.toolCallId) ?? {
4632 toolCallId: update.toolCallId,
4633 status: "inProgress" as const,
4634 data: { toolCallId: update.toolCallId },
4635 };
4636 yield* emitTool(context, appendToolOutputText(previous, text));
4637 }
4638 break;
4639 }
4640 case "compaction_summary_chunk": {
4641 const text = acpContentBlockDisplayText(update.content);
4642 if (text !== undefined) {
4643 const toolCallId = `acp-compaction:${update.compactionId}`;
4644 const previous = context.tools.get(toolCallId) ?? {
4645 toolCallId,
4646 kind: "think",
4647 title: "Compact context",
4648 status: "inProgress" as const,
4649 data: { toolCallId },
4650 };
4651 yield* emitTool(context, appendToolOutputText(previous, text));
4652 }
4653 break;
4654 }
4655 case "user_message_chunk": {
4656 if (update.content.type === "text" && flavor.extractBackgroundToolMutation) {
4657 const mutations = flavor.extractBackgroundToolMutation(update.content.text);
4658 const terminalTaskIds = mutations
4659 .filter((mutation) => mutation.status !== "running")
4660 .map((mutation) => mutation.taskId);
4661 const reportArrivedBeforeNotice =
4662 context.earlyInjectedReportObserved && terminalTaskIds.length > 0;
4663 if (reportArrivedBeforeNotice) {
4664 context.earlyInjectedReportObserved = false;
4665 yield* clearMidTurnUnreportedTaskIds(terminalTaskIds);
4666 }
4667 for (const mutation of mutations) {
4668 const toolCallId = context.toolCallIdsByBackgroundTaskId.get(mutation.taskId);
4669 const previous =
4670 toolCallId !== undefined ? context.tools.get(toolCallId) : undefined;
4671 if (previous !== undefined) {
4672 let nextStatus =
4673 mutation.status === "running"
4674 ? ("inProgress" as const)
4675 : mutation.status === "failed"
4676 ? ("failed" as const)
4677 : ("completed" as const);
4678 // End notices typically omit full stdout (no get_command mention
4679 // either). Keep the tool running and hold finalize until
4680 // TaskOutput hydrates, or the safety timer force-completes.
4681 if (nextStatus !== "inProgress") {
4682 yield* markAwaitingBackgroundHydration(context, mutation.taskId);
4683 nextStatus = "inProgress";
4684 }
4685 yield* emitTool(
4686 context,
4687 appendToolOutputText(
4688 { ...previous, status: nextStatus },
4689 mutation.appendOutput,
4690 ),
4691 );
4692 }
4693 // After emitTool: the hydration hold keeps the tool row at
4694 // inProgress past an end notice, but the offer gate must see
4695 // the mutation's semantic status. Also tracks monitors that
4696 // never surfaced a tool_call row at all.
4697 yield* applyBackgroundTaskMutationRunning(mutation);
4698 if (mutation.status !== "running" && !reportArrivedBeforeNotice) {
4699 yield* markPendingInjectedReport(context, mutation.taskId);
4700 }
4701 }
4702 }
4703 if (update.content.type === "text" && flavor.extractSubagentEndNotice) {
4704 const notice = flavor.extractSubagentEndNotice(update.content.text);
4705 const subagent =
4706 notice !== undefined
4707 ? context.subagentsBySessionId.get(notice.childSessionId)
4708 : undefined;
4709 if (
4710 notice !== undefined &&
4711 subagent !== undefined &&
4712 (subagent.task.status === "running" || subagent.task.status === "pending")
4713 ) {
4714 // The agent is free to answer with text only and never call
4715 // get_command_or_subagent_output; without this, the subagent
4716 // row stays running and holds deferred finalize open forever.
4717 yield* emitSubagent(context, {
4718 nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? notice.childSessionId,
4719 prompt: subagent.task.prompt,
4720 title: subagent.task.title,
4721 model: subagent.task.model,
4722 status: notice.status,
4723 childSessionId: notice.childSessionId,
4724 result: null,
4725 suppressNormalTool: true,
4726 });
4727 }
4728 }
4729 // startTurn already projects the submitted prompt. Active ACP
4730 // user chunks are transport echoes or injected background
4731 // notices, so only their semantic mutations belong in the turn.
4732 break;
4733 }
4734 default: {
4735 const parsed = parseSessionUpdateEvent(notification);
4736 for (const event of parsed.events) {
4737 if (event._tag === "ToolCallUpdated") {
4738 yield* emitTool(context, event.toolCall);
4739 } else if (event._tag === "PlanUpdated") {
4740 yield* emitPlan(context, event.payload);
4741 } else if (event._tag === "UnknownUpdate") {
4742 yield* emitTool(context, {
4743 toolCallId: `${context.nativeTurnId}:unsupported:${event.updateType}`,
4744 kind: "other",
4745 title: `Unsupported ACP update: ${event.updateType}`,
4746 status: "completed",
4747 data: { updateType: event.updateType },
4748 });
4749 }
4750 }
4751 }
4752 }
4753 // Keep deferred finalize quiet-window fresh while wake traffic lands.
4754 yield* rearmDeferredFinalize(context);
4755 });
4756
4757 const activeContext = Effect.gen(function* () {
4758 const context = yield* Ref.get(activeTurn);
4759 if (context === null) {
4760 return yield* new EffectAcpErrors.AcpTransportError({
4761 detail: "ACP agent requested input without an active turn",
4762 cause: "No active ACP turn",
4763 });
4764 }
4765 return context;
4766 });
4767
4768 const beginApprovalRequest = Effect.fnUntraced(function* (
4769 context: ActiveAcpTurn,
4770 params: EffectAcpSchema.RequestPermissionRequest,
4771 generation: number,
4772 transportRequestId: string,
4773 ) {
4774 yield* closeTextStreams(context);
4775 const parsed = parsePermissionRequest(params);
4776 const approvalOptions = flavor.approvalOptions?.(params);
4777 const nativeRequestId = params.toolCall.toolCallId;
4778 const requestId = yield* idAllocator.allocate.runtimeRequest({
4779 driver,
4780 providerTurnId: context.providerTurnId,
4781 nativeRequestId,
4782 });
4783 const decision = yield* Deferred.make<ProviderApprovalDecision>();
4784 const nativeResponseAcknowledgement = yield* Deferred.make<
4785 void,
4786 EffectAcpErrors.AcpError
4787 >();
4788 yield* registerNativeResponseAcknowledgement(
4789 generation,
4790 transportRequestId,
4791 nativeResponseAcknowledgement,
4792 );
4793 const now = yield* DateTime.now;
4794 const nodeId = idAllocator.derive.approvalNode({ requestId });
4795 const requestKind = providerRequestKind(parsed.kind);
4796 const nativeItemRef = { driver, nativeId: nativeRequestId, strength: "weak" as const };
4797 const ordinal = yield* resolveItemOrdinal(
4798 context,
4799 `${context.nativeTurnId}:approval:${nativeRequestId}`,
4800 );
4801 const runtimeRequest: OrchestrationV2RuntimeRequest = {
4802 id: requestId,
4803 nodeId,
4804 providerTurnId: context.providerTurnId,
4805 nativeRequestRef: nativeItemRef,
4806 kind: requestKind,
4807 status: "pending",
4808 responseCapability: {
4809 type: "live",
4810 providerSessionId: input.providerSessionId,
4811 },
4812 createdAt: now,
4813 resolvedAt: null,
4814 };
4815 const node: OrchestrationV2ExecutionNode = {
4816 id: nodeId,
4817 threadId: context.input.threadId,
4818 runId: context.input.runId,
4819 parentNodeId: context.input.rootNodeId,
4820 rootNodeId: context.input.rootNodeId,
4821 kind: "approval_request",
4822 status: "waiting",
4823 countsForRun: false,
4824 providerThreadId: context.input.providerThread.id,
4825 providerTurnId: context.providerTurnId,
4826 nativeItemRef,
4827 runtimeRequestId: requestId,
4828 checkpointScopeId: null,
4829 startedAt: now,
4830 completedAt: null,
4831 };
4832 const turnItem: OrchestrationV2TurnItem = {
4833 id: idAllocator.derive.approvalTurnItem({ requestId }),
4834 threadId: context.input.threadId,
4835 runId: context.input.runId,
4836 nodeId,
4837 providerThreadId: context.input.providerThread.id,
4838 providerTurnId: context.providerTurnId,
4839 nativeItemRef,
4840 parentItemId: null,
4841 ordinal,
4842 status: "waiting",
4843 title: null,
4844 startedAt: now,
4845 completedAt: null,
4846 updatedAt: now,
4847 type: "approval_request",
4848 requestId,
4849 requestKind,
4850 ...(parsed.detail === undefined ? {} : { prompt: parsed.detail }),
4851 ...(approvalOptions === undefined ? {} : { options: approvalOptions }),
4852 };
4853 yield* Ref.update(pendingRuntimeRequests, (current) => {
4854 const updated = new Map(current);
4855 updated.set(String(requestId), {
4856 type: "approval",
4857 generation,
4858 nativeResponseAcknowledgement,
4859 requestId,
4860 transportRequestId,
4861 decision,
4862 runtimeRequest,
4863 node,
4864 turnItem,
4865 });
4866 return updated;
4867 });
4868 yield* emitProviderEvent({
4869 type: "node.updated",
4870 driver,
4871 node,
4872 });
4873 yield* emitProviderEvent({
4874 type: "runtime_request.updated",
4875 driver,
4876 threadId: context.input.threadId,
4877 runtimeRequest,
4878 });
4879 yield* emitProviderEvent({
4880 type: "turn_item.updated",
4881 driver,
4882 turnItem,
4883 });
4884 return {
4885 context,
4886 decision,
4887 nativeResponseAcknowledgement,
4888 requestId,
4889 transportRequestId,
4890 } as const;
4891 });
4892
4893 const beginUserInputRequest = Effect.fnUntraced(function* (
4894 request: AcpAdapterV2UserInputRequest,
4895 generation: number,
4896 transportRequestId: string,
4897 ) {
4898 const context = yield* activeContext;
4899 yield* closeTextStreams(context);
4900 const requestId = yield* idAllocator.allocate.runtimeRequest({
4901 driver,
4902 providerTurnId: context.providerTurnId,
4903 nativeRequestId: request.nativeRequestId,
4904 });
4905 const answers = yield* Deferred.make<ProviderUserInputAnswers | null>();
4906 const nativeResponseAcknowledgement = yield* Deferred.make<
4907 void,
4908 EffectAcpErrors.AcpError
4909 >();
4910 yield* registerNativeResponseAcknowledgement(
4911 generation,
4912 transportRequestId,
4913 nativeResponseAcknowledgement,
4914 );
4915 const now = yield* DateTime.now;
4916 const nodeId = providerNodeId(request.nativeItemId);
4917 const turnItemId = providerTurnItemId(request.nativeItemId);
4918 const nativeItemRef = {
4919 driver,
4920 nativeId: request.nativeItemId,
4921 strength: "weak" as const,
4922 };
4923 const ordinal = yield* resolveItemOrdinal(context, request.nativeItemId);
4924 const runtimeRequest: OrchestrationV2RuntimeRequest = {
4925 id: requestId,
4926 nodeId,
4927 providerTurnId: context.providerTurnId,
4928 nativeRequestRef: {
4929 driver,
4930 nativeId: request.nativeRequestId,
4931 strength: "weak",
4932 },
4933 kind: "user_input",
4934 status: "pending",
4935 responseCapability: {
4936 type: "live",
4937 providerSessionId: input.providerSessionId,
4938 },
4939 createdAt: now,
4940 resolvedAt: null,
4941 };
4942 const node: OrchestrationV2ExecutionNode = {
4943 id: nodeId,
4944 threadId: context.input.threadId,
4945 runId: context.input.runId,
4946 parentNodeId: context.input.rootNodeId,
4947 rootNodeId: context.input.rootNodeId,
4948 kind: "user_input_request",
4949 status: "waiting",
4950 countsForRun: false,
4951 providerThreadId: context.input.providerThread.id,
4952 providerTurnId: context.providerTurnId,
4953 nativeItemRef,
4954 runtimeRequestId: requestId,
4955 checkpointScopeId: null,
4956 startedAt: now,
4957 completedAt: null,
4958 };
4959 const turnItem: OrchestrationV2TurnItem = {
4960 id: turnItemId,
4961 threadId: context.input.threadId,
4962 runId: context.input.runId,
4963 nodeId,
4964 providerThreadId: context.input.providerThread.id,
4965 providerTurnId: context.providerTurnId,
4966 nativeItemRef,
4967 parentItemId: null,
4968 ordinal,
4969 status: "waiting",
4970 title: null,
4971 startedAt: now,
4972 completedAt: null,
4973 updatedAt: now,
4974 type: "user_input_request",
4975 requestId,
4976 questions: [...request.questions],
4977 };
4978 yield* Ref.update(pendingRuntimeRequests, (current) => {
4979 const updated = new Map(current);
4980 updated.set(String(requestId), {
4981 type: "user_input",
4982 generation,
4983 nativeResponseAcknowledgement,
4984 requestId,
4985 transportRequestId,
4986 answers,
4987 runtimeRequest,
4988 node,
4989 turnItem,
4990 });
4991 return updated;
4992 });
4993 yield* emitProviderEvent({
4994 type: "node.updated",
4995 driver,
4996 node,
4997 });
4998 yield* emitProviderEvent({
4999 type: "runtime_request.updated",
5000 driver,
5001 threadId: context.input.threadId,
5002 runtimeRequest,
5003 });
5004 yield* emitProviderEvent({
5005 type: "turn_item.updated",
5006 driver,
5007 turnItem,
5008 });
5009 return {
5010 answers,
5011 context,
5012 nativeResponseAcknowledgement,
5013 requestId,
5014 transportRequestId,
5015 } as const;
5016 });
5017
5018 const requestUserInputWithAdmission = (
5019 generation: number,
5020 request: Effect.Effect<AcpAdapterV2UserInputRequest>,
5021 transportRequestId: string,
5022 ) =>
5023 runRuntimeCallbackAtGeneration(
5024 generation,
5025 request.pipe(
5026 Effect.flatMap((value) =>
5027 beginUserInputRequest(value, generation, transportRequestId),
5028 ),
5029 ),
5030 ).pipe(
5031 Effect.flatMap((pending) => {
5032 if (Option.isNone(pending)) return Effect.never;
5033 const { answers, requestId, transportRequestId } = pending.value;
5034 return Deferred.await(answers).pipe(
5035 Effect.flatMap((result) =>
5036 runRuntimeCallbackAtGeneration(generation, Effect.succeed(result)).pipe(
5037 Effect.flatMap((checked) =>
5038 Option.isSome(checked)
5039 ? Effect.succeed({
5040 acknowledgeNativeResponse: acknowledgeNativeResponse(
5041 generation,
5042 transportRequestId,
5043 ),
5044 answers: checked.value,
5045 })
5046 : Effect.never,
5047 ),
5048 ),
5049 ),
5050 Effect.ensuring(
5051 runRuntimeCallbackAtGeneration(
5052 generation,
5053 Effect.gen(function* () {
5054 yield* Ref.update(pendingRuntimeRequests, (current) => {
5055 const updated = new Map(current);
5056 updated.delete(String(requestId));
5057 return updated;
5058 });
5059 }),
5060 ).pipe(Effect.asVoid),
5061 ),
5062 );
5063 }),
5064 Effect.mapError(
5065 (cause) =>
5066 new EffectAcpErrors.AcpTransportError({
5067 detail: "Failed to handle ACP user input request",
5068 cause,
5069 }),
5070 ),
5071 );
5072
5073 const cancelPendingRuntimeRequests = Effect.fnUntraced(function* () {
5074 const requests = yield* Ref.modify(pendingRuntimeRequests, (current) => [
5075 [...current.values()],
5076 new Map<string, PendingRuntimeRequest>(),
5077 ]);
5078 if (requests.length === 0) return;
5079
5080 const now = yield* DateTime.now;
5081 yield* Effect.forEach(
5082 requests,
5083 (request) =>
5084 Effect.gen(function* () {
5085 const cancelled = yield* request.type === "approval"
5086 ? Deferred.succeed(request.decision, "cancel")
5087 : Deferred.succeed(request.answers, null);
5088 if (!cancelled) return;
5089
5090 yield* emitProviderEvent({
5091 type: "runtime_request.updated",
5092 driver,
5093 threadId: request.node.threadId,
5094 runtimeRequest: {
5095 ...request.runtimeRequest,
5096 status: "cancelled",
5097 resolvedAt: now,
5098 },
5099 });
5100 yield* emitProviderEvent({
5101 type: "node.updated",
5102 driver,
5103 node: {
5104 ...request.node,
5105 status: "cancelled",
5106 completedAt: now,
5107 },
5108 });
5109 yield* emitProviderEvent({
5110 type: "turn_item.updated",
5111 driver,
5112 turnItem: {
5113 ...request.turnItem,
5114 status: "cancelled",
5115 completedAt: now,
5116 updatedAt: now,
5117 },
5118 });
5119 }),
5120 { concurrency: 1, discard: true },
5121 );
5122 });
5123
5124 /**
5125 * Mutate a carryover subagent's in-memory task status without projecting.
5126 * Used after a non-completed root whose original run subscriber is no
5127 * longer guaranteed to ingest the terminal event.
5128 */
5129 const mutateCarryoverSubagentStatus = Effect.fnUntraced(function* (
5130 subagent: ActiveAcpSubagent,
5131 status: OrchestrationV2Subagent["status"],
5132 resultOverride?: string | null,
5133 ) {
5134 const now = yield* DateTime.now;
5135 const result =
5136 resultOverride !== undefined && resultOverride !== null && resultOverride.length > 0
5137 ? resultOverride
5138 : (subagent.task.result ?? (subagent.assistantText || null));
5139 const completedAt = acpSubagentStatusIsTerminal(status) ? now : null;
5140 subagent.task = {
5141 ...subagent.task,
5142 status,
5143 result,
5144 completedAt,
5145 updatedAt: now,
5146 };
5147 });
5148
5149 /**
5150 * Project a carryover subagent status change without an ActiveAcpTurn.
5151 * Shared by completed-root post-settle completion, deferred attach, and
5152 * Direct Stop terminalization.
5153 */
5154 const projectCarryoverSubagentStatus = Effect.fnUntraced(function* (
5155 subagent: ActiveAcpSubagent,
5156 status: OrchestrationV2Subagent["status"],
5157 resultOverride?: string | null,
5158 ) {
5159 yield* mutateCarryoverSubagentStatus(subagent, status, resultOverride);
5160 const now = subagent.task.updatedAt;
5161 const nativeTaskId = subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id;
5162 const nativeItemRef = {
5163 driver,
5164 nativeId: nativeTaskId,
5165 strength: "strong" as const,
5166 };
5167 const parentProviderThreadId = subagent.parentProviderThreadId;
5168 const result = subagent.task.result;
5169 const completedAt = subagent.task.completedAt;
5170 yield* emitProviderEvent({
5171 type: "node.updated",
5172 driver,
5173 node: {
5174 id: subagent.task.id,
5175 threadId: subagent.task.threadId,
5176 runId: subagent.task.runId,
5177 parentNodeId: subagent.task.parentNodeId,
5178 rootNodeId: subagent.task.parentNodeId,
5179 kind: "subagent",
5180 status,
5181 countsForRun: false,
5182 providerThreadId: parentProviderThreadId,
5183 providerTurnId: subagent.providerTurnId,
5184 nativeItemRef,
5185 runtimeRequestId: null,
5186 checkpointScopeId: null,
5187 startedAt: subagent.task.startedAt,
5188 completedAt,
5189 },
5190 });
5191 yield* emitProviderEvent({
5192 type: "node.updated",
5193 driver,
5194 node: {
5195 id: subagent.childRootNodeId,
5196 threadId: subagent.childThreadId,
5197 runId: null,
5198 parentNodeId: null,
5199 rootNodeId: subagent.childRootNodeId,
5200 kind: "root_turn",
5201 status,
5202 countsForRun: false,
5203 providerThreadId: subagent.task.providerThreadId,
5204 providerTurnId: null,
5205 nativeItemRef,
5206 runtimeRequestId: null,
5207 checkpointScopeId: null,
5208 startedAt: subagent.task.startedAt,
5209 completedAt,
5210 },
5211 });
5212 yield* emitProviderEvent({
5213 type: "subagent.updated",
5214 driver,
5215 subagent: subagent.task,
5216 });
5217 yield* emitProviderEvent({
5218 type: "turn_item.updated",
5219 driver,
5220 turnItem: {
5221 id: subagent.turnItemId,
5222 threadId: subagent.task.threadId,
5223 runId: subagent.task.runId,
5224 nodeId: subagent.task.id,
5225 providerThreadId: parentProviderThreadId,
5226 providerTurnId: subagent.providerTurnId,
5227 nativeItemRef,
5228 parentItemId: null,
5229 ordinal: subagent.turnItemOrdinal,
5230 status,
5231 title: subagent.task.title,
5232 startedAt: subagent.task.startedAt,
5233 completedAt,
5234 updatedAt: now,
5235 type: "subagent",
5236 subagentId: subagent.task.id,
5237 origin: "provider_native",
5238 driver,
5239 providerInstanceId: subagent.task.providerInstanceId,
5240 childThreadId: subagent.childThreadId,
5241 prompt: subagent.task.prompt,
5242 result,
5243 },
5244 });
5245 if (acpSubagentStatusIsTerminal(status)) {
5246 subagent.terminalStatusProjected = true;
5247 }
5248 });
5249
5250 /**
5251 * Update a carryover subagent matched by native task id or child session
5252 * id. Post-settle completions must flip the pin in hasPendingBackgroundWork
5253 * without waiting for a new user turn to consume carryover.
5254 * When `project` is false, only the in-memory carryover status advances
5255 * (the next attach projects the terminal state).
5256 * If status already matches but projection was deferred, a later
5257 * project:true caller still projects once via terminalStatusProjected.
5258 */
5259 const updateCarryoverSubagentStatus = Effect.fnUntraced(function* (
5260 nativeIdOrChildSessionId: string,
5261 status: OrchestrationV2Subagent["status"],
5262 result?: string | null,
5263 options?: { readonly project?: boolean },
5264 ) {
5265 const carryover = yield* Ref.get(carryoverSubagents);
5266 if (carryover === null) return false;
5267 const match = carryover.subagents.find((subagent) => {
5268 const nativeId = subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id);
5269 return (
5270 nativeId === nativeIdOrChildSessionId ||
5271 subagent.childSessionId === nativeIdOrChildSessionId
5272 );
5273 });
5274 if (match === undefined) return false;
5275 const shouldProject = options?.project !== false;
5276 if (match.task.status === status) {
5277 // Already at this status: only project if requested and not yet done.
5278 if (!shouldProject || match.terminalStatusProjected) {
5279 return true;
5280 }
5281 yield* projectCarryoverSubagentStatus(match, status, result);
5282 return true;
5283 }
5284 // Only advance nonterminal entries; do not resurrect a terminal one.
5285 if (match.task.status !== "running" && match.task.status !== "pending") {
5286 return false;
5287 }
5288 if (!shouldProject) {
5289 yield* mutateCarryoverSubagentStatus(match, status, result);
5290 } else {
5291 yield* projectCarryoverSubagentStatus(match, status, result);
5292 }
5293 return true;
5294 });
5295
5296 const finishSubagentFromNotice = Effect.fnUntraced(function* (notice: {
5297 readonly childSessionId: string;
5298 readonly status: "completed" | "failed" | "cancelled";
5299 readonly result: string | null;
5300 }) {
5301 const context = yield* Ref.get(activeTurn);
5302 const subagent =
5303 context === null ? undefined : context.subagentsBySessionId.get(notice.childSessionId);
5304 const known =
5305 subagent ??
5306 (yield* Ref.get(carryoverSubagents))?.subagents.find(
5307 (candidate) =>
5308 candidate.childSessionId === notice.childSessionId ||
5309 candidate.task.nativeTaskRef?.nativeId === notice.childSessionId,
5310 );
5311 if (known !== undefined && acpSubagentStatusBlocksTurnSettlement(known.task.status)) {
5312 yield* recordWakeReport(notice.childSessionId, {
5313 kind: "subagent",
5314 label: known.task.title ?? known.task.prompt,
5315 outcome: notice.status,
5316 childThreadId: known.childThreadId,
5317 });
5318 }
5319 if (context !== null && subagent !== undefined && !context.finalized) {
5320 if (!acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) return;
5321 yield* emitSubagent(context, {
5322 nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? notice.childSessionId,
5323 prompt: subagent.task.prompt,
5324 title: subagent.task.title,
5325 model: subagent.task.model,
5326 status: notice.status,
5327 childSessionId: notice.childSessionId,
5328 result: notice.result,
5329 suppressNormalTool: true,
5330 });
5331 yield* rearmDeferredFinalize(context);
5332 return;
5333 }
5334 // The root turn already settled: the subagent is carryover. Project
5335 // its end while the completed root still owns the run.
5336 const carryover = yield* Ref.get(carryoverSubagents);
5337 yield* updateCarryoverSubagentStatus(
5338 notice.childSessionId,
5339 notice.status,
5340 notice.result,
5341 {
5342 project:
5343 carryover !== null &&
5344 carryover.sessionId === (yield* Ref.get(activeSessionId)) &&
5345 carryover.rootTerminalStatus === "completed",
5346 },
5347 );
5348 });
5349
5350 applyFinalizedActiveTurnSubagentTerminal = Effect.fnUntraced(function* (
5351 context: ActiveAcpTurn,
5352 notification: EffectAcpSchema.SessionNotification,
5353 ) {
5354 if (flavor.extractSubagentUpdate === undefined) return false;
5355
5356 const applyTerminal = Effect.fnUntraced(function* (
5357 subagent: ActiveAcpSubagent,
5358 update: AcpAdapterV2SubagentUpdate,
5359 ) {
5360 if (!acpSubagentStatusIsTerminal(update.status)) return false;
5361 if (acpSubagentStatusIsTerminal(subagent.task.status)) {
5362 if (subagent.terminalStatusProjected || context.finalizedStatus !== "completed") {
5363 return true;
5364 }
5365 }
5366 if (context.finalizedStatus === "completed") {
5367 yield* emitSubagent(context, update);
5368 } else {
5369 yield* mutateCarryoverSubagentStatus(subagent, update.status, update.result);
5370 }
5371 return true;
5372 });
5373
5374 const sessionUpdate = notification.update;
5375 if (
5376 sessionUpdate.sessionUpdate === "tool_call" ||
5377 sessionUpdate.sessionUpdate === "tool_call_update"
5378 ) {
5379 for (const event of parseSessionUpdateEvent(notification).events) {
5380 if (event._tag !== "ToolCallUpdated") continue;
5381 const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall;
5382 const subagentUpdate = flavor.extractSubagentUpdate(toolCall);
5383 if (
5384 subagentUpdate === undefined ||
5385 !acpSubagentStatusIsTerminal(subagentUpdate.status)
5386 ) {
5387 continue;
5388 }
5389 const subagent =
5390 context.subagents.get(subagentUpdate.nativeTaskId) ??
5391 (subagentUpdate.childSessionId === null
5392 ? undefined
5393 : context.subagentsBySessionId.get(subagentUpdate.childSessionId)) ??
5394 context.subagentsBySessionId.get(notification.sessionId);
5395 if (subagent === undefined) continue;
5396 const nativeTaskId =
5397 subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id);
5398 if (
5399 subagentUpdate.nativeTaskId !== nativeTaskId &&
5400 (subagentUpdate.childSessionId === null ||
5401 (subagentUpdate.childSessionId !== subagent.childSessionId &&
5402 subagentUpdate.childSessionId !== notification.sessionId))
5403 ) {
5404 continue;
5405 }
5406 if (yield* applyTerminal(subagent, subagentUpdate)) return true;
5407 }
5408 }
5409
5410 if (
5411 sessionUpdate.sessionUpdate === "user_message_chunk" &&
5412 sessionUpdate.content.type === "text" &&
5413 flavor.extractSubagentEndNotice !== undefined
5414 ) {
5415 const notice = flavor.extractSubagentEndNotice(sessionUpdate.content.text);
5416 const subagent =
5417 notice === undefined
5418 ? undefined
5419 : context.subagentsBySessionId.get(notice.childSessionId);
5420 if (notice !== undefined && subagent !== undefined) {
5421 return yield* applyTerminal(subagent, {
5422 nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? String(subagent.task.id),
5423 prompt: subagent.task.prompt,
5424 title: subagent.task.title,
5425 model: subagent.task.model,
5426 status: notice.status,
5427 childSessionId: notice.childSessionId,
5428 result: null,
5429 suppressNormalTool: true,
5430 });
5431 }
5432 }
5433
5434 return false;
5435 });
5436
5437 const projectDeferredCarryoverTerminals = Effect.fnUntraced(function* (
5438 subagents: ReadonlyArray<ActiveAcpSubagent>,
5439 ) {
5440 for (const subagent of subagents) {
5441 if (
5442 subagent.task.status === "running" ||
5443 subagent.task.status === "pending" ||
5444 subagent.terminalStatusProjected
5445 ) {
5446 continue;
5447 }
5448 yield* projectCarryoverSubagentStatus(
5449 subagent,
5450 subagent.task.status,
5451 subagent.task.result,
5452 );
5453 }
5454 });
5455
5456 /**
5457 * Direct Stop after a soft steer clears carryover without an active turn.
5458 * Emit the same interrupted terminal events terminalizeOpenRunOwnedItems
5459 * would have, context-free (no ActiveAcpTurn).
5460 */
5461 const terminalizeCarryoverSubagents = Effect.fnUntraced(function* (
5462 carryover: AcpCarryoverSubagents | null,
5463 ) {
5464 if (carryover === null) return;
5465 for (const subagent of carryover.subagents) {
5466 if (acpSubagentStatusIsTerminal(subagent.task.status)) {
5467 if (!subagent.terminalStatusProjected) {
5468 yield* projectCarryoverSubagentStatus(
5469 subagent,
5470 subagent.task.status,
5471 subagent.task.result,
5472 );
5473 }
5474 continue;
5475 }
5476 yield* projectCarryoverSubagentStatus(subagent, "interrupted");
5477 }
5478 });
5479
5480 const projectAcpRuntimeSessionUpdateEffect = (
5481 rawNotification: EffectAcpSchema.SessionNotification,
5482 ) =>
5483 Effect.gen(function* () {
5484 if (clientTerminals !== undefined) {
5485 const embedded = embeddedTerminalIdsFromSessionUpdate(rawNotification);
5486 if (embedded !== undefined) {
5487 rememberEmbeddedTerminals({
5488 ...embedded,
5489 sessionId: rawNotification.sessionId,
5490 });
5491 }
5492 }
5493 const notification =
5494 clientTerminals === undefined
5495 ? rawNotification
5496 : resolveEmbeddedTerminalContent(
5497 rawNotification,
5498 clientTerminals.readOutputSnapshot,
5499 );
5500 if (notification.update.sessionUpdate === "available_commands_update") {
5501 yield* (
5502 flavor.onAvailableCommandsUpdate?.(notification.update.availableCommands) ??
5503 Effect.void
5504 );
5505 }
5506 if (
5507 notification.update.sessionUpdate === "config_option_update" ||
5508 notification.update.sessionUpdate === "current_mode_update"
5509 ) {
5510 yield* (
5511 flavor.onSessionConfigurationUpdate?.(
5512 yield* runtime.getConfigOptions,
5513 yield* runtime.getModeState,
5514 ) ?? Effect.void
5515 );
5516 }
5517 yield* handleSessionUpdate(
5518 flavor.normalizeSessionUpdate?.(notification) ?? notification,
5519 );
5520 }).pipe(
5521 Effect.mapError(
5522 (cause) =>
5523 new EffectAcpErrors.AcpTransportError({
5524 detail: "Failed to project an ACP session update",
5525 cause,
5526 }),
5527 ),
5528 );
5529 const projectAcpRuntimeSessionUpdate = (
5530 handlerGeneration: number,
5531 rawNotification: EffectAcpSchema.SessionNotification,
5532 ) =>
5533 runRuntimeCallbackAtGeneration(
5534 handlerGeneration,
5535 projectAcpRuntimeSessionUpdateEffect(rawNotification),
5536 ).pipe(Effect.asVoid);
5537
5538 // Falls back to the latest turn policy when no turn is active so
5539 // post-settle background work stays under the policy it started with.
5540 const clientPolicyContext = Effect.map(Ref.get(activeTurn), (context) => ({
5541 policy: context?.input.runtimePolicy ?? latestRuntimePolicy,
5542 turnKey: context === null ? null : String(context.providerTurnId),
5543 }));
5544
5545 const denyClientRequest = (operation: string, disposition: "ask" | "deny") =>
5546 Effect.logWarning("ACP client policy denied a client-mediated operation", {
5547 operation,
5548 disposition,
5549 }).pipe(
5550 Effect.andThen(
5551 Effect.fail(
5552 EffectAcpErrors.AcpRequestError.internalError(
5553 disposition === "ask"
5554 ? `The active T3 runtime policy requires approval for ${operation}. Request permission with session/request_permission before retrying.`
5555 : `The active T3 runtime policy does not allow ${operation}.`,
5556 ),
5557 ),
5558 ),
5559 );
5560
5561 const guardClientTerminalCreate = clientPolicyContext.pipe(
5562 Effect.flatMap(({ policy, turnKey }) => {
5563 const disposition = acpClientExecuteDisposition(policy);
5564 if (
5565 disposition === "allow" ||
5566 (disposition === "ask" && clientPolicyGrants.allowsExecute(turnKey))
5567 ) {
5568 return Effect.void;
5569 }
5570 return denyClientRequest("terminal/create", disposition);
5571 }),
5572 );
5573
5574 const wireAcpRuntimeTerminalHandlers = Effect.fnUntraced(function* (
5575 targetRuntime: AcpSessionRuntime.AcpSessionRuntime["Service"],
5576 ) {
5577 if (clientTerminals === undefined) return;
5578 yield* targetRuntime.handleCreateTerminal((request) =>
5579 guardClientTerminalCreate.pipe(Effect.andThen(clientTerminals.create(request))),
5580 );
5581 yield* targetRuntime.handleTerminalOutput(clientTerminals.output);
5582 yield* targetRuntime.handleTerminalWaitForExit(clientTerminals.waitForExit);
5583 yield* targetRuntime.handleTerminalKill(clientTerminals.kill);
5584 yield* targetRuntime.handleTerminalRelease(clientTerminals.release);
5585 });
5586
5587 const wireAcpRuntimeMcpHandlers = Effect.fnUntraced(function* (
5588 targetRuntime: AcpSessionRuntime.AcpSessionRuntime["Service"],
5589 mcpBridge: AcpMcpOverAcpBridge | undefined,
5590 ) {
5591 if (mcpBridge === undefined) return;
5592 const mapMcpBridgeError = Effect.mapError(
5593 (error: Error) =>
5594 new EffectAcpErrors.AcpRequestError({
5595 code: -32603,
5596 errorMessage: error.message,
5597 cause: error,
5598 }),
5599 );
5600 yield* targetRuntime.handleMcpConnect((request) =>
5601 mcpBridge.connect(request).pipe(mapMcpBridgeError),
5602 );
5603 yield* targetRuntime.handleMcpMessage((request) =>
5604 mcpBridge.message(request).pipe(mapMcpBridgeError),
5605 );
5606 yield* targetRuntime.handleMcpNotification((request) =>
5607 mcpBridge.notification(request).pipe(mapMcpBridgeError),
5608 );
5609 yield* targetRuntime.handleMcpDisconnect((request) =>
5610 mcpBridge.disconnect(request).pipe(mapMcpBridgeError),
5611 );
5612 });
5613
5614 const wireAcpRuntimeHandlers = Effect.fnUntraced(function* (
5615 targetRuntime: AcpSessionRuntime.AcpSessionRuntime["Service"],
5616 handlerGeneration: number,
5617 handlerOptions: {
5618 readonly sessionUpdates?: boolean;
5619 readonly terminals?: boolean;
5620 readonly mcp?: boolean;
5621 } = {},
5622 ) {
5623 yield* targetRuntime.getEvents().pipe(
5624 Stream.runForEach((event) => {
5625 if (event._tag === "EventStreamBarrier") {
5626 return Deferred.succeed(event.acknowledge, undefined).pipe(Effect.asVoid);
5627 }
5628 return runRuntimeCallbackAtGeneration(
5629 handlerGeneration,
5630 flavor.onSessionEvent?.(event) ?? Effect.void,
5631 ).pipe(Effect.asVoid);
5632 }),
5633 Effect.forkIn(runtimeScope ?? sessionScope),
5634 );
5635 const requestUserInput = (
5636 request: AcpAdapterV2UserInputRequest,
5637 requestContext: EffectAcpProtocol.AcpRequestContext,
5638 ) =>
5639 requestUserInputWithAdmission(
5640 handlerGeneration,
5641 Effect.succeed(request),
5642 requestContext.requestId,
5643 );
5644 // Without the capability no fs handler is registered, so a stray
5645 // request (OpenCode and Kilo send one after approved edits) gets
5646 // method-not-found and cannot touch the disk. A flavor that opts in
5647 // serves requests itself, confined to the workspace of the policy
5648 // active when the request arrives; the agent asks before its edits.
5649 const clientFileSystem = flavor.clientFileSystem;
5650 if (clientFileSystem !== undefined) {
5651 yield* targetRuntime.handleReadTextFile((request) =>
5652 clientPolicyContext.pipe(
5653 Effect.flatMap(({ policy }) => clientFileSystem.readTextFile(request, policy.cwd)),
5654 ),
5655 );
5656 yield* targetRuntime.handleWriteTextFile((request) =>
5657 clientPolicyContext.pipe(
5658 Effect.flatMap(({ policy }) => clientFileSystem.writeTextFile(request, policy.cwd)),
5659 ),
5660 );
5661 }
5662 if (handlerOptions.mcp !== false) {
5663 yield* wireAcpRuntimeMcpHandlers(targetRuntime, runtimeMcpBridge);
5664 }
5665 if (handlerOptions.terminals !== false) {
5666 yield* wireAcpRuntimeTerminalHandlers(targetRuntime);
5667 }
5668 if (handlerOptions.sessionUpdates !== false) {
5669 yield* targetRuntime.handleSessionUpdate((rawNotification) =>
5670 projectAcpRuntimeSessionUpdate(handlerGeneration, rawNotification),
5671 );
5672 }
5673 yield* targetRuntime.handleRequestPermission((params, requestContext) =>
5674 Effect.gen(function* () {
5675 const transportRequestId = requestContext.requestId;
5676 const permissionQuestion = flavor.extractPermissionQuestion?.(params);
5677 if (permissionQuestion !== undefined) {
5678 const userInput = yield* requestUserInputWithAdmission(
5679 handlerGeneration,
5680 Effect.succeed({
5681 nativeItemId: `${params.sessionId}:question:${params.toolCall.toolCallId}`,
5682 nativeMethod: "session/request_permission",
5683 nativeRequestId: params.toolCall.toolCallId,
5684 nativeSessionId: params.sessionId,
5685 questions: [permissionQuestion.question],
5686 }),
5687 transportRequestId,
5688 );
5689 const response =
5690 userInput.answers === null
5691 ? undefined
5692 : permissionQuestion.respond(userInput.answers);
5693 yield* userInput.acknowledgeNativeResponse;
5694 return response ?? ({ outcome: { outcome: "cancelled" } } as const);
5695 }
5696 const admitted = yield* runRuntimeCallbackAtGeneration(
5697 handlerGeneration,
5698 Effect.gen(function* () {
5699 const context = yield* activeContext;
5700 const disposition = (flavor.permissionDisposition ?? acpPermissionDisposition)(
5701 context.input.runtimePolicy,
5702 params,
5703 );
5704 if (disposition === "allow") {
5705 const optionId = selectAutoApprovedPermissionOption(params);
5706 return {
5707 _tag: "Immediate" as const,
5708 response:
5709 optionId === undefined
5710 ? ({ outcome: { outcome: "cancelled" } } as const)
5711 : ({ outcome: { outcome: "selected", optionId } } as const),
5712 };
5713 }
5714 if (disposition === "deny") {
5715 const optionId = selectPermissionOptionId(params, "decline");
5716 return {
5717 _tag: "Immediate" as const,
5718 response:
5719 optionId === undefined
5720 ? ({ outcome: { outcome: "cancelled" } } as const)
5721 : ({ outcome: { outcome: "selected", optionId } } as const),
5722 };
5723 }
5724 return {
5725 _tag: "Pending" as const,
5726 pending: yield* beginApprovalRequest(
5727 context,
5728 params,
5729 handlerGeneration,
5730 transportRequestId,
5731 ),
5732 };
5733 }),
5734 );
5735 if (Option.isNone(admitted)) {
5736 return yield* Effect.never;
5737 }
5738 if (admitted.value._tag === "Immediate") {
5739 const response = admitted.value.response;
5740 const checked = yield* runRuntimeCallbackAtGeneration(
5741 handlerGeneration,
5742 Effect.gen(function* () {
5743 const nativeResponseAcknowledgement = yield* Deferred.make<
5744 void,
5745 EffectAcpErrors.AcpError
5746 >();
5747 yield* registerNativeResponseAcknowledgement(
5748 handlerGeneration,
5749 transportRequestId,
5750 nativeResponseAcknowledgement,
5751 );
5752 return response;
5753 }),
5754 );
5755 return Option.isSome(checked) ? checked.value : yield* Effect.never;
5756 }
5757 const {
5758 context,
5759 decision: pendingDecision,
5760 requestId,
5761 transportRequestId: pendingTransportRequestId,
5762 } = admitted.value.pending;
5763 const parsedPermission = parsePermissionRequest(params);
5764 const decision = yield* Deferred.await(pendingDecision).pipe(
5765 Effect.ensuring(
5766 runRuntimeCallbackAtGeneration(
5767 handlerGeneration,
5768 Effect.gen(function* () {
5769 yield* Ref.update(pendingRuntimeRequests, (current) => {
5770 const updated = new Map(current);
5771 updated.delete(String(requestId));
5772 return updated;
5773 });
5774 }),
5775 ).pipe(Effect.asVoid),
5776 ),
5777 );
5778 if (
5779 parsedPermission.kind !== "unknown" &&
5780 (decision === "accept" || decision === "acceptForSession")
5781 ) {
5782 clientPolicyGrants.recordApproval({
5783 kind: providerRequestKind(parsedPermission.kind),
5784 scope: decision === "acceptForSession" ? "session" : "turn",
5785 turnKey: String(context.providerTurnId),
5786 });
5787 }
5788 const response = (() => {
5789 if (decision === "cancel") {
5790 return { outcome: { outcome: "cancelled" } } as const;
5791 }
5792 const optionId = selectPermissionOptionId(params, decision);
5793 return optionId === undefined
5794 ? ({ outcome: { outcome: "cancelled" } } as const)
5795 : ({ outcome: { outcome: "selected", optionId } } as const);
5796 })();
5797 const checked = yield* runRuntimeCallbackAtGeneration(
5798 handlerGeneration,
5799 Effect.succeed(response),
5800 );
5801 if (Option.isNone(checked)) return yield* Effect.never;
5802 yield* acknowledgeNativeResponse(handlerGeneration, pendingTransportRequestId);
5803 return checked.value;
5804 }).pipe(
5805 Effect.mapError(
5806 (cause) =>
5807 new EffectAcpErrors.AcpTransportError({
5808 detail: "Failed to handle an ACP permission request",
5809 cause,
5810 }),
5811 ),
5812 ),
5813 );
5814 yield* targetRuntime.handleElicitation((params, requestContext) =>
5815 Effect.gen(function* () {
5816 const transportRequestId = requestContext.requestId;
5817 if (
5818 params.mode === "form" &&
5819 (unknownRecord(params._meta)?.codex_approval_kind === "mcp_tool_call" ||
5820 transportRequestId.startsWith("mcp_tool_call_approval_"))
5821 ) {
5822 const mcpApprovalDisposition = yield* runRuntimeCallbackAtGeneration(
5823 handlerGeneration,
5824 Effect.gen(function* () {
5825 const context = yield* activeContext;
5826 const disposition = acpMcpToolApprovalElicitationDisposition(
5827 context.input.runtimePolicy,
5828 params,
5829 transportRequestId,
5830 );
5831 if (disposition === undefined || disposition === "ask") {
5832 return disposition;
5833 }
5834 const nativeResponseAcknowledgement = yield* Deferred.make<
5835 void,
5836 EffectAcpErrors.AcpError
5837 >();
5838 yield* registerNativeResponseAcknowledgement(
5839 handlerGeneration,
5840 transportRequestId,
5841 nativeResponseAcknowledgement,
5842 );
5843 return disposition;
5844 }),
5845 );
5846 if (Option.isNone(mcpApprovalDisposition)) return yield* Effect.never;
5847 if (mcpApprovalDisposition.value === "allow") {
5848 return { action: "accept", content: {} } as const;
5849 }
5850 if (mcpApprovalDisposition.value === "deny") {
5851 return { action: "decline" } as const;
5852 }
5853 }
5854 if (
5855 params.mode === "url" &&
5856 "url" in params &&
5857 typeof params.url === "string" &&
5858 "elicitationId" in params &&
5859 typeof params.elicitationId === "string"
5860 ) {
5861 const { elicitationId, url } = params;
5862 const admitted = yield* runRuntimeCallbackAtGeneration(
5863 handlerGeneration,
5864 Effect.gen(function* () {
5865 const nativeResponseAcknowledgement = yield* Deferred.make<
5866 void,
5867 EffectAcpErrors.AcpError
5868 >();
5869 yield* registerNativeResponseAcknowledgement(
5870 handlerGeneration,
5871 transportRequestId,
5872 nativeResponseAcknowledgement,
5873 );
5874 const accepted = yield* (
5875 flavor.onUrlElicitation?.({
5876 elicitationId,
5877 url,
5878 message: params.message,
5879 }) ?? Effect.succeed(false)
5880 );
5881 return accepted
5882 ? ({ action: "accept" } as const)
5883 : ({ action: "decline" } as const);
5884 }),
5885 );
5886 if (Option.isNone(admitted)) return yield* Effect.never;
5887 return admitted.value;
5888 }
5889 if (params.mode !== "form" || !("requestedSchema" in params)) {
5890 // Future elicitation modes beyond form and url decline rather
5891 // than guessing at their semantics.
5892 return { action: "decline" } as const;
5893 }
5894 const requestedSchema = unknownRecord(params.requestedSchema);
5895 const properties = unknownRecord(requestedSchema?.properties) ?? {};
5896 const elicitationScopeId =
5897 "sessionId" in params ? params.sessionId : `request:${params.requestId}`;
5898 const questions = Object.entries(properties).map(
5899 ([id, property], index): OrchestrationV2UserInputQuestion => {
5900 const record = unknownRecord(property);
5901 const enumValues = Array.isArray(record?.enum)
5902 ? record.enum.filter((value): value is string => typeof value === "string")
5903 : [];
5904 const options =
5905 enumValues.length > 0
5906 ? enumValues.map((value) => ({ label: value, description: value }))
5907 : record?.type === "boolean"
5908 ? [
5909 { label: "true", description: "Yes" },
5910 { label: "false", description: "No" },
5911 ]
5912 : [];
5913 return {
5914 id,
5915 header: nonEmptyText(record?.title, `Question ${index + 1}`),
5916 question: nonEmptyText(record?.description, params.message),
5917 options,
5918 };
5919 },
5920 );
5921 const userInput = yield* requestUserInputWithAdmission(
5922 handlerGeneration,
5923 Effect.gen(function* () {
5924 const ordinal = yield* Ref.getAndUpdate(
5925 nextElicitationOrdinal,
5926 (current) => current + 1,
5927 );
5928 const nativeRequestId = `${elicitationScopeId}:elicitation:${ordinal}`;
5929 return {
5930 nativeItemId: nativeRequestId,
5931 nativeRequestId,
5932 questions,
5933 };
5934 }),
5935 transportRequestId,
5936 );
5937 const response =
5938 userInput.answers === null
5939 ? ({ action: "cancel" } as const)
5940 : ({
5941 action: "accept",
5942 content: elicitationContent(
5943 userInput.answers,
5944 new Set(Object.keys(properties)),
5945 ),
5946 } as const);
5947 yield* userInput.acknowledgeNativeResponse;
5948 return response;
5949 }),
5950 );
5951 if (flavor.registerExtensions !== undefined) {
5952 yield* flavor.registerExtensions({
5953 runtime: targetRuntime,
5954 reportProviderRetry: (notice) =>
5955 runRuntimeCallbackAtGeneration(handlerGeneration, reportProviderRetry(notice)).pipe(
5956 Effect.asVoid,
5957 ),
5958 requestUserInput,
5959 captureProposedPlan,
5960 lastProposedPlanMarkdown,
5961 finishSubagent: (notice) =>
5962 runRuntimeCallbackAtGeneration(
5963 handlerGeneration,
5964 Effect.gen(function* () {
5965 if (yield* Ref.get(stoppedRunQuarantine)) return;
5966 // Root-session notices only; nested subagents report to
5967 // their own parent session.
5968 if ((yield* Ref.get(activeSessionId)) !== notice.sessionId) return;
5969 yield* finishSubagentFromNotice(notice);
5970 }),
5971 ).pipe(Effect.asVoid),
5972 applyBackgroundTaskMutation: (mutation) =>
5973 runRuntimeCallbackAtGeneration(
5974 handlerGeneration,
5975 Effect.gen(function* () {
5976 // Direct Stop quarantine: drop residual task lifecycle from
5977 // the stopped run instead of mutating wake machinery.
5978 if (yield* Ref.get(stoppedRunQuarantine)) return;
5979 // Root-session tasks only: a cancelled subagent's re-run in
5980 // its child session must not gate root wake machinery.
5981 if ((yield* Ref.get(activeSessionId)) !== mutation.sessionId) return;
5982 if (
5983 mutation.status !== "running" &&
5984 mutation.report !== undefined &&
5985 !(yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(mutation.taskId)
5986 ) {
5987 yield* recordWakeReport(mutation.taskId, {
5988 ...mutation.report,
5989 outcome: mutation.status,
5990 });
5991 }
5992 yield* applyLateBackgroundMutation(mutation.sessionId, mutation);
5993 if (mutation.status !== "running") {
5994 yield* finishRegisteredBackgroundTool({
5995 taskId: mutation.taskId,
5996 status: mutation.status,
5997 ...(mutation.output === undefined ? {} : { output: mutation.output }),
5998 });
5999 }
6000 }),
6001 ).pipe(Effect.asVoid),
6002 });
6003 }
6004 });
6005
6006 /** Builds the MCP-over-ACP bridge for one runtime, disposed with that runtime's scope. */
6007 const makeRuntimeMcpBridge = Effect.fnUntraced(function* (
6008 mcpContext: AcpMcpContext,
6009 scope: Scope.Scope,
6010 ) {
6011 if (mcpContext.endpoint === undefined || mcpContext.authorization === undefined) {
6012 return undefined;
6013 }
6014 const mcpBridge = yield* makeAcpMcpOverAcpBridge({
6015 endpoint: mcpContext.endpoint,
6016 authorization: mcpContext.authorization,
6017 allocateConnectionId: crypto.randomUUIDv4().pipe(Effect.orDie),
6018 });
6019 yield* Scope.addFinalizer(scope, mcpBridge.dispose);
6020 return mcpBridge;
6021 });
6022
6023 const spawnAcpRuntime = Effect.fnUntraced(function* (
6024 threadId: ThreadId | null,
6025 resumeSessionId?: string,
6026 ) {
6027 if (runtimeScope !== undefined) {
6028 yield* Scope.close(runtimeScope, Exit.void);
6029 }
6030 runtimeScope = yield* Scope.make();
6031 const mcpContext = yield* readMcpContext(threadId);
6032 runtimeMcpBridge = yield* makeRuntimeMcpBridge(mcpContext, runtimeScope);
6033 const runtimeGeneration = yield* Ref.get(runtimeCallbackGeneration);
6034 runtime = yield* flavor
6035 .makeRuntime(makeRuntimeInput(runtimeGeneration, mcpContext, resumeSessionId))
6036 .pipe(
6037 Effect.provideService(Scope.Scope, runtimeScope),
6038 Effect.provideService(Crypto.Crypto, crypto),
6039 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
6040 Effect.provideService(AgentScope, agentScope),
6041 Effect.provideService(AgentScopeThreadId, threadId ?? undefined),
6042 );
6043 });
6044
6045 const startAcpRuntime = Effect.fnUntraced(function* (
6046 threadId: ThreadId | null,
6047 resumeSessionId?: string,
6048 ) {
6049 const startup = Effect.gen(function* () {
6050 yield* spawnAcpRuntime(threadId, resumeSessionId);
6051 yield* wireAcpRuntimeHandlers(runtime, yield* Ref.get(runtimeCallbackGeneration));
6052 return yield* runtime.start();
6053 });
6054 return yield* flavor.withRuntimeStartup?.(startup) ?? startup;
6055 });
6056
6057 const restartAcpRuntime = Effect.fnUntraced(function* (threadId: ThreadId | null) {
6058 yield* spawnAcpRuntime(threadId);
6059 yield* wireAcpRuntimeHandlers(runtime, yield* Ref.get(runtimeCallbackGeneration));
6060 });
6061
6062 const startReplacementAcpRuntime = Effect.fnUntraced(function* (
6063 threadId: ThreadId | null,
6064 commitSessionState: (
6065 replacement: AcpSessionRuntime.AcpSessionRuntimeStartResult,
6066 ) => Effect.Effect<void>,
6067 ) {
6068 const previousScope = runtimeScope;
6069 const previousGeneration = yield* Ref.get(runtimeCallbackGeneration);
6070 yield* runtimeCallbackPermit.withPermit(awaitAdmittedNativeResponses);
6071
6072 // Keep the original generation active until the candidate has
6073 // completed session startup. A failed candidate therefore cannot
6074 // suppress termination or background callbacks from the live session.
6075 const replacementGeneration = yield* allocateRuntimeCallbackGeneration;
6076 const replacementScope = yield* Scope.make();
6077 type CandidateLifecycle =
6078 | { readonly _tag: "Starting" }
6079 | { readonly _tag: "Terminated"; readonly error: EffectAcpErrors.AcpError }
6080 | { readonly _tag: "Committed" };
6081 const candidateLifecycle = yield* Ref.make<CandidateLifecycle>({ _tag: "Starting" });
6082 const handleCandidateTermination: AcpAdapterV2RuntimeInput["onTermination"] = (error) =>
6083 Ref.modify(
6084 candidateLifecycle,
6085 (current): readonly [Effect.Effect<void>, CandidateLifecycle] => {
6086 if (current._tag === "Committed") {
6087 return [handleRuntimeTerminationAtGeneration(replacementGeneration), current];
6088 }
6089 return [
6090 Effect.void,
6091 current._tag === "Terminated"
6092 ? current
6093 : ({ _tag: "Terminated", error } as const),
6094 ];
6095 },
6096 ).pipe(Effect.flatten);
6097 const stagedSessionUpdates: Array<EffectAcpSchema.SessionNotification> = [];
6098 let handleCandidateSessionUpdate: (
6099 notification: EffectAcpSchema.SessionNotification,
6100 ) => Effect.Effect<void, EffectAcpErrors.AcpError> = (notification) =>
6101 Effect.sync(() => {
6102 stagedSessionUpdates.push(notification);
6103 });
6104 let replacementMcpBridge: AcpMcpOverAcpBridge | undefined;
6105 const startup = Effect.gen(function* () {
6106 const mcpContext = yield* readMcpContext(threadId);
6107 replacementMcpBridge = yield* makeRuntimeMcpBridge(mcpContext, replacementScope);
6108 const replacementRuntime = yield* flavor
6109 .makeRuntime(
6110 makeRuntimeInput(
6111 replacementGeneration,
6112 mcpContext,
6113 undefined,
6114 handleCandidateTermination,
6115 ),
6116 )
6117 .pipe(
6118 Effect.provideService(Scope.Scope, replacementScope),
6119 Effect.provideService(Crypto.Crypto, crypto),
6120 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
6121 Effect.provideService(AgentScope, agentScope),
6122 Effect.provideService(AgentScopeThreadId, threadId ?? undefined),
6123 );
6124 // Session setup may publish commands before it returns. Buffer those
6125 // notifications, but do not expose request or extension handlers
6126 // until the candidate generation has committed.
6127 yield* replacementRuntime.handleSessionUpdate((notification) =>
6128 Effect.suspend(() => handleCandidateSessionUpdate(notification)),
6129 );
6130 yield* wireAcpRuntimeMcpHandlers(replacementRuntime, replacementMcpBridge);
6131 const started = yield* replacementRuntime.start();
6132 return { replacementRuntime, started };
6133 });
6134 const replacementExit = yield* Effect.exit(
6135 (flavor.withRuntimeStartup?.(startup) ?? startup).pipe(
6136 Effect.onInterrupt(() =>
6137 Scope.close(replacementScope, Exit.void).pipe(Effect.ignore),
6138 ),
6139 ),
6140 );
6141 if (Exit.isFailure(replacementExit)) {
6142 yield* runtimeCallbackPermit.withPermit(
6143 quarantineNativeTransportAtGeneration(replacementGeneration),
6144 );
6145 yield* Scope.close(replacementScope, Exit.void).pipe(Effect.ignore);
6146 return yield* Effect.failCause(replacementExit.cause);
6147 }
6148
6149 yield* runtimeCallbackPermit
6150 .withPermit(
6151 Effect.uninterruptible(
6152 Effect.gen(function* () {
6153 yield* awaitAdmittedNativeResponses;
6154 // Session updates and MCP were wired during candidate startup
6155 // (against its own bridge); terminals attach after commit below.
6156 yield* wireAcpRuntimeHandlers(
6157 replacementExit.value.replacementRuntime,
6158 replacementGeneration,
6159 { sessionUpdates: false, terminals: false, mcp: false },
6160 );
6161 const lifecycle = yield* Ref.modify(
6162 candidateLifecycle,
6163 (current): readonly [CandidateLifecycle, CandidateLifecycle] =>
6164 current._tag === "Terminated"
6165 ? [current, current]
6166 : [{ _tag: "Committed" }, { _tag: "Committed" }],
6167 );
6168 if (lifecycle._tag === "Terminated") {
6169 return yield* lifecycle.error;
6170 }
6171 yield* quarantineNativeTransportAtGeneration(previousGeneration);
6172 runtime = replacementExit.value.replacementRuntime;
6173 runtimeScope = replacementScope;
6174 runtimeMcpBridge = replacementMcpBridge;
6175 yield* Ref.set(runtimeCallbackGeneration, replacementGeneration);
6176 prepareTerminalEnvironment(
6177 yield* readMcpContext(threadId),
6178 replacementExit.value.started.sessionId,
6179 );
6180 yield* wireAcpRuntimeTerminalHandlers(replacementExit.value.replacementRuntime);
6181 yield* commitSessionState(replacementExit.value.started);
6182 while (true) {
6183 const buffered = stagedSessionUpdates.splice(0, stagedSessionUpdates.length);
6184 if (buffered.length === 0) {
6185 handleCandidateSessionUpdate = (notification) =>
6186 projectAcpRuntimeSessionUpdate(replacementGeneration, notification);
6187 break;
6188 }
6189 yield* Effect.forEach(
6190 buffered,
6191 (notification) =>
6192 projectAcpRuntimeSessionUpdateEffect(notification).pipe(
6193 Effect.catchCause((cause) =>
6194 Effect.logError("failed to replay staged ACP session update", {
6195 driver,
6196 cause,
6197 }),
6198 ),
6199 ),
6200 { discard: true },
6201 );
6202 }
6203 yield* cancelPendingRuntimeRequests();
6204 if (previousScope !== undefined) {
6205 yield* Scope.close(previousScope, Exit.void).pipe(
6206 Effect.catchCause((cause) =>
6207 Effect.logError("failed to close replaced ACP runtime scope", {
6208 driver,
6209 cause,
6210 }),
6211 ),
6212 );
6213 }
6214 }),
6215 ),
6216 )
6217 .pipe(
6218 Effect.onExit((exit) =>
6219 Exit.isFailure(exit)
6220 ? Ref.get(candidateLifecycle).pipe(
6221 Effect.flatMap((lifecycle) =>
6222 lifecycle._tag === "Committed"
6223 ? Effect.void
6224 : Scope.close(replacementScope, Exit.void).pipe(Effect.ignore),
6225 ),
6226 )
6227 : Effect.void,
6228 ),
6229 );
6230 return replacementExit.value.started;
6231 });
6232
6233 const initialStart = yield* Effect.result(
6234 startAcpRuntime(input.threadId, input.initialNativeThreadId),
6235 );
6236 const started = Result.isSuccess(initialStart)
6237 ? initialStart.success
6238 : yield* Effect.gen(function* () {
6239 const failedMethod =
6240 "method" in initialStart.failure ? initialStart.failure.method : undefined;
6241 if (
6242 input.initialNativeThreadId === undefined ||
6243 (failedMethod !== "session/load" && failedMethod !== "session/resume")
6244 ) {
6245 return yield* initialStart.failure;
6246 }
6247 yield* Ref.set(initialSessionActivationFailure, {
6248 sessionId: input.initialNativeThreadId,
6249 error: initialStart.failure,
6250 });
6251 itemIdentityVersion = 2;
6252 yield* Ref.set(runtimeRestartRequired, false);
6253 prepareClaimableTerminalEnvironment(yield* readMcpContext(input.threadId));
6254 return yield* startAcpRuntime(input.threadId);
6255 });
6256 yield* Ref.set(activeSessionId, started.sessionId);
6257 yield* Ref.set(activeSessionSetup, started);
6258 rememberTerminalEnvironment(started.sessionId, yield* readMcpContext(input.threadId));
6259 const capabilities = negotiatedCapabilities(flavor.capabilities, started);
6260 const canLoadSession = started.initializeResult.agentCapabilities?.loadSession === true;
6261 const canResumeSession =
6262 started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null;
6263 const supportsImagePrompts = acpSupportsImagePrompts({
6264 flavorSupportsImagePrompts: flavor.supportsImagePrompts,
6265 negotiatedImage:
6266 started.initializeResult.agentCapabilities?.promptCapabilities?.image === true,
6267 });
6268
6269 const activateSession = Effect.fnUntraced(function* (
6270 sessionId: string,
6271 threadId: ThreadId | null,
6272 ) {
6273 const initialFailure = yield* Ref.modify(initialSessionActivationFailure, (failure) =>
6274 failure?.sessionId === sessionId ? [failure.error, null] : [undefined, failure],
6275 );
6276 if (initialFailure !== undefined) {
6277 return yield* initialFailure;
6278 }
6279 const mcpContext = yield* readMcpContext(threadId);
6280 const activationOptions = acpMcpActivation(mcpContext);
6281 prepareTerminalEnvironment(mcpContext, sessionId);
6282 const activated = canLoadSession
6283 ? yield* runtime.loadSession(sessionId, activationOptions)
6284 : canResumeSession
6285 ? yield* runtime.resumeSession(sessionId, activationOptions)
6286 : yield* new ProviderAdapter.ProviderAdapterProtocolError({
6287 driver,
6288 detail: `ACP driver cannot load or resume session ${sessionId}`,
6289 });
6290 rememberTerminalEnvironment(activated.sessionId, yield* readMcpContext(threadId));
6291 return activated;
6292 });
6293
6294 const configureSession = Effect.fnUntraced(function* (
6295 startResult: AcpSessionRuntime.AcpSessionRuntimeStartResult,
6296 modelSelection: ModelSelection,
6297 runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy,
6298 ) {
6299 // A step failing below leaves the native config partly changed, so
6300 // the previously applied selection no longer describes the session.
6301 // Callers record the new selection only once this completes.
6302 yield* Ref.set(activeSelection, null);
6303 const requestedModel = flavor.resolveModelId?.(modelSelection) ?? modelSelection.model;
6304 let appliedModel: string | undefined;
6305 if (flavor.applyModelSelection !== undefined) {
6306 appliedModel = yield* flavor.applyModelSelection({
6307 runtime,
6308 startResult,
6309 modelSelection,
6310 });
6311 } else if (
6312 requestedModel.length > 0 &&
6313 requestedModel !== "auto" &&
6314 requestedModel !== "default"
6315 ) {
6316 const hasModelConfig =
6317 startResult.sessionSetupResult.configOptions?.some(
6318 (option) => option.category === "model",
6319 ) === true;
6320 if (hasModelConfig) {
6321 yield* runtime.setModel(requestedModel);
6322 }
6323 }
6324 // Same-runtime switches compare against this stored setup, so keep
6325 // its model metadata in sync with what the session now runs on;
6326 // otherwise switching A -> B -> A would see the stale setup-time A
6327 // and skip the final switch.
6328 if (appliedModel !== undefined) {
6329 const applied = appliedModel;
6330 yield* Ref.update(activeSessionSetup, (setup) => {
6331 if (setup === null) {
6332 return setup;
6333 }
6334 const models = setup.sessionSetupResult.models;
6335 if (models == null || models.currentModelId === applied) {
6336 return setup;
6337 }
6338 return {
6339 ...setup,
6340 sessionSetupResult: {
6341 ...setup.sessionSetupResult,
6342 models: { ...models, currentModelId: applied },
6343 },
6344 };
6345 });
6346 }
6347 // Restore the agent's own empty value for choices T3 stores under a
6348 // reserved ID (see ACP_EMPTY_OPTION_VALUE).
6349 const optionSelections = (modelSelection.options ?? []).map((selection) =>
6350 selection.value === ACP_EMPTY_OPTION_VALUE ? { ...selection, value: "" } : selection,
6351 );
6352 const configOptions = yield* runtime.getConfigOptions;
6353 const availableConfigIds = new Set(configOptions.map((option) => option.id));
6354 const hasNativeConfigWithSyntheticModeId = availableConfigIds.has(
6355 ACP_SESSION_MODE_OPTION_ID,
6356 );
6357 const modeSelection = hasNativeConfigWithSyntheticModeId
6358 ? undefined
6359 : optionSelections.find((selection) => selection.id === ACP_SESSION_MODE_OPTION_ID);
6360 const configSelections = hasNativeConfigWithSyntheticModeId
6361 ? optionSelections
6362 : optionSelections.filter((selection) => selection.id !== ACP_SESSION_MODE_OPTION_ID);
6363 // Probe-time descriptors are a per-model union, so a stored
6364 // selection can reference an option the live session does not
6365 // expose (Kilo advertises per-model "effort" descriptors while its
6366 // session config omits them). Failing the open here wedges the run
6367 // in a retry loop; skip like the out-of-range values below and let
6368 // the agent default apply.
6369 const unsupportedConfigIds = configSelections
6370 .map((selection) => selection.id)
6371 .filter((id) => !availableConfigIds.has(id));
6372 if (unsupportedConfigIds.length > 0) {
6373 yield* Effect.logWarning(
6374 "ACP session does not expose requested configuration option(s)",
6375 {
6376 driver,
6377 sessionId: startResult.sessionId,
6378 optionIds: unsupportedConfigIds,
6379 },
6380 );
6381 }
6382 for (const selection of configSelections) {
6383 if (!availableConfigIds.has(selection.id)) continue;
6384 // Tuning knobs degrade instead of failing the session open: agents
6385 // advertise the union of values across models but can reject a
6386 // per-model invalid one at set time (codex-acp advertises "ultra"
6387 // reasoning effort and then rejects it for most models). Skip
6388 // values the session does not currently offer and downgrade an
6389 // agent-side set rejection to a warning; the agent's default
6390 // applies for that option.
6391 const option = configOptions.find((candidate) => candidate.id === selection.id);
6392 if (
6393 option !== undefined &&
6394 option.type === "select" &&
6395 typeof selection.value === "string"
6396 ) {
6397 const advertisedValues = option.options.flatMap((entry) =>
6398 "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value),
6399 );
6400 if (!advertisedValues.includes(selection.value)) continue;
6401 }
6402 yield* runtime.setConfigOption(selection.id, selection.value).pipe(
6403 Effect.catchTags({
6404 AcpRequestError: (error) =>
6405 Effect.logWarning("ACP session rejected a configuration option value", {
6406 optionId: selection.id,
6407 value: selection.value,
6408 detail: error.message,
6409 }),
6410 }),
6411 );
6412 }
6413 const policyMode = flavor.sessionModeForPolicy?.(runtimePolicy);
6414 if (policyMode !== undefined) {
6415 yield* runtime.setMode(policyMode);
6416 }
6417 const modeState = yield* runtime.getModeState;
6418 // The synthetic mode selection is skipped rather than failed when the
6419 // agent no longer advertises it: mode sets are volatile across agent
6420 // versions and a stale persisted mode should not block the turn.
6421 if (
6422 modeSelection !== undefined &&
6423 typeof modeSelection.value === "string" &&
6424 modeState?.availableModes.some((mode) => mode.id === modeSelection.value) === true &&
6425 modeState.currentModeId !== modeSelection.value
6426 ) {
6427 yield* runtime.setMode(modeSelection.value);
6428 }
6429 const effectiveModeState = yield* runtime.getModeState;
6430 const effectiveConfigOptions = yield* runtime.getConfigOptions;
6431 const planSensitiveOptions = effectiveConfigOptions.filter(
6432 (
6433 option,
6434 ): option is Extract<
6435 EffectAcpSchema.SessionConfigOption,
6436 { readonly type: "select" }
6437 > =>
6438 option.type === "select" &&
6439 (option.category === "mode" || option.category === "collaboration_mode"),
6440 );
6441 if (runtimePolicy.interactionMode === "plan") {
6442 if (!nativeBuildConfigurationBySessionId.has(startResult.sessionId)) {
6443 nativeBuildConfigurationBySessionId.set(startResult.sessionId, {
6444 ...(effectiveModeState === undefined
6445 ? {}
6446 : { modeId: effectiveModeState.currentModeId }),
6447 configOptions: planSensitiveOptions.map((option) => ({
6448 id: option.id,
6449 value: option.currentValue,
6450 })),
6451 });
6452 }
6453 const planMode = effectiveModeState?.availableModes.find(
6454 (mode) => mode.id === "plan" || mode.id === "architect",
6455 );
6456 if (planMode !== undefined && effectiveModeState?.currentModeId !== planMode.id) {
6457 yield* runtime.setMode(planMode.id);
6458 }
6459 for (const option of planSensitiveOptions) {
6460 const choices = option.options.flatMap((entry) =>
6461 "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value),
6462 );
6463 const requested = choices.find(
6464 (choice) => choice === "plan" || choice === "architect",
6465 );
6466 if (requested !== undefined && option.currentValue !== requested) {
6467 yield* runtime.setConfigOption(option.id, requested);
6468 }
6469 }
6470 } else {
6471 const nativeBuild = nativeBuildConfigurationBySessionId.get(startResult.sessionId);
6472 if (nativeBuild !== undefined) {
6473 if (
6474 nativeBuild.modeId !== undefined &&
6475 effectiveModeState?.currentModeId !== nativeBuild.modeId &&
6476 effectiveModeState?.availableModes.some(
6477 (mode) => mode.id === nativeBuild.modeId,
6478 ) === true
6479 ) {
6480 yield* runtime.setMode(nativeBuild.modeId);
6481 }
6482 for (const saved of nativeBuild.configOptions) {
6483 const option = effectiveConfigOptions.find(
6484 (candidate) => candidate.type === "select" && candidate.id === saved.id,
6485 );
6486 if (option === undefined || option.type !== "select") continue;
6487 const choices = option.options.flatMap((entry) =>
6488 "value" in entry ? [entry.value] : entry.options.map((choice) => choice.value),
6489 );
6490 if (option.currentValue !== saved.value && choices.includes(saved.value)) {
6491 yield* runtime.setConfigOption(option.id, saved.value);
6492 }
6493 }
6494 nativeBuildConfigurationBySessionId.delete(startResult.sessionId);
6495 }
6496 }
6497 yield* (
6498 flavor.onSessionConfigurationUpdate?.(
6499 yield* runtime.getConfigOptions,
6500 yield* runtime.getModeState,
6501 ) ?? Effect.void
6502 );
6503 });
6504
6505 yield* configureSession(started, input.modelSelection, input.runtimePolicy);
6506 yield* Ref.set(activeSelection, input.modelSelection);
6507 yield* Ref.set(activeInteractionMode, input.runtimePolicy.interactionMode);
6508 const createdAt = yield* DateTime.now;
6509 const providerSession: OrchestrationV2ProviderSession = {
6510 id: input.providerSessionId,
6511 driver,
6512 providerInstanceId: options.instanceId,
6513 status: "ready",
6514 cwd: input.runtimePolicy.cwd ?? process.cwd(),
6515 model: input.modelSelection.model,
6516 capabilities,
6517 createdAt,
6518 updatedAt: createdAt,
6519 lastError: null,
6520 };
6521
6522 const providerTurnPayload = (
6523 context: ActiveAcpTurn,
6524 status: OrchestrationV2ProviderTurn["status"],
6525 completedAt: DateTime.Utc | null,
6526 ): OrchestrationV2ProviderTurn => ({
6527 id: context.providerTurnId,
6528 providerThreadId: context.input.providerThread.id,
6529 nodeId: context.input.rootNodeId,
6530 runAttemptId: context.input.attemptId,
6531 nativeTurnRef: {
6532 driver,
6533 nativeId: context.nativeTurnId,
6534 strength: "weak",
6535 },
6536 ordinal: context.input.providerTurnOrdinal,
6537 status,
6538 startedAt: context.startedAt,
6539 completedAt,
6540 });
6541
6542 const terminalizeOpenRunOwnedItems = Effect.fnUntraced(function* (
6543 context: ActiveAcpTurn,
6544 options: { readonly terminalizeSubagents: boolean },
6545 ) {
6546 for (const tool of context.tools.values()) {
6547 const status = toolStatus(tool.status);
6548 if (status === "pending" || status === "running" || status === "waiting") {
6549 yield* emitTool(context, tool, "interrupted");
6550 }
6551 }
6552 if (!options.terminalizeSubagents) return;
6553 for (const subagent of context.subagents.values()) {
6554 if (subagent.task.status !== "running" && subagent.task.status !== "pending") {
6555 continue;
6556 }
6557 yield* emitSubagent(context, {
6558 nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id,
6559 prompt: subagent.task.prompt,
6560 title: subagent.task.title,
6561 model: subagent.task.model,
6562 status: "interrupted",
6563 childSessionId: subagent.childSessionId,
6564 result: subagent.task.result,
6565 suppressNormalTool: true,
6566 });
6567 }
6568 });
6569
6570 const terminalizeOpenForegroundTools = Effect.fnUntraced(function* (
6571 context: ActiveAcpTurn,
6572 ) {
6573 for (const tool of context.tools.values()) {
6574 if (!acpCompletedTurnShouldTerminalizeTool(tool, flavor)) continue;
6575 yield* emitTool(context, tool, "completed");
6576 }
6577 });
6578
6579 const quarantineStoppedRun = Effect.fnUntraced(function* () {
6580 yield* continuationPermit.withPermit(
6581 Effect.gen(function* () {
6582 yield* Ref.update(continuationGeneration, (value) => value + 1);
6583 yield* Ref.set(stoppedRunQuarantine, true);
6584 yield* Ref.set(wakeBuffer, []);
6585 yield* Ref.set(wakeReports, noWakeReports);
6586 yield* Ref.set(continuationRequested, false);
6587 yield* Ref.set(runningBackgroundTaskIds, new Set());
6588 yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set());
6589 yield* Ref.set(carryoverSubagents, null);
6590 yield* Ref.set(lastTurnRoute, null);
6591 }),
6592 );
6593 });
6594
6595 const finalizeTurn = Effect.fnUntraced(function* (
6596 context: ActiveAcpTurn,
6597 status: "completed" | "interrupted" | "failed" | "cancelled",
6598 failure?: OrchestrationV2ProviderFailure,
6599 ) {
6600 if (context.finalized) return;
6601 const settledStatus = context.interrupted ? "interrupted" : status;
6602 context.finalizedStatus = settledStatus;
6603 context.finalized = true;
6604 const directStopQuarantine = yield* Ref.get(stoppedRunQuarantine);
6605 if (flavor.subagentsIdleOnTurnCompletion === true) {
6606 for (const subagent of context.subagents.values()) {
6607 if (!acpSubagentStatusBlocksTurnSettlement(subagent.task.status)) continue;
6608 yield* emitSubagent(context, {
6609 nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id,
6610 prompt: subagent.task.prompt,
6611 title: subagent.task.title,
6612 model: subagent.task.model,
6613 status: settledStatus === "completed" ? "idle" : settledStatus,
6614 childSessionId: subagent.childSessionId,
6615 result: null,
6616 });
6617 }
6618 }
6619 if (settledStatus === "completed") {
6620 yield* terminalizeOpenForegroundTools(context);
6621 } else if (settledStatus === "interrupted") {
6622 // Direct Stop terminalizes every visible run-owned item. restart_active
6623 // keeps live subagent lineages for in-process replacement carryover.
6624 yield* terminalizeOpenRunOwnedItems(context, {
6625 terminalizeSubagents: directStopQuarantine,
6626 });
6627 }
6628 yield* closeTextStreams(context);
6629 if (settledStatus !== "failed") {
6630 yield* emitProviderRetry(context, settledStatus);
6631 }
6632 const now = yield* DateTime.now;
6633 if (
6634 flavor.supportsCompaction === true &&
6635 context.input.message.text.trim() === "/compact" &&
6636 context.input.message.attachments.length === 0 &&
6637 settledStatus === "completed"
6638 ) {
6639 const nativeItemId = `${context.nativeTurnId}:compaction`;
6640 yield* emitProviderEvent({
6641 type: "turn_item.updated",
6642 driver,
6643 turnItem: {
6644 id: idAllocator.derive.turnItemFromProviderItem({ driver, nativeItemId }),
6645 threadId: context.input.threadId,
6646 runId: context.input.runId,
6647 nodeId: context.input.rootNodeId,
6648 providerThreadId: context.input.providerThread.id,
6649 providerTurnId: context.providerTurnId,
6650 nativeItemRef: { driver, nativeId: nativeItemId, strength: "weak" },
6651 parentItemId: null,
6652 ordinal: yield* resolveItemOrdinal(context, nativeItemId),
6653 type: "compaction",
6654 driver,
6655 status: "completed",
6656 title: "Context compacted",
6657 startedAt: context.startedAt,
6658 completedAt: now,
6659 updatedAt: now,
6660 },
6661 });
6662 }
6663 const turn = providerTurnPayload(context, settledStatus, now);
6664 yield* Ref.update(providerTurns, (current) => {
6665 const updated = new Map(current);
6666 updated.set(String(turn.id), turn);
6667 return updated;
6668 });
6669 yield* emitProviderEvent({
6670 type: "provider_turn.updated",
6671 driver,
6672 threadId: context.input.threadId,
6673 providerTurn: turn,
6674 });
6675 const updatedProviderThread: OrchestrationV2ProviderThread = {
6676 ...context.input.providerThread,
6677 providerSessionId: input.providerSessionId,
6678 status: "active",
6679 lastRunOrdinal: context.input.runOrdinal,
6680 firstRunOrdinal:
6681 context.input.providerThread.firstRunOrdinal ?? context.input.runOrdinal,
6682 contextUsage: context.contextUsage,
6683 nativeMetadata: context.nativeMetadata,
6684 updatedAt: now,
6685 };
6686 yield* Ref.update(providerThreadByNativeSessionId, (current) =>
6687 new Map(current).set(context.nativeThreadId, updatedProviderThread),
6688 );
6689 yield* emitProviderEvent({
6690 type: "provider_thread.updated",
6691 driver,
6692 providerThread: updatedProviderThread,
6693 });
6694 yield* emitProviderEvent(
6695 settledStatus === "failed"
6696 ? {
6697 type: "turn.terminal",
6698 driver,
6699 providerThreadId: context.input.providerThread.id,
6700 providerTurnId: context.providerTurnId,
6701 runOrdinal: context.input.runOrdinal,
6702 failureItemOrdinal: yield* resolveItemOrdinal(
6703 context,
6704 `terminal-failure:${context.providerTurnId}`,
6705 ),
6706 status: settledStatus,
6707 failure: failure ?? makeProviderFailure({ class: "provider_error" }),
6708 ...(context.providerRetry === undefined
6709 ? {}
6710 : {
6711 retry: context.providerRetry.retry,
6712 retryStartedAt: context.providerRetry.startedAt,
6713 }),
6714 threadDisposition: "reusable",
6715 }
6716 : {
6717 type: "turn.terminal",
6718 driver,
6719 providerThreadId: context.input.providerThread.id,
6720 providerTurnId: context.providerTurnId,
6721 runOrdinal: context.input.runOrdinal,
6722 status: settledStatus,
6723 failure: null,
6724 threadDisposition: "reusable",
6725 },
6726 );
6727 const subagentsRequiringCarryover = [...context.subagents.values()].filter(
6728 acpSubagentHasPendingBackgroundWork,
6729 );
6730 // Direct Stop must not carry residual subagents into a later run.
6731 if (subagentsRequiringCarryover.length > 0 && !directStopQuarantine) {
6732 const sessionId = yield* Ref.get(activeSessionId);
6733 if (sessionId !== null) {
6734 yield* Ref.set(carryoverSubagents, {
6735 sessionId,
6736 rootTerminalStatus: settledStatus,
6737 subagents: subagentsRequiringCarryover,
6738 });
6739 }
6740 }
6741 yield* Ref.set(activeTurn, null);
6742 // A mid-turn background completion may have deferred its offer while
6743 // this root turn was still streaming. Once the turn leaves the active
6744 // slot, re-check: empty running set + residual wake evidence (or a
6745 // mid-turn unreported completion) means a legitimate unhandled
6746 // completion can open exactly one continuation. Sticky
6747 // continuationRequested prevents double-offer if a later frame also
6748 // races into offerContinuationRun.
6749 if (
6750 postSettleContinuationEnabled &&
6751 settledStatus === "completed" &&
6752 !directStopQuarantine &&
6753 (yield* Ref.get(runningBackgroundTaskIds)).size === 0 &&
6754 ((yield* Ref.get(wakeBuffer)).length > 0 ||
6755 (yield* Ref.get(midTurnUnreportedCompletedTaskIds)).size > 0)
6756 ) {
6757 const sessionId = yield* Ref.get(activeSessionId);
6758 if (sessionId !== null) {
6759 yield* offerContinuationRun(sessionId);
6760 }
6761 }
6762 // Clear mid-turn unreported marks unless a completed turn still has
6763 // running background work: keep them so the post-finalize gate can
6764 // offer once when the last task ends. Interrupted/failed turns must
6765 // not leave marks that open a wake after interrupt (quarantine owns
6766 // that path). Non-continuation turn start also clears (~user turn).
6767 if (
6768 settledStatus !== "completed" ||
6769 (yield* Ref.get(runningBackgroundTaskIds)).size === 0
6770 ) {
6771 yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set());
6772 }
6773 yield* Deferred.succeed(context.completed, undefined).pipe(Effect.ignore);
6774 });
6775
6776 scheduleDeferredFinalize = (context) =>
6777 Effect.gen(function* () {
6778 if (!flavor.deferFinalizeForBackgroundWork) return;
6779 if (!context.promptSettled || context.finalized || context.interrupted) return;
6780 if (hasDeferredBackgroundWork(context)) return;
6781 context.backgroundFinalizeGeneration += 1;
6782 const generation = context.backgroundFinalizeGeneration;
6783 yield* Effect.gen(function* () {
6784 yield* Effect.sleep(ACP_DEFERRED_FINALIZE_DEBOUNCE);
6785 if (
6786 context.finalized ||
6787 context.interrupted ||
6788 context.backgroundFinalizeGeneration !== generation
6789 ) {
6790 return;
6791 }
6792 if (hasDeferredBackgroundWork(context)) return;
6793 const status = context.promptSettledStatus ?? "completed";
6794 yield* finalizeTurn(context, status);
6795 }).pipe(Effect.forkIn(sessionScope), Effect.asVoid);
6796 yield* (
6797 options.testHooks?.onDeferredFinalizeScheduled?.(ACP_DEFERRED_FINALIZE_DEBOUNCE) ??
6798 Effect.void
6799 );
6800 });
6801
6802 const resolvePromptParts = Effect.fnUntraced(function* (
6803 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
6804 sessionId: string,
6805 ) {
6806 const prompt: Array<EffectAcpSchema.ContentBlock> = [];
6807 const instructionState = {
6808 interactionMode: turnInput.runtimePolicy.interactionMode,
6809 hasT3Mcp: (yield* readMcpContext(turnInput.threadId)).servers.length > 0,
6810 } satisfies T3AcpInstructionState;
6811 const previousInstructionState = (yield* Ref.get(promptInstructionStates)).get(sessionId);
6812 const messageText = providerMessageTextWithAttachmentPaths({
6813 text: turnInput.message.text,
6814 attachments: turnInput.message.attachments,
6815 resolveAttachmentPath: host.resolveAttachmentPath,
6816 });
6817 const text = t3AcpPromptWithInstructions({
6818 prompt: messageText,
6819 state: instructionState,
6820 ...(previousInstructionState === undefined
6821 ? {}
6822 : { previousState: previousInstructionState }),
6823 });
6824 if (text.length > 0) {
6825 prompt.push({ type: "text", text });
6826 }
6827 const imageAttachments = turnInput.message.attachments.filter(
6828 isProviderNativeImageAttachment,
6829 );
6830 if (imageAttachments.length > 0 && !supportsImagePrompts) {
6831 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
6832 driver,
6833 detail: "ACP driver did not negotiate image prompt support",
6834 });
6835 }
6836 for (const attachment of imageAttachments) {
6837 const path = host.resolveAttachmentPath(attachment as ChatAttachment);
6838 if (path === null) {
6839 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
6840 driver,
6841 detail: `Invalid attachment id '${attachment.id}'`,
6842 });
6843 }
6844 const bytes = yield* fileSystem.readFile(path).pipe(
6845 Effect.mapError(
6846 (cause) =>
6847 new ProviderAdapter.ProviderAdapterProtocolError({
6848 driver,
6849 detail: `Failed to read attachment '${attachment.id}'`,
6850 payload: cause,
6851 }),
6852 ),
6853 );
6854 prompt.push({
6855 type: "image",
6856 data: Buffer.from(bytes).toString("base64"),
6857 mimeType: attachment.mimeType,
6858 });
6859 }
6860 if (prompt.length === 0) {
6861 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
6862 driver,
6863 detail: "ACP turn requires non-empty text or attachments",
6864 });
6865 }
6866 prompt.push({
6867 type: "text",
6868 text: buildRuntimeInstructions({
6869 harness: flavor.runtimeHarness ?? driver,
6870 model: turnInput.modelSelection.model,
6871 }),
6872 });
6873 return { prompt, instructionState: text === messageText ? undefined : instructionState };
6874 });
6875
6876 const restartRuntimeAfterTeardownIfRequired = Effect.fnUntraced(function* (
6877 threadId: ThreadId | null,
6878 ) {
6879 const restartRequired = yield* Ref.get(runtimeRestartRequired);
6880 if (!restartRequired) return false;
6881 yield* restartAcpRuntime(threadId);
6882 yield* Ref.set(runtimeRestartRequired, false);
6883 yield* Ref.set(activeSessionId, null);
6884 yield* Ref.set(activeSessionSetup, null);
6885 yield* Ref.set(activeSelection, null);
6886 yield* Ref.set(activeInteractionMode, null);
6887 yield* Ref.set(snapshot, {
6888 order: [],
6889 messages: new Map(),
6890 loadingRole: null,
6891 loadingMessageId: null,
6892 loadingIndex: 0,
6893 });
6894 return true;
6895 });
6896
6897 const startTurnUnlocked = Effect.fn("AcpAdapterV2.startTurn")(
6898 function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) {
6899 yield* awaitRuntimeTeardown();
6900 const existing = yield* Ref.get(activeTurn);
6901 if (existing !== null) {
6902 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
6903 driver,
6904 detail: `ACP provider turn ${existing.providerTurnId} is still active`,
6905 });
6906 }
6907 useProviderThreadIdentity(turnInput.providerThread);
6908 // Session activation can itself invoke client fs/terminal methods.
6909 // Install the incoming thread policy before load/resume so those
6910 // requests can never inherit the previously active thread's policy.
6911 latestRuntimePolicy = turnInput.runtimePolicy;
6912 const requestedSessionId = yield* nativeThreadId(driver, turnInput.providerThread);
6913 const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired(
6914 turnInput.threadId,
6915 );
6916 const needsSessionActivation =
6917 (yield* Ref.get(activeSessionId)) !== requestedSessionId || restartAfterInterrupt;
6918 if (needsSessionActivation) {
6919 const activated = yield* activateSession(requestedSessionId, turnInput.threadId);
6920 yield* Ref.set(activeSessionId, activated.sessionId);
6921 yield* Ref.set(activeSessionSetup, activated);
6922 yield* configureSession(activated, turnInput.modelSelection, turnInput.runtimePolicy);
6923 yield* Ref.set(activeSelection, turnInput.modelSelection);
6924 yield* Ref.set(activeInteractionMode, turnInput.runtimePolicy.interactionMode);
6925 } else {
6926 const configuredSelection = yield* Ref.get(activeSelection);
6927 const configuredInteractionMode = yield* Ref.get(activeInteractionMode);
6928 if (
6929 configuredSelection === null ||
6930 !modelSelectionsEqual(configuredSelection, turnInput.modelSelection) ||
6931 configuredInteractionMode !== turnInput.runtimePolicy.interactionMode
6932 ) {
6933 const currentSessionSetup = yield* Ref.get(activeSessionSetup);
6934 if (currentSessionSetup === null) {
6935 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
6936 driver,
6937 detail: `ACP session ${requestedSessionId} has no active setup metadata`,
6938 });
6939 }
6940 yield* configureSession(
6941 currentSessionSetup,
6942 turnInput.modelSelection,
6943 turnInput.runtimePolicy,
6944 );
6945 yield* Ref.set(activeSelection, turnInput.modelSelection);
6946 yield* Ref.set(activeInteractionMode, turnInput.runtimePolicy.interactionMode);
6947 }
6948 }
6949 yield* Ref.set(lastTurnRoute, {
6950 threadId: turnInput.threadId,
6951 providerThreadId: turnInput.providerThread.id,
6952 });
6953 yield* Ref.set(suppressPostSettleMonitorPrompt, false);
6954 yield* Ref.set(handledBackgroundTaskIdsInActiveTurn, new Set());
6955 // Continuation turns attach to wake traffic the agent already produced
6956 // after the prior root turn settled; do not re-prompt the ACP session.
6957 const isContinuationTurn =
6958 postSettleContinuationEnabled && acpIsProviderContinuationMessage(turnInput.message);
6959 const isAppOwnedWakeTurn = acpIsAppOwnedWakeTurn(turnInput.message);
6960 // An app-owned wake reports on a sibling delegated child and owns
6961 // none of this session's background work, so it must not discard
6962 // pending wake evidence: neither the mid-turn completion marks kept
6963 // across a settle with work still running, nor the wake buffer
6964 // below. Dropping the marks here would lose the offer for a task
6965 // that completed unreported before the settle.
6966 if (!isAppOwnedWakeTurn) {
6967 yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set());
6968 }
6969 // A user turn supersedes an empty offer, but not an offer that owns
6970 // buffered wake traffic. ProviderContinuationService queues that
6971 // continuation behind the user run so it can drain afterwards.
6972 const continuationWasRequested = yield* continuationPermit.withPermit(
6973 Effect.gen(function* () {
6974 const wasRequested = yield* Ref.get(continuationRequested);
6975 const preserveBufferedContinuation = acpTurnStartShouldPreserveContinuation({
6976 continuationRequested: wasRequested,
6977 isContinuationTurn,
6978 wakeBufferLength: (yield* Ref.get(wakeBuffer)).length,
6979 });
6980 // User turns must not inherit prior-turn wake residue. Stale
6981 // injected-turn ack chatter can otherwise arm a later offer.
6982 // Preserve only a queued continuation that owns buffered wake
6983 // traffic, and exempt app-owned sibling wakes entirely.
6984 if (!isContinuationTurn && !isAppOwnedWakeTurn && !preserveBufferedContinuation) {
6985 yield* Ref.set(wakeBuffer, []);
6986 yield* Ref.set(wakeReports, noWakeReports);
6987 }
6988 if (preserveBufferedContinuation) return wasRequested;
6989 yield* Ref.update(continuationGeneration, (value) => value + 1);
6990 yield* Ref.set(continuationRequested, false);
6991 return wasRequested;
6992 }),
6993 );
6994 const promptParts = isContinuationTurn
6995 ? null
6996 : yield* resolvePromptParts(turnInput, requestedSessionId);
6997 const startedAt = yield* DateTime.now;
6998 const nativeTurnId = `${requestedSessionId}:turn:${turnInput.providerTurnOrdinal}`;
6999 const providerTurnId = deriveProviderTurnId(nativeTurnId);
7000 const completed = yield* Deferred.make<void, never>();
7001 const promptWireSettled = yield* Deferred.make<void, never>();
7002 const rememberedContextUsage = (yield* Ref.get(contextUsageBySessionId)).get(
7003 requestedSessionId,
7004 );
7005 const rememberedNativeMetadata = (yield* Ref.get(nativeMetadataBySessionId)).get(
7006 requestedSessionId,
7007 );
7008 const initialNativeMetadata =
7009 rememberedNativeMetadata === undefined
7010 ? (turnInput.providerThread.nativeMetadata ?? null)
7011 : {
7012 ...turnInput.providerThread.nativeMetadata,
7013 ...rememberedNativeMetadata,
7014 };
7015 if (initialNativeMetadata !== null) {
7016 yield* Ref.update(nativeMetadataBySessionId, (current) =>
7017 new Map(current).set(requestedSessionId, initialNativeMetadata),
7018 );
7019 }
7020 const context: ActiveAcpTurn = {
7021 input: turnInput,
7022 providerTurnId,
7023 nativeThreadId: requestedSessionId,
7024 nativeTurnId,
7025 startedAt,
7026 completed,
7027 itemOrdinals: new Map(),
7028 user: { current: null, nextSegment: 0 },
7029 assistant: { current: null, nextSegment: 0 },
7030 reasoning: { current: null, nextSegment: 0 },
7031 contextUsage: rememberedContextUsage ?? turnInput.providerThread.contextUsage ?? null,
7032 nativeMetadata: initialNativeMetadata,
7033 tools: new Map(),
7034 toolUpdatesSkipped: new Map(),
7035 toolStartedAt: new Map(),
7036 subagents: new Map(),
7037 subagentsBySessionId: new Map(),
7038 pendingSubagentNotifications: new Map(),
7039 toolCallIdsByBackgroundTaskId: new Map(),
7040 persistentBackgroundTaskIds: new Set(),
7041 awaitingBackgroundHydration: new Set(),
7042 pendingInjectedReport: new Set(),
7043 earlyInjectedReportObserved: false,
7044 plans: new Map(),
7045 interrupted: false,
7046 finalized: false,
7047 finalizedStatus: null,
7048 promptSettled: false,
7049 promptSettledStatus: null,
7050 promptWireSettled,
7051 backgroundFinalizeGeneration: 0,
7052 };
7053 const carryover = yield* Ref.getAndSet(carryoverSubagents, null);
7054 let rehydratedCarryoverSubagents: ReadonlyArray<ActiveAcpSubagent> = [];
7055 if (carryover !== null && carryover.sessionId === requestedSessionId) {
7056 rehydratedCarryoverSubagents = carryover.subagents;
7057 for (const subagent of carryover.subagents) {
7058 const nativeId = subagent.task.nativeTaskRef?.nativeId ?? null;
7059 if (nativeId !== null) {
7060 context.subagents.set(nativeId, subagent);
7061 }
7062 if (subagent.childSessionId !== null) {
7063 context.subagentsBySessionId.set(subagent.childSessionId, subagent);
7064 }
7065 }
7066 }
7067 yield* Ref.set(activeTurn, context);
7068 // Direct Stop closes and recreates the old runtime before reaching
7069 // this reset. The quarantine remains session-scoped by design.
7070 yield* Ref.set(stoppedRunQuarantine, false);
7071 const runningTurn = providerTurnPayload(context, "running", null);
7072 yield* Ref.update(providerTurns, (current) => {
7073 const updated = new Map(current);
7074 updated.set(String(runningTurn.id), runningTurn);
7075 return updated;
7076 });
7077 yield* emitProviderEvent({
7078 type: "provider_turn.updated",
7079 driver,
7080 threadId: turnInput.threadId,
7081 providerTurn: runningTurn,
7082 });
7083 const activeProviderThread: OrchestrationV2ProviderThread = {
7084 ...turnInput.providerThread,
7085 providerSessionId: input.providerSessionId,
7086 status: "active",
7087 contextUsage: context.contextUsage,
7088 nativeMetadata: context.nativeMetadata,
7089 updatedAt: startedAt,
7090 };
7091 yield* Ref.update(providerThreadByNativeSessionId, (current) =>
7092 new Map(current).set(requestedSessionId, activeProviderThread),
7093 );
7094 yield* emitProviderEvent({
7095 type: "provider_thread.updated",
7096 driver,
7097 providerThread: activeProviderThread,
7098 });
7099 yield* rememberSnapshotMessage({
7100 createdBy: turnInput.message.createdBy,
7101 creationSource: turnInput.message.creationSource,
7102 ...(turnInput.message.scheduledTaskId === undefined
7103 ? {}
7104 : { scheduledTaskId: turnInput.message.scheduledTaskId }),
7105 ...(turnInput.message.senderThreadId === undefined
7106 ? {}
7107 : { senderThreadId: turnInput.message.senderThreadId }),
7108 id: turnInput.message.messageId,
7109 threadId: turnInput.threadId,
7110 runId: turnInput.runId,
7111 nodeId: turnInput.rootNodeId,
7112 role: "user",
7113 text: turnInput.message.text,
7114 attachments: [...turnInput.message.attachments],
7115 streaming: false,
7116 createdAt: startedAt,
7117 updatedAt: startedAt,
7118 });
7119 // Every attach ends the deferred-terminal contract, but only the
7120 // queued provider continuation owns wake traffic. A user turn may
7121 // start before that continuation and must leave its buffer intact.
7122 yield* projectDeferredCarryoverTerminals(rehydratedCarryoverSubagents);
7123 // Keep projected terminals addressable through the wake drain so
7124 // emitSubagent's monotonic guard can reject replayed spawn frames.
7125 // Finalize carries only entries with pending background work, so a
7126 // terminal-and-projected lineage still expires with this turn.
7127 if (isContinuationTurn) {
7128 yield* Ref.set(continuationRequested, false);
7129 // This turn delivers what its offer named. Work that ended after
7130 // the offer keeps its report for the next one.
7131 yield* Ref.update(wakeReports, ({ reports, offered }) => ({
7132 reports: new Map([...reports].filter(([key]) => !offered.has(key))),
7133 offered: noWakeReports.offered,
7134 }));
7135 const drainedWakeCount = yield* Ref.modify(wakeBuffer, (current) => {
7136 const next: Array<EffectAcpSchema.SessionNotification> = [];
7137 return [
7138 current.filter((notification) => notification.sessionId === requestedSessionId),
7139 next,
7140 ] as const;
7141 }).pipe(
7142 Effect.tap((drained) =>
7143 Effect.forEach(drained, handleSessionUpdate, {
7144 concurrency: 1,
7145 discard: true,
7146 }),
7147 ),
7148 Effect.map((drained) => drained.length),
7149 );
7150 // Treat attach mode as prompt-settled so deferred finalize / quiet
7151 // windows can complete the continuation after wake traffic drains.
7152 context.promptSettled = true;
7153 context.promptSettledStatus = "completed";
7154 if (drainedWakeCount === 0) {
7155 // A requested continuation with only mid-turn evidence has no
7156 // buffered frame yet. Wait the quiet window so late CLI frames
7157 // can attach. A provider-authored attach without a matching
7158 // request only exists to project deferred carryover terminals,
7159 // so it can finish immediately once those terminals are visible.
7160 if (continuationWasRequested && flavor.deferFinalizeForBackgroundWork === true) {
7161 if (hasDeferredBackgroundWork(context)) {
7162 yield* rearmDeferredFinalize(context);
7163 } else {
7164 yield* scheduleDeferredFinalize(context);
7165 }
7166 } else {
7167 yield* finalizeTurn(context, "completed");
7168 }
7169 return;
7170 }
7171 if (!context.finalized) {
7172 if (hasDeferredBackgroundWork(context)) {
7173 yield* rearmDeferredFinalize(context);
7174 } else {
7175 yield* scheduleDeferredFinalize(context);
7176 }
7177 }
7178 return;
7179 }
7180 const promptGeneration = yield* Ref.get(runtimeCallbackGeneration);
7181 yield* runtime.prompt({ prompt: promptParts!.prompt }).pipe(
7182 Effect.tap(() =>
7183 Ref.update(promptInstructionStates, (current) => {
7184 if (promptParts?.instructionState === undefined) return current;
7185 const updated = new Map(current);
7186 updated.set(requestedSessionId, promptParts.instructionState);
7187 return updated;
7188 }),
7189 ),
7190 // Wire settlement precedes the completion callback's permit request so
7191 // settled-soft classification can observe the native return even when
7192 // the completion fiber has not yet set promptSettled under the permit.
7193 Effect.tap(() =>
7194 Deferred.succeed(context.promptWireSettled, undefined).pipe(Effect.asVoid),
7195 ),
7196 Effect.flatMap((result) =>
7197 runRuntimeCallbackAtGeneration(
7198 promptGeneration,
7199 Effect.gen(function* () {
7200 if (context.finalized) return;
7201 const status =
7202 result.stopReason === "cancelled"
7203 ? context.interrupted
7204 ? "interrupted"
7205 : "cancelled"
7206 : "completed";
7207 // Grok monitors (and async subagents) keep working after the root
7208 // prompt RPC returns. Defer finalize so their later updates and
7209 // wake-turn traffic still project onto this run.
7210 if (
7211 flavor.deferFinalizeForBackgroundWork === true &&
7212 !context.interrupted &&
7213 hasDeferredBackgroundWork(context)
7214 ) {
7215 context.promptSettled = true;
7216 context.promptSettledStatus = status;
7217 // The agent finished this prompt's reply. Background work
7218 // holds the run open, not the text it already sent.
7219 yield* closeTextStreams(context);
7220 yield* (
7221 options.testHooks?.afterPromptSettledWithBackgroundWork?.() ?? Effect.void
7222 );
7223 return;
7224 }
7225 yield* finalizeTurn(context, status);
7226 }),
7227 ).pipe(Effect.asVoid),
7228 ),
7229 // Prompt failure is not wire-settled: only a successful resolve marks
7230 // the signal. catchCause must not complete promptWireSettled.
7231 Effect.catchCause((cause) =>
7232 runRuntimeCallbackAtGeneration(
7233 promptGeneration,
7234 Effect.gen(function* () {
7235 if (context.finalized) return;
7236 yield* finalizeTurn(
7237 context,
7238 context.interrupted ? "interrupted" : "failed",
7239 flavor.promptFailure?.(Cause.squash(cause)) ??
7240 makeProviderFailure({
7241 cause: Cause.squash(cause),
7242 class: "provider_error",
7243 }),
7244 ).pipe(
7245 Effect.andThen(
7246 Effect.logWarning("orchestration-v2.acp-prompt-failed", {
7247 driver,
7248 providerSessionId: input.providerSessionId,
7249 providerThreadId: turnInput.providerThread.id,
7250 providerTurnId,
7251 cause,
7252 }),
7253 ),
7254 );
7255 }),
7256 ).pipe(Effect.asVoid),
7257 ),
7258 Effect.forkIn(sessionScope),
7259 );
7260 },
7261 (effect, turnInput) =>
7262 effect.pipe(
7263 Effect.mapError(
7264 (cause) =>
7265 new ProviderAdapter.ProviderAdapterTurnStartError({
7266 driver,
7267 threadId: turnInput.threadId,
7268 providerThreadId: turnInput.providerThread.id,
7269 runId: turnInput.runId,
7270 cause,
7271 }),
7272 ),
7273 ),
7274 );
7275
7276 const startTurn = Effect.fn("AcpAdapterV2.startTurn.transition")(function* (
7277 turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
7278 ) {
7279 return yield* runtimeTransitionPermit.withPermit(startTurnUnlocked(turnInput));
7280 });
7281
7282 yield* Effect.addFinalizer(() =>
7283 Effect.gen(function* () {
7284 yield* continuationPermit.withPermit(
7285 Effect.gen(function* () {
7286 yield* Ref.set(continuationClosed, true);
7287 yield* Ref.update(continuationGeneration, (value) => value + 1);
7288 }),
7289 );
7290 const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()];
7291 yield* Effect.forEach(
7292 requests,
7293 (request) =>
7294 request.type === "approval"
7295 ? Deferred.succeed(request.decision, "cancel").pipe(Effect.ignore)
7296 : Deferred.succeed(request.answers, null).pipe(Effect.ignore),
7297 { discard: true },
7298 );
7299 const closingError = new EffectAcpErrors.AcpTransportError({
7300 detail: "The ACP session closed before its admitted response reached the transport",
7301 cause: "ACP session transport closed",
7302 });
7303 yield* Effect.forEach(
7304 requests,
7305 (request) => Deferred.fail(request.nativeResponseAcknowledgement, closingError),
7306 { discard: true },
7307 );
7308 const transportHadOutstandingResponses = yield* closeNativeTransport;
7309 yield* options.testHooks?.afterNativeResponseTransportClosed?.() ?? Effect.void;
7310 const sessionCapabilities =
7311 started.initializeResult.agentCapabilities?.sessionCapabilities;
7312 if (sessionCapabilities?.close != null) {
7313 yield* runtimeTransitionPermit.withPermitsIfAvailable(1)(
7314 Effect.gen(function* () {
7315 const teardownState = yield* Ref.get(runtimeTeardownState);
7316 const restartRequired = yield* Ref.get(runtimeRestartRequired);
7317 if (
7318 teardownState._tag === "Idle" &&
7319 !restartRequired &&
7320 !transportHadOutstandingResponses
7321 ) {
7322 yield* runtime.closeSession().pipe(Effect.ignore);
7323 }
7324 }),
7325 );
7326 }
7327 if (flavor.assertComplete !== undefined) {
7328 yield* flavor.assertComplete.pipe(Effect.orDie);
7329 }
7330 if (runtimeScope !== undefined) {
7331 yield* Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore);
7332 }
7333 }),
7334 );
7335
7336 const sessionRuntime: ProviderAdapter.ProviderAdapterV2SessionRuntime = {
7337 instanceId: options.instanceId,
7338 driver,
7339 providerSessionId: input.providerSessionId,
7340 providerSession,
7341 events: Stream.fromEffectRepeat(Queue.take(events)),
7342 ...(postSettleContinuationEnabled
7343 ? {
7344 hasPendingBackgroundWork: Effect.gen(function* () {
7345 if ((yield* Ref.get(wakeBuffer)).length > 0) return true;
7346 if (yield* Ref.get(continuationRequested)) return true;
7347 if ((yield* Ref.get(runningBackgroundTaskIds)).size > 0) return true;
7348 // Projected post-settle Grok subagents can outlive the root
7349 // turn via carryover; keep the ACP process pinned until they
7350 // terminalize or teardown clears the carryover.
7351 // Also pin while a terminal status is held only in memory
7352 // (project:false) so idle release cannot drop the session
7353 // before the continuation drain (or a later project:true path)
7354 // delivers the turn_item terminal.
7355 const active = yield* Ref.get(activeTurn);
7356 if (
7357 active !== null &&
7358 [...active.subagents.values()].some(acpSubagentHasPendingBackgroundWork)
7359 ) {
7360 return true;
7361 }
7362 const carryover = yield* Ref.get(carryoverSubagents);
7363 if (
7364 carryover !== null &&
7365 carryover.subagents.some(acpSubagentHasPendingBackgroundWork)
7366 ) {
7367 return true;
7368 }
7369 return false;
7370 }),
7371 }
7372 : {}),
7373 ensureThread: Effect.fn("AcpAdapterV2.ensureThread")(
7374 function* (threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput) {
7375 const now = yield* DateTime.now;
7376 const sessionId = yield* Ref.get(activeSessionId);
7377 if (sessionId === null) {
7378 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
7379 driver,
7380 detail: "ACP runtime did not produce a session id",
7381 });
7382 }
7383 const providerThread = makeProviderThread({
7384 driver,
7385 providerInstanceId: options.instanceId,
7386 idAllocator,
7387 appThreadId: threadInput.threadId,
7388 providerSessionId: input.providerSessionId,
7389 nativeThreadId: sessionId,
7390 ...(itemIdentityVersion === undefined ? {} : { itemIdentityVersion }),
7391 now,
7392 });
7393 yield* Ref.update(providerThreadByNativeSessionId, (current) =>
7394 new Map(current).set(sessionId, providerThread),
7395 );
7396 return providerThread;
7397 },
7398 (effect, threadInput) =>
7399 effect.pipe(
7400 Effect.mapError(
7401 (cause) =>
7402 new ProviderAdapter.ProviderAdapterEnsureThreadError({
7403 driver,
7404 threadId: threadInput.threadId,
7405 cause,
7406 }),
7407 ),
7408 ),
7409 ),
7410 resumeThread: Effect.fn("AcpAdapterV2.resumeThread")(
7411 function* (threadInput: {
7412 readonly providerThread: OrchestrationV2ProviderThread;
7413 readonly modelSelection?: ModelSelection;
7414 readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
7415 }) {
7416 return yield* runtimeTransitionPermit.withPermit(
7417 Effect.gen(function* () {
7418 yield* awaitRuntimeTeardown();
7419 const sessionId = yield* nativeThreadId(driver, threadInput.providerThread);
7420 const previousItemIdentityVersion = itemIdentityVersion;
7421 useProviderThreadIdentity(threadInput.providerThread);
7422 const restorePreviousItemIdentity = Effect.sync(() => {
7423 itemIdentityVersion = previousItemIdentityVersion;
7424 });
7425 const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired(
7426 threadInput.providerThread.appThreadId,
7427 ).pipe(Effect.tapError(() => restorePreviousItemIdentity));
7428 if ((yield* Ref.get(activeSessionId)) !== sessionId || restartAfterInterrupt) {
7429 yield* Ref.set(snapshot, {
7430 order: [],
7431 messages: new Map(),
7432 loadingRole: null,
7433 loadingMessageId: null,
7434 loadingIndex: 0,
7435 });
7436 const activated = yield* activateSession(
7437 sessionId,
7438 threadInput.providerThread.appThreadId,
7439 ).pipe(Effect.tapError(() => restorePreviousItemIdentity));
7440 yield* Ref.set(activeSessionId, activated.sessionId);
7441 yield* Ref.set(activeSessionSetup, activated);
7442 const nextSelection = threadInput.modelSelection ?? input.modelSelection;
7443 const nextRuntimePolicy = threadInput.runtimePolicy ?? input.runtimePolicy;
7444 yield* configureSession(activated, nextSelection, nextRuntimePolicy);
7445 yield* Ref.set(activeSelection, nextSelection);
7446 yield* Ref.set(activeInteractionMode, nextRuntimePolicy.interactionMode);
7447 }
7448 const now = yield* DateTime.now;
7449 return {
7450 ...threadInput.providerThread,
7451 providerSessionId: input.providerSessionId,
7452 status: "idle" as const,
7453 updatedAt: now,
7454 };
7455 }),
7456 );
7457 },
7458 (effect, threadInput) =>
7459 effect.pipe(
7460 Effect.mapError(
7461 (cause) =>
7462 new ProviderAdapter.ProviderAdapterResumeThreadError({
7463 driver,
7464 providerSessionId: input.providerSessionId,
7465 providerThreadId: threadInput.providerThread.id,
7466 cause,
7467 }),
7468 ),
7469 ),
7470 ),
7471 startTurn,
7472 ...(flavor.supportsCompaction === true
7473 ? {
7474 compactThread: (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) =>
7475 startTurn({
7476 ...turnInput,
7477 message: { ...turnInput.message, text: "/compact" },
7478 }),
7479 }
7480 : {}),
7481 steerTurn: (turnInput) =>
7482 Effect.fail(
7483 new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({
7484 driver,
7485 providerThreadId: turnInput.providerThread.id,
7486 }),
7487 ),
7488 interruptTurn: Effect.fn("AcpAdapterV2.interruptTurn")(
7489 function* (turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput) {
7490 return yield* Effect.uninterruptibleMask((restore) =>
7491 Effect.gen(function* () {
7492 yield* restore(runtimeTransitionPermit.take(1));
7493 return yield* Effect.gen(function* () {
7494 const transition = yield* Effect.gen(function* () {
7495 const interruptContext = yield* Ref.get(activeTurn);
7496 // Settled steering restart: the native prompt already
7497 // returned and this is not a user Stop, so keep the
7498 // process (and its background subagents) alive instead
7499 // of hard-killing it. The turn still terminalizes below.
7500 const softSteerCandidate =
7501 flavor.preserveRuntimeOnSettledInterrupt === true &&
7502 turnInput.requestRuntimeRestart !== true &&
7503 interruptContext?.providerTurnId === turnInput.providerTurnId;
7504 // Settlement is read under the callback permit after
7505 // draining admitted responses: the native prompt can have
7506 // returned on the wire while its completion callback has
7507 // not yet run, and a cancel sent in that window is exactly
7508 // what settled-soft mode exists to avoid. Also treat the
7509 // turn as settled when the wire signal is already done
7510 // (non-blocking; do not await under the permit).
7511 let settledSoftInterrupt = false;
7512 if (softSteerCandidate && interruptContext !== null) {
7513 const promptSettledUnderPermit = yield* runtimeCallbackPermit.withPermit(
7514 awaitAdmittedNativeResponses.pipe(
7515 Effect.andThen(
7516 Effect.sync(() => interruptContext.promptSettled === true),
7517 ),
7518 ),
7519 );
7520 const wireSettled = yield* Deferred.isDone(
7521 interruptContext.promptWireSettled,
7522 );
7523 settledSoftInterrupt = promptSettledUnderPermit || wireSettled;
7524 }
7525 const restartRuntime =
7526 !settledSoftInterrupt &&
7527 (turnInput.requestRuntimeRestart === true ||
7528 flavor.restartRuntimeOnEveryInterrupt === true);
7529 const hardRestart =
7530 restartRuntime && flavor.terminateRuntimeProcessGroupOnInterrupt === true;
7531 if (hardRestart) {
7532 const teardownState = yield* Ref.get(runtimeTeardownState);
7533 if (teardownState._tag === "Failed") {
7534 return yield* teardownState.error;
7535 }
7536 if (teardownState._tag === "InProgress") {
7537 // Concurrent Stop/restart: wait for the in-flight hard
7538 // teardown instead of failing the durable effect.
7539 yield* Effect.logInfo(
7540 "ACP interrupt awaiting in-progress hard teardown",
7541 {
7542 driver,
7543 providerTurnId: turnInput.providerTurnId,
7544 },
7545 );
7546 yield* Deferred.await(teardownState.completed);
7547 return undefined;
7548 }
7549 }
7550 const context = interruptContext;
7551 if (context?.providerTurnId !== turnInput.providerTurnId) {
7552 // A soft steering interrupt can clear the turn while
7553 // intentionally leaving the process alive. A queued user
7554 // Stop must still contain that orphan runtime. With a
7555 // different live turn active this stays a pure no-op
7556 // success instead: quarantine and teardown are
7557 // session-global and would maim the replacement turn.
7558 const containOrphanRuntime =
7559 context === null &&
7560 hardRestart &&
7561 turnInput.requestRuntimeRestart === true;
7562 // Transport death or a prior Stop already cleared the
7563 // turn. Failing here caused effect-worker retries while
7564 // the process was already gone; treat as success.
7565 yield* Effect.logWarning(
7566 containOrphanRuntime
7567 ? "ACP Stop raced a soft interrupt that cleared the turn; containing the orphan runtime"
7568 : "ACP interrupt raced transport teardown or a prior Stop; treating as already interrupted",
7569 {
7570 driver,
7571 requestedProviderTurnId: turnInput.providerTurnId,
7572 activeProviderTurnId: context?.providerTurnId ?? null,
7573 hardRestart,
7574 requestRuntimeRestart: turnInput.requestRuntimeRestart === true,
7575 teardownState: (yield* Ref.get(runtimeTeardownState))._tag,
7576 },
7577 );
7578 if (containOrphanRuntime) {
7579 const teardownBarrier = yield* Deferred.make<
7580 void,
7581 ProviderAdapter.ProviderAdapterProtocolError
7582 >();
7583 yield* runtimeCallbackPermit.withPermit(
7584 Effect.gen(function* () {
7585 yield* awaitAdmittedNativeResponses;
7586 const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration);
7587 yield* quarantineNativeTransportAtGeneration(stoppedGeneration);
7588 yield* Ref.set(runtimeTeardownState, {
7589 _tag: "InProgress",
7590 completed: teardownBarrier,
7591 });
7592 yield* advanceRuntimeCallbackGeneration;
7593 }),
7594 );
7595 // Capture before quarantineStoppedRun clears carryover.
7596 const orphanCarryover = yield* Ref.getAndSet(carryoverSubagents, null);
7597 yield* quarantineStoppedRun();
7598 // Match the main hard-restart path: cancel pending
7599 // approvals/elicitations after quarantine, before kill.
7600 yield* cancelPendingRuntimeRequests();
7601 yield* terminalizeCarryoverSubagents(orphanCarryover);
7602 if (runtime.terminateProcessGroup === undefined) {
7603 const error = new ProviderAdapter.ProviderAdapterProtocolError({
7604 driver,
7605 detail:
7606 "ACP runtime does not expose its required process-group teardown; the session is poisoned",
7607 });
7608 yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error });
7609 yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore);
7610 return yield* error;
7611 }
7612 const teardownExit = yield* runtime.terminateProcessGroup.pipe(
7613 Effect.exit,
7614 );
7615 if (Exit.isFailure(teardownExit)) {
7616 const error = new ProviderAdapter.ProviderAdapterProtocolError({
7617 driver,
7618 detail:
7619 "ACP orphan runtime process-group teardown failed; the session is poisoned",
7620 payload: Cause.squash(teardownExit.cause),
7621 });
7622 yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error });
7623 yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore);
7624 return yield* error;
7625 }
7626 yield* Ref.set(runtimeRestartRequired, true);
7627 yield* Ref.set(runtimeTeardownState, { _tag: "Idle" });
7628 yield* Deferred.succeed(teardownBarrier, undefined).pipe(Effect.ignore);
7629 }
7630 return undefined;
7631 }
7632 const teardownBarrier = hardRestart
7633 ? yield* Deferred.make<void, ProviderAdapter.ProviderAdapterProtocolError>()
7634 : null;
7635 if (teardownBarrier !== null) {
7636 yield* runtimeCallbackPermit.withPermit(
7637 Effect.gen(function* () {
7638 yield* awaitAdmittedNativeResponses;
7639 const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration);
7640 yield* quarantineNativeTransportAtGeneration(stoppedGeneration);
7641 yield* Ref.set(runtimeTeardownState, {
7642 _tag: "InProgress",
7643 completed: teardownBarrier,
7644 });
7645 yield* advanceRuntimeCallbackGeneration;
7646 yield* (
7647 options.testHooks?.afterHardTeardownTransportDrained?.() ??
7648 Effect.void
7649 );
7650 }),
7651 );
7652 }
7653 return {
7654 context,
7655 restartRuntime,
7656 hardRestart,
7657 teardownBarrier,
7658 settledSoftInterrupt,
7659 };
7660 });
7661 // Concurrent hard teardown already completed above.
7662 if (transition === undefined) return;
7663 const {
7664 context,
7665 restartRuntime,
7666 hardRestart,
7667 teardownBarrier,
7668 settledSoftInterrupt,
7669 } = transition;
7670 const poisonTeardown = Effect.fnUntraced(function* (
7671 detail: string,
7672 payload?: unknown,
7673 ) {
7674 const error = new ProviderAdapter.ProviderAdapterProtocolError({
7675 driver,
7676 detail,
7677 ...(payload === undefined ? {} : { payload }),
7678 });
7679 yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error });
7680 yield* Deferred.fail(teardownBarrier!, error).pipe(Effect.ignore);
7681 return error;
7682 });
7683 const runTransition = Effect.gen(function* () {
7684 context.interrupted = true;
7685 // Quarantine only when this run is discarded (user Stop /
7686 // hard process kill). Soft in-process restarts must keep
7687 // carryoverSubagents so a still-running subagent can
7688 // complete into the replacement turn.
7689 if (hardRestart || turnInput.requestRuntimeRestart === true) {
7690 yield* quarantineStoppedRun();
7691 }
7692 yield* cancelPendingRuntimeRequests();
7693 // Finalize before process-group kill so projection/UI cannot
7694 // lag a dead transport, and concurrent interrupt effects see
7695 // activeTurn cleared (idempotent success) rather than racing.
7696 if (
7697 (hardRestart || context.promptSettled || settledSoftInterrupt) &&
7698 !context.finalized
7699 ) {
7700 // hardRestart: always terminalize locally.
7701 // promptSettled / settledSoftInterrupt (incl. wire-settled):
7702 // native prompt already returned; only deferred background
7703 // work remains, so session/cancel has nothing to acknowledge.
7704 yield* finalizeTurn(context, "interrupted");
7705 }
7706 if (hardRestart) {
7707 if (runtime.terminateProcessGroup === undefined) {
7708 return yield* poisonTeardown(
7709 "ACP runtime does not expose its required process-group teardown; the session is poisoned",
7710 ).pipe(Effect.flatMap(Effect.fail));
7711 }
7712 const teardownExit = yield* runtime.terminateProcessGroup!.pipe(
7713 Effect.exit,
7714 );
7715 if (Exit.isFailure(teardownExit)) {
7716 return yield* poisonTeardown(
7717 "ACP runtime process-group teardown failed; the session is poisoned",
7718 Cause.squash(teardownExit.cause),
7719 ).pipe(Effect.flatMap(Effect.fail));
7720 }
7721 // Process group is gone; the next turn must spawn a
7722 // replacement even if the flavor only set hardRestart
7723 // without restartRuntimeAfterInterrupt.
7724 yield* Ref.set(runtimeRestartRequired, true);
7725 yield* Ref.set(runtimeTeardownState, { _tag: "Idle" });
7726 yield* Deferred.succeed(teardownBarrier!, undefined).pipe(Effect.ignore);
7727 } else {
7728 // Settled soft interrupt: the native prompt already
7729 // returned, so session/cancel has nothing to
7730 // acknowledge and would only threaten still-running
7731 // background subagents. Skip it and leave the runtime
7732 // untouched for the replacement turn.
7733 if (!settledSoftInterrupt) {
7734 yield* runtime.cancel;
7735 }
7736 if (restartRuntime && flavor.restartRuntimeAfterInterrupt === true) {
7737 yield* Ref.set(runtimeRestartRequired, true);
7738 }
7739 }
7740 const stopped = yield* Deferred.await(context.completed).pipe(
7741 Effect.timeoutOption("10 seconds"),
7742 );
7743 if (Option.isNone(stopped)) {
7744 if (!context.finalized) {
7745 yield* finalizeTurn(context, "interrupted");
7746 }
7747 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
7748 driver,
7749 detail: `ACP provider turn ${turnInput.providerTurnId} did not acknowledge cancellation before the interrupt timeout`,
7750 });
7751 }
7752 });
7753 if (!hardRestart) {
7754 return yield* restore(runTransition);
7755 }
7756 return yield* runTransition.pipe(
7757 Effect.catchCause((cause) =>
7758 Effect.gen(function* () {
7759 const state = yield* Ref.get(runtimeTeardownState);
7760 if (state._tag === "InProgress") {
7761 yield* poisonTeardown(
7762 "ACP hard teardown failed unexpectedly; the session is poisoned",
7763 Cause.squash(cause),
7764 );
7765 }
7766 return yield* Effect.failCause(cause);
7767 }),
7768 ),
7769 );
7770 }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1)));
7771 }),
7772 );
7773 },
7774 (effect, turnInput) =>
7775 effect.pipe(
7776 Effect.mapError(
7777 (cause) =>
7778 new ProviderAdapter.ProviderAdapterInterruptError({
7779 driver,
7780 providerThreadId: turnInput.providerThread.id,
7781 providerTurnId: turnInput.providerTurnId,
7782 cause,
7783 }),
7784 ),
7785 ),
7786 ),
7787 respondToRuntimeRequest: (requestInput) =>
7788 Effect.uninterruptibleMask((restore) =>
7789 Effect.gen(function* () {
7790 yield* restore(runtimeTransitionPermit.take(1));
7791 return yield* Effect.gen(function* () {
7792 yield* awaitRuntimeTeardown();
7793 const generation = yield* Ref.get(runtimeCallbackGeneration);
7794 const pending = (yield* Ref.get(pendingRuntimeRequests)).get(
7795 String(requestInput.requestId),
7796 );
7797 if (pending === undefined || pending.generation !== generation) {
7798 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
7799 driver,
7800 detail: `No pending ACP runtime request ${requestInput.requestId}`,
7801 });
7802 }
7803 const settled =
7804 pending.type === "user_input"
7805 ? yield* Deferred.succeed(pending.answers, requestInput.answers ?? null)
7806 : requestInput.decision === undefined
7807 ? yield* new ProviderAdapter.ProviderAdapterProtocolError({
7808 driver,
7809 detail: `ACP approval request ${requestInput.requestId} requires a decision`,
7810 })
7811 : yield* Deferred.succeed(pending.decision, requestInput.decision);
7812 if (!settled) {
7813 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
7814 driver,
7815 detail: `ACP runtime request ${requestInput.requestId} was already resolved`,
7816 });
7817 }
7818 yield* awaitNativeResponseAcknowledgements([
7819 [pending.transportRequestId, pending.nativeResponseAcknowledgement],
7820 ]);
7821 yield* Deferred.await(pending.nativeResponseAcknowledgement);
7822 }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1)));
7823 }),
7824 ).pipe(
7825 Effect.mapError(
7826 (cause) =>
7827 new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({
7828 driver,
7829 requestId: requestInput.requestId,
7830 cause,
7831 }),
7832 ),
7833 ),
7834 readThreadSnapshot: Effect.fn("AcpAdapterV2.readThreadSnapshot")(
7835 function* (snapshotInput) {
7836 return yield* runtimeTransitionPermit.withPermit(
7837 Effect.gen(function* () {
7838 yield* awaitRuntimeTeardown();
7839 useProviderThreadIdentity(snapshotInput.providerThread);
7840 yield* restartRuntimeAfterTeardownIfRequired(
7841 snapshotInput.providerThread.appThreadId,
7842 );
7843 const sessionId = yield* nativeThreadId(driver, snapshotInput.providerThread);
7844 if ((yield* Ref.get(activeSessionId)) !== sessionId) {
7845 if (!capabilities.threads.canReadThreadSnapshot) {
7846 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
7847 driver,
7848 detail: "ACP driver does not support session/load snapshots",
7849 });
7850 }
7851 yield* Ref.set(snapshot, {
7852 order: [],
7853 messages: new Map(),
7854 loadingRole: null,
7855 loadingMessageId: null,
7856 loadingIndex: 0,
7857 });
7858 const mcpContext = yield* readMcpContext(
7859 snapshotInput.providerThread.appThreadId,
7860 );
7861 prepareTerminalEnvironment(mcpContext, sessionId);
7862 const activated = yield* runtime.loadSession(
7863 sessionId,
7864 acpMcpActivation(mcpContext),
7865 );
7866 rememberTerminalEnvironment(
7867 activated.sessionId,
7868 yield* readMcpContext(snapshotInput.providerThread.appThreadId),
7869 );
7870 yield* Ref.set(activeSessionId, activated.sessionId);
7871 yield* Ref.set(activeSessionSetup, activated);
7872 yield* Ref.set(activeSelection, null);
7873 yield* Ref.set(activeInteractionMode, null);
7874 }
7875 const state = yield* Ref.get(snapshot);
7876 const now = yield* DateTime.now;
7877 return {
7878 providerThread: {
7879 ...snapshotInput.providerThread,
7880 providerSessionId: input.providerSessionId,
7881 status: "idle" as const,
7882 updatedAt: now,
7883 },
7884 providerTurns: [...(yield* Ref.get(providerTurns)).values()],
7885 messages: state.order.flatMap((key) => {
7886 const message = state.messages.get(key);
7887 return message === undefined ? [] : [message];
7888 }),
7889 runtimeRequests: [],
7890 providerPayload: { protocol: ACP_PROTOCOL, sessionId },
7891 };
7892 }),
7893 );
7894 },
7895 (effect, snapshotInput) =>
7896 effect.pipe(
7897 Effect.mapError(
7898 (cause) =>
7899 new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({
7900 driver,
7901 providerThreadId: snapshotInput.providerThread.id,
7902 cause,
7903 }),
7904 ),
7905 ),
7906 ),
7907 rollbackThread: (rollbackInput) =>
7908 runtimeTransitionPermit
7909 .withPermit(
7910 Effect.gen(function* () {
7911 const currentTurn = yield* Ref.get(activeTurn);
7912 if (currentTurn !== null) {
7913 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
7914 driver,
7915 detail: `Cannot roll back ACP provider thread ${rollbackInput.providerThread.id} while turn ${currentTurn.providerTurnId} is active`,
7916 });
7917 }
7918 // ACP defines no conversation truncation, so rollback stages
7919 // a fresh native session before retiring the original.
7920 // Returning its binding keeps the next turn runnable without
7921 // loading any of the rolled-back conversation.
7922 yield* awaitRuntimeTeardown();
7923 itemIdentityVersion = 2;
7924 prepareTerminalEnvironment(
7925 yield* readMcpContext(rollbackInput.providerThread.appThreadId),
7926 );
7927 const replacement = yield* startReplacementAcpRuntime(
7928 rollbackInput.providerThread.appThreadId,
7929 (candidate) =>
7930 Effect.gen(function* () {
7931 rememberTerminalEnvironment(
7932 candidate.sessionId,
7933 yield* readMcpContext(rollbackInput.providerThread.appThreadId),
7934 );
7935 yield* Ref.set(runtimeRestartRequired, false);
7936 yield* Ref.set(activeSessionId, candidate.sessionId);
7937 yield* Ref.set(activeSessionSetup, candidate);
7938 yield* Ref.set(activeSelection, null);
7939 yield* Ref.set(activeInteractionMode, null);
7940 yield* Ref.set(promptInstructionStates, new Map());
7941 yield* Ref.set(providerTurns, new Map());
7942 yield* Ref.set(snapshot, {
7943 order: [],
7944 messages: new Map(),
7945 loadingRole: null,
7946 loadingMessageId: null,
7947 loadingIndex: 0,
7948 });
7949 yield* continuationPermit.withPermit(
7950 Effect.gen(function* () {
7951 yield* Ref.update(continuationGeneration, (value) => value + 1);
7952 yield* Ref.set(stoppedRunQuarantine, false);
7953 yield* Ref.set(wakeBuffer, []);
7954 yield* Ref.set(wakeReports, noWakeReports);
7955 yield* Ref.set(continuationRequested, false);
7956 yield* Ref.set(runningBackgroundTaskIds, new Set());
7957 yield* Ref.set(endedBackgroundTaskIds, new Set());
7958 yield* Ref.set(midTurnUnreportedCompletedTaskIds, new Set());
7959 yield* Ref.set(handledBackgroundTaskIdsInActiveTurn, new Set());
7960 yield* Ref.set(carryoverSubagents, null);
7961 yield* Ref.set(suppressPostSettleMonitorPrompt, false);
7962 yield* Ref.set(lastTurnRoute, null);
7963 }),
7964 );
7965 }),
7966 ).pipe(Effect.retry({ times: 1 }));
7967 const now = yield* DateTime.now;
7968 return {
7969 providerThread: {
7970 ...rollbackInput.providerThread,
7971 nativeThreadRef: {
7972 driver,
7973 nativeId: replacement.sessionId,
7974 strength: "strong" as const,
7975 },
7976 nativeConversationHeadRef: null,
7977 nativeMetadata: {
7978 ...rollbackInput.providerThread.nativeMetadata,
7979 itemIdentityVersion: 2 as const,
7980 },
7981 status: "idle" as const,
7982 updatedAt: now,
7983 },
7984 providerTurns: [],
7985 messages: [],
7986 runtimeRequests: [],
7987 };
7988 }),
7989 )
7990 .pipe(
7991 Effect.mapError(
7992 (cause) =>
7993 new ProviderAdapter.ProviderAdapterRollbackThreadError({
7994 driver,
7995 providerThreadId: rollbackInput.providerThread.id,
7996 checkpointId: rollbackInput.target.checkpointId,
7997 cause,
7998 }),
7999 ),
8000 ),
8001 forkThread: Effect.fn("AcpAdapterV2.forkThread")(
8002 function* (forkInput) {
8003 return yield* runtimeTransitionPermit.withPermit(
8004 Effect.gen(function* () {
8005 yield* awaitRuntimeTeardown();
8006 useProviderThreadIdentity(forkInput.sourceProviderThread);
8007 yield* restartRuntimeAfterTeardownIfRequired(
8008 forkInput.sourceProviderThread.appThreadId,
8009 );
8010 if (!capabilities.threads.canForkThread) {
8011 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
8012 driver,
8013 detail: "ACP driver did not negotiate session/fork",
8014 });
8015 }
8016 if (forkInput.providerTurnId !== undefined) {
8017 return yield* new ProviderAdapter.ProviderAdapterProtocolError({
8018 driver,
8019 detail: "ACP session/fork can only fork the current session head",
8020 });
8021 }
8022 const sourceSessionId = yield* nativeThreadId(
8023 driver,
8024 forkInput.sourceProviderThread,
8025 );
8026 const mcpContext = yield* readMcpContext(forkInput.targetThreadId);
8027 prepareTerminalEnvironment(mcpContext);
8028 const forked = yield* runtime.forkSession(
8029 sourceSessionId,
8030 acpMcpActivation(mcpContext),
8031 );
8032 rememberTerminalEnvironment(
8033 forked.sessionId,
8034 yield* readMcpContext(forkInput.targetThreadId),
8035 );
8036 yield* Ref.set(activeSessionId, forked.sessionId);
8037 yield* Ref.set(activeSessionSetup, forked);
8038 yield* Ref.set(activeSelection, null);
8039 yield* Ref.set(activeInteractionMode, null);
8040 itemIdentityVersion = 2;
8041 const now = yield* DateTime.now;
8042 const providerThread = makeProviderThread({
8043 driver,
8044 providerInstanceId: options.instanceId,
8045 idAllocator,
8046 appThreadId: forkInput.targetThreadId,
8047 providerSessionId: input.providerSessionId,
8048 nativeThreadId: forked.sessionId,
8049 itemIdentityVersion: 2,
8050 ...(forkInput.ownerNodeId === undefined
8051 ? {}
8052 : { ownerNodeId: forkInput.ownerNodeId }),
8053 forkedFrom: {
8054 providerThreadId: forkInput.sourceProviderThread.id,
8055 ...(forkInput.providerTurnId === undefined
8056 ? {}
8057 : { providerTurnId: forkInput.providerTurnId }),
8058 },
8059 now,
8060 });
8061 yield* Ref.update(providerThreadByNativeSessionId, (current) =>
8062 new Map(current).set(forked.sessionId, providerThread),
8063 );
8064 return providerThread;
8065 }),
8066 );
8067 },
8068 (effect, forkInput) =>
8069 effect.pipe(
8070 Effect.mapError(
8071 (cause) =>
8072 new ProviderAdapter.ProviderAdapterForkThreadError({
8073 driver,
8074 providerThreadId: forkInput.sourceProviderThread.id,
8075 cause,
8076 }),
8077 ),
8078 ),
8079 ),
8080 };
8081 return sessionRuntime;
8082 },
8083 (effect, input) =>
8084 effect.pipe(
8085 Effect.mapError(
8086 (cause) =>
8087 new ProviderAdapter.ProviderAdapterOpenSessionError({
8088 driver,
8089 providerSessionId: input.providerSessionId,
8090 cause,
8091 }),
8092 ),
8093 ),
8094 ),
8095 });
8096});
8097
8098export type AcpAdapterV2Env =
8099 | FileSystem.FileSystem
8100 | IdAllocator.IdAllocatorV2
8101 | ScriptProgress.ScriptProgress
8102 | McpProviderSessions.McpProviderSessions;