Integrate the current Grok settlement, monitor/subagent lifecycle, interrupt containment, steer visibility, and image support stack into the orchestration-v2 branch.
Unified · Split
| @@ -82,6 +82,16 @@ jobs: · 81 unmodified lines | ||
| 82 | 82 | !/.repos/ |
| 83 | 83 | sparse-checkout-cone-mode: false |
| 84 | 84 | |
| 85 | + # Blacksmith boots GitHub's Ubuntu runner image (gcc is usually present), | |
| 86 | + # but ACP process-tree live tests compile a small pthread fixture with `cc` | |
| 87 | + # and soft-skip when it is missing. Install build-essential so that path | |
| 88 | + # always runs in CI instead of silently no-oping. | |
| 89 | + - name: Install C toolchain for process-tree fixtures | |
| 90 | + run: | | |
| 91 | + sudo apt-get update | |
| 92 | + sudo apt-get install -y --no-install-recommends build-essential | |
| 93 | + command -v cc | |
| 94 | + | |
| 85 | 95 | - name: Setup Vite+ |
| 86 | 96 | uses: voidzero-dev/setup-vp@v1 |
| 87 | 97 | with: |
| @@ -16,6 +16,7 @@ import { useV2ItemSupport } from "../../state/v2-item-support"; · 15 unmodified lines | ||
| 16 | 16 | |
| 17 | 17 | export function ThreadActivityInspector(props: { |
| 18 | 18 | readonly activity: ThreadFeedActivity; |
| 19 | + readonly currentThreadId: ThreadId; | |
| 19 | 20 | readonly environmentId: EnvironmentId; |
| 20 | 21 | readonly iconColor: ColorValue; |
| 21 | 22 | readonly workspaceRoot?: string | null; |
| @@ -28,8 +29,8 @@ export function ThreadActivityInspector(props: { · 6 unmodified lines | ||
| 28 | 29 | sourceItemId: row.sourceItemId, |
| 29 | 30 | }); |
| 30 | 31 | const model = useMemo( |
| 31 | − () => buildThreadActivityInspector(props.activity, support), | |
| 32 | − [props.activity, support], | |
| 32 | + () => buildThreadActivityInspector(props.activity, support, props.currentThreadId), | |
| 33 | + [props.activity, props.currentThreadId, support], | |
| 33 | 34 | ); |
| 34 | 35 | const revertCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, { |
| 35 | 36 | label: "checkpoint rollback", |
| @@ -1055,7 +1055,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 1054 unmodified lines | ||
| 1055 | 1055 | |
| 1056 | 1056 | function renderFeedEntry( |
| 1057 | 1057 | info: { item: ThreadFeedEntry; index: number }, |
| 1058 | − props: Pick<ThreadFeedProps, "environmentId" | "skills" | "workspaceRoot"> & { | |
| 1058 | + props: Pick<ThreadFeedProps, "environmentId" | "skills" | "threadId" | "workspaceRoot"> & { | |
| 1059 | 1059 | readonly copiedRowId: string | null; |
| 1060 | 1060 | readonly expandedWorkRows: Record<string, boolean>; |
| 1061 | 1061 | readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; |
| @@ -1298,6 +1298,7 @@ function renderFeedEntry( · 236 unmodified lines | ||
| 1298 | 1298 | activities={entry.activities} |
| 1299 | 1299 | anchorKey={entry.id} |
| 1300 | 1300 | copiedRowId={props.copiedRowId} |
| 1301 | + currentThreadId={props.threadId} | |
| 1301 | 1302 | environmentId={props.environmentId} |
| 1302 | 1303 | expanded={props.expandedWorkGroups[entry.id] ?? false} |
| 1303 | 1304 | expandedRows={props.expandedWorkRows} |
| @@ -2117,6 +2118,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 813 unmodified lines | ||
| 2117 | 2118 | (info: { item: ThreadFeedEntry; index: number }) => |
| 2118 | 2119 | renderFeedEntry(info, { |
| 2119 | 2120 | environmentId: props.environmentId, |
| 2121 | + threadId: props.threadId, | |
| 2120 | 2122 | copiedRowId, |
| 2121 | 2123 | expandedWorkRows, |
| 2122 | 2124 | terminalAssistantMessageIds, |
| @@ -2158,6 +2160,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 35 unmodified lines | ||
| 2158 | 2160 | onToggleWorkGroup, |
| 2159 | 2161 | onToggleWorkRow, |
| 2160 | 2162 | props.environmentId, |
| 2163 | + props.threadId, | |
| 2161 | 2164 | props.threadTitle, |
| 2162 | 2165 | props.skills, |
| 2163 | 2166 | props.workspaceRoot, |
| @@ -93,7 +93,7 @@ describe("buildThreadActivityInspector", () => { · 92 unmodified lines | ||
| 93 | 93 | } as never, |
| 94 | 94 | }; |
| 95 | 95 | |
| 96 | − const model = buildThreadActivityInspector(activityFor(item), support); | |
| 96 | + const model = buildThreadActivityInspector(activityFor(item), support, sourceThreadId); | |
| 97 | 97 | expect(model.fields).toEqual( |
| 98 | 98 | expect.arrayContaining([ |
| 99 | 99 | { label: "Duration", value: "2.0s" }, |
| @@ -119,7 +119,11 @@ describe("buildThreadActivityInspector", () => { · 19 unmodified lines | ||
| 119 | 119 | pattern: "resolveSession", |
| 120 | 120 | results: [{ fileName: "src/session.ts", line: 42, preview: "function resolveSession()" }], |
| 121 | 121 | }; |
| 122 | − const fileModel = buildThreadActivityInspector(activityFor(fileSearch), EMPTY_V2_ITEM_SUPPORT); | |
| 122 | + const fileModel = buildThreadActivityInspector( | |
| 123 | + activityFor(fileSearch), | |
| 124 | + EMPTY_V2_ITEM_SUPPORT, | |
| 125 | + sourceThreadId, | |
| 126 | + ); | |
| 123 | 127 | expect(fileModel.fileLinks).toEqual([ |
| 124 | 128 | { |
| 125 | 129 | label: "src/session.ts — function resolveSession()", |
| @@ -136,7 +140,11 @@ describe("buildThreadActivityInspector", () => { · 10 unmodified lines | ||
| 136 | 140 | { title: "Schema", url: "https://effect.website/docs/schema", snippet: "Typed schemas" }, |
| 137 | 141 | ], |
| 138 | 142 | }; |
| 139 | − const webModel = buildThreadActivityInspector(activityFor(webSearch), EMPTY_V2_ITEM_SUPPORT); | |
| 143 | + const webModel = buildThreadActivityInspector( | |
| 144 | + activityFor(webSearch), | |
| 145 | + EMPTY_V2_ITEM_SUPPORT, | |
| 146 | + sourceThreadId, | |
| 147 | + ); | |
| 140 | 148 | expect(webModel.webLinks).toEqual([ |
| 141 | 149 | { label: "Schema", url: "https://effect.website/docs/schema" }, |
| 142 | 150 | ]); |
| @@ -156,6 +164,7 @@ describe("buildThreadActivityInspector", () => { · 13 unmodified lines | ||
| 156 | 164 | const dynamicModel = buildThreadActivityInspector( |
| 157 | 165 | activityFor(dynamicTool), |
| 158 | 166 | EMPTY_V2_ITEM_SUPPORT, |
| 167 | + sourceThreadId, | |
| 159 | 168 | ); |
| 160 | 169 | expect(dynamicModel.blocks).toEqual( |
| 161 | 170 | expect.arrayContaining([ |
| @@ -177,11 +186,15 @@ describe("buildThreadActivityInspector", () => { · 15 unmodified lines | ||
| 177 | 186 | files: [{ path: "src/main.ts", kind: "modified", additions: 2, deletions: 1 }], |
| 178 | 187 | }; |
| 179 | 188 | const checkpoint = { id: checkpointId, scopeId, status: "ready" } as never; |
| 180 | − const model = buildThreadActivityInspector(activityFor(item), { | |
| 181 | − ...EMPTY_V2_ITEM_SUPPORT, | |
| 182 | − item, | |
| 183 | − checkpoint, | |
| 184 | − }); | |
| 189 | + const model = buildThreadActivityInspector( | |
| 190 | + activityFor(item), | |
| 191 | + { | |
| 192 | + ...EMPTY_V2_ITEM_SUPPORT, | |
| 193 | + item, | |
| 194 | + checkpoint, | |
| 195 | + }, | |
| 196 | + sourceThreadId, | |
| 197 | + ); | |
| 185 | 198 | |
| 186 | 199 | expect(model.canRollback).toBe(true); |
| 187 | 200 | expect(model.rollbackTarget).toEqual({ |
| @@ -189,6 +202,18 @@ describe("buildThreadActivityInspector", () => { · 1 unmodified line | ||
| 189 | 202 | checkpointId, |
| 190 | 203 | scopeId, |
| 191 | 204 | }); |
| 205 | + | |
| 206 | + const inheritedModel = buildThreadActivityInspector( | |
| 207 | + activityFor(item), | |
| 208 | + { | |
| 209 | + ...EMPTY_V2_ITEM_SUPPORT, | |
| 210 | + item, | |
| 211 | + checkpoint, | |
| 212 | + }, | |
| 213 | + ThreadId.make("child-thread"), | |
| 214 | + ); | |
| 215 | + expect(inheritedModel.canRollback).toBe(false); | |
| 216 | + expect(inheritedModel.rollbackTarget).toBeNull(); | |
| 192 | 217 | }); |
| 193 | 218 | |
| 194 | 219 | it("prefers live subagent progress from supporting state", () => { |
| @@ -204,16 +229,20 @@ describe("buildThreadActivityInspector", () => { · 9 unmodified lines | ||
| 204 | 229 | progress: "Starting", |
| 205 | 230 | result: null, |
| 206 | 231 | }; |
| 207 | − const model = buildThreadActivityInspector(activityFor(item), { | |
| 208 | − ...EMPTY_V2_ITEM_SUPPORT, | |
| 209 | − item, | |
| 210 | − subagent: { | |
| 211 | − origin: "provider_native", | |
| 212 | − status: "running", | |
| 213 | − progress: "Reading projection tests", | |
| 214 | − result: null, | |
| 215 | − } as never, | |
| 216 | − }); | |
| 232 | + const model = buildThreadActivityInspector( | |
| 233 | + activityFor(item), | |
| 234 | + { | |
| 235 | + ...EMPTY_V2_ITEM_SUPPORT, | |
| 236 | + item, | |
| 237 | + subagent: { | |
| 238 | + origin: "provider_native", | |
| 239 | + status: "running", | |
| 240 | + progress: "Reading projection tests", | |
| 241 | + result: null, | |
| 242 | + } as never, | |
| 243 | + }, | |
| 244 | + sourceThreadId, | |
| 245 | + ); | |
| 217 | 246 | |
| 218 | 247 | expect(model.fields).toContainEqual({ |
| 219 | 248 | label: "Delegated task", |
| @@ -73,6 +73,7 @@ function addBlock( · 72 unmodified lines | ||
| 73 | 73 | export function buildThreadActivityInspector( |
| 74 | 74 | activity: ThreadFeedActivity, |
| 75 | 75 | support: V2ItemSupport, |
| 76 | + currentThreadId: ThreadId, | |
| 76 | 77 | ): ThreadActivityInspectorModel { |
| 77 | 78 | const row = activity.projectedItem; |
| 78 | 79 | const item = row.item; |
| @@ -279,7 +280,10 @@ export function buildThreadActivityInspector( · 200 unmodified lines | ||
| 279 | 280 | break; |
| 280 | 281 | } |
| 281 | 282 | |
| 282 | − const checkpoint = item.type === "checkpoint" ? support.checkpoint : null; | |
| 283 | + const checkpoint = | |
| 284 | + item.type === "checkpoint" && row.sourceThreadId === currentThreadId | |
| 285 | + ? support.checkpoint | |
| 286 | + : null; | |
| 283 | 287 | return { |
| 284 | 288 | fields, |
| 285 | 289 | blocks, |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | #!/usr/bin/env node |
| 2 | 2 | // @effect-diagnostics nodeBuiltinImport:off |
| 3 | +import * as NodeChildProcess from "node:child_process"; | |
| 3 | 4 | import * as NodeFS from "node:fs"; |
| 4 | 5 | |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| @@ -17,21 +18,36 @@ const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; · 11 unmodified lines | ||
| 17 | 18 | const emitInterleavedAssistantToolCalls = |
| 18 | 19 | process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; |
| 19 | 20 | const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; |
| 21 | +const emitPostSettleMonitorFlow = process.env.T3_ACP_EMIT_POST_SETTLE_MONITOR_FLOW === "1"; | |
| 22 | +const emitInTurnTaskOutputThenLateDuplicate = | |
| 23 | + process.env.T3_ACP_EMIT_IN_TURN_TASKOUTPUT_THEN_LATE_DUPLICATE === "1"; | |
| 24 | +const injectedReportTriggerPath = process.env.T3_ACP_INJECTED_REPORT_TRIGGER_PATH; | |
| 20 | 25 | const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; |
| 26 | +const emitElicitation = process.env.T3_ACP_EMIT_ELICITATION === "1"; | |
| 27 | +const emitUrlElicitation = process.env.T3_ACP_EMIT_URL_ELICITATION === "1"; | |
| 21 | 28 | const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; |
| 22 | −const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1"; | |
| 23 | −const emitXAiPlanMdWrite = process.env.T3_ACP_EMIT_XAI_PLAN_MD_WRITE === "1"; | |
| 24 | 29 | const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; |
| 25 | −const emitXAiRateLimitThenHang = process.env.T3_ACP_EMIT_XAI_RATE_LIMIT_THEN_HANG === "1"; | |
| 26 | −const emitXAiAskUserQuestionThenHang = | |
| 27 | − process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG === "1"; | |
| 28 | −const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1"; | |
| 29 | −const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; | |
| 30 | −const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; | |
| 31 | 30 | const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; |
| 32 | 31 | const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; |
| 32 | +const hangAfterPermission = process.env.T3_ACP_HANG_AFTER_PERMISSION === "1"; | |
| 33 | 33 | const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; |
| 34 | 34 | const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1"; |
| 35 | +const emitTaskBackgroundedAfterCancel = | |
| 36 | + process.env.T3_ACP_EMIT_TASK_BACKGROUNDED_AFTER_CANCEL === "1"; | |
| 37 | +const residualCallbackResponseLogPath = process.env.T3_ACP_RESIDUAL_CALLBACK_RESPONSE_LOG_PATH; | |
| 38 | +const residualCallbackTriggerPath = process.env.T3_ACP_RESIDUAL_CALLBACK_TRIGGER_PATH; | |
| 39 | +const exitAfterResidualCallbacks = process.env.T3_ACP_EXIT_AFTER_RESIDUAL_CALLBACKS === "1"; | |
| 40 | +const emitRunningCommandThenHang = process.env.T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG === "1"; | |
| 41 | +const emitRunningCommandThenHangOnFirstPrompt = | |
| 42 | + process.env.T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG_FIRST_PROMPT === "1"; | |
| 43 | +const emitEmptySuccessfulBash = process.env.T3_ACP_EMIT_EMPTY_SUCCESSFUL_BASH === "1"; | |
| 44 | +const emitEmptySuccessfulBashThenHang = | |
| 45 | + process.env.T3_ACP_EMIT_EMPTY_SUCCESSFUL_BASH_THEN_HANG === "1"; | |
| 46 | +const exitOnCancel = process.env.T3_ACP_EXIT_ON_CANCEL === "1"; | |
| 47 | +const runningCommandIgnoresTerm = process.env.T3_ACP_RUNNING_COMMAND_IGNORE_TERM === "1"; | |
| 48 | +const runningCommandPidPath = process.env.T3_ACP_RUNNING_COMMAND_PID_PATH; | |
| 49 | +const runningCommandSeparateSession = process.env.T3_ACP_RUNNING_COMMAND_SEPARATE_SESSION === "1"; | |
| 50 | +const exitAfterRunningCommandLaunch = process.env.T3_ACP_EXIT_AFTER_RUNNING_COMMAND_LAUNCH === "1"; | |
| 35 | 51 | const omitXAiPromptCompleteStopReason = |
| 36 | 52 | process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1"; |
| 37 | 53 | const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1"; |
| @@ -47,8 +63,6 @@ const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; · 9 unmodified lines | ||
| 47 | 63 | const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; |
| 48 | 64 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; |
| 49 | 65 | const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; |
| 50 | −const initialGrokReasoningEffort = | |
| 51 | − process.env.T3_ACP_INITIAL_GROK_REASONING_EFFORT?.trim() || undefined; | |
| 52 | 66 | const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); |
| 53 | 67 | const supportsSessionLifecycle = process.env.T3_ACP_SESSION_LIFECYCLE === "1"; |
| 54 | 68 | const advertisedAuthMethodId = process.env.T3_ACP_AUTH_METHOD_ID?.trim(); |
| @@ -58,11 +72,6 @@ const permissionOptionIds = { · 3 unmodified lines | ||
| 58 | 72 | allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always", |
| 59 | 73 | rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once", |
| 60 | 74 | }; |
| 61 | −const omitAllowAlways = process.env.T3_ACP_OMIT_ALLOW_ALWAYS === "1"; | |
| 62 | −const permissionRequestCount = Math.max( | |
| 63 | − 1, | |
| 64 | − Number(process.env.T3_ACP_PERMISSION_REQUEST_COUNT ?? "1") || 1, | |
| 65 | −); | |
| 66 | 75 | const sessionId = "mock-session-1"; |
| 67 | 76 | |
| 68 | 77 | let currentModeId = "ask"; |
| @@ -81,6 +90,11 @@ function logExit(reason: string): void { · 12 unmodified lines | ||
| 81 | 90 | appendFileSync(exitLogPath, `${reason}\n`, "utf8"); |
| 82 | 91 | } |
| 83 | 92 | |
| 93 | +function logResidualCallbackResponse(kind: string): void { | |
| 94 | + if (!residualCallbackResponseLogPath) return; | |
| 95 | + NodeFS.appendFileSync(residualCallbackResponseLogPath, `${kind}\n`, "utf8"); | |
| 96 | +} | |
| 97 | + | |
| 84 | 98 | process.once("SIGTERM", () => { |
| 85 | 99 | logExit("SIGTERM"); |
| 86 | 100 | process.exit(0); |
| @@ -254,20 +268,7 @@ function modeState(): AcpSchema.SessionModeState { · 167 unmodified lines | ||
| 254 | 268 | // Mirrors the real Grok ACP: it advertises versioned model ids, never the CLI's own |
| 255 | 269 | // "grok-build" product name, and it rejects unknown ids in session/set_model. |
| 256 | 270 | const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [ |
| 257 | − { | |
| 258 | − modelId: "grok-4.6", | |
| 259 | − name: "Grok 4.6", | |
| 260 | − _meta: { | |
| 261 | − totalContextTokens: 500_000, | |
| 262 | − supportsReasoningEffort: true, | |
| 263 | − reasoningEffort: initialGrokReasoningEffort ?? "high", | |
| 264 | − reasoningEfforts: [ | |
| 265 | − { id: "xhigh", value: "xhigh", label: "Extra High Effort", default: false }, | |
| 266 | − { id: "high", value: "high", label: "High Effort", default: true }, | |
| 267 | − { id: "low", value: "low", label: "Low Effort", default: false }, | |
| 268 | − ], | |
| 269 | − }, | |
| 270 | − }, | |
| 271 | + { modelId: "grok-build", name: "Grok Build" }, | |
| 271 | 272 | { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, |
| 272 | 273 | ]; |
| 273 | 274 | |
| @@ -491,8 +492,54 @@ const program = Effect.gen(function* () { · 217 unmodified lines | ||
| 491 | 492 | ); |
| 492 | 493 | |
| 493 | 494 | yield* agent.handleCancel(({ sessionId }) => |
| 494 | − Effect.sync(() => { | |
| 495 | − cancelledSessions.add(String(sessionId ?? "mock-session-1")); | |
| 495 | + Effect.gen(function* () { | |
| 496 | + const cancelledSessionId = String(sessionId ?? "mock-session-1"); | |
| 497 | + cancelledSessions.add(cancelledSessionId); | |
| 498 | + if (exitOnCancel) { | |
| 499 | + return yield* Effect.sync(() => process.exit(0)); | |
| 500 | + } | |
| 501 | + if (emitLateUpdateAfterCancel) { | |
| 502 | + yield* Effect.sleep("50 millis"); | |
| 503 | + yield* Effect.sync(() => { | |
| 504 | + writeJsonRpcNotification("session/update", { | |
| 505 | + sessionId: cancelledSessionId, | |
| 506 | + update: { | |
| 507 | + sessionUpdate: "agent_message_chunk", | |
| 508 | + content: { type: "text", text: "late after cancel" }, | |
| 509 | + }, | |
| 510 | + }); | |
| 511 | + }); | |
| 512 | + } | |
| 513 | + if (emitTaskBackgroundedAfterCancel) { | |
| 514 | + // Grok cancel-as-detach: the foreground command is re-run as a | |
| 515 | + // background task that later completes on its own. | |
| 516 | + yield* Effect.sync(() => { | |
| 517 | + writeJsonRpcNotification("_x.ai/task_backgrounded", { | |
| 518 | + sessionId: cancelledSessionId, | |
| 519 | + update: { | |
| 520 | + sessionUpdate: "task_backgrounded", | |
| 521 | + tool_call_id: "task-bg-1", | |
| 522 | + task_id: "task-bg-1", | |
| 523 | + command: "sleep 30", | |
| 524 | + }, | |
| 525 | + }); | |
| 526 | + }); | |
| 527 | + yield* Effect.sleep("1200 millis") | |
| 528 | + .pipe( | |
| 529 | + Effect.andThen( | |
| 530 | + Effect.sync(() => { | |
| 531 | + writeJsonRpcNotification("_x.ai/task_completed", { | |
| 532 | + sessionId: cancelledSessionId, | |
| 533 | + update: { | |
| 534 | + sessionUpdate: "task_completed", | |
| 535 | + task_snapshot: { task_id: "task-bg-1", command: "sleep 30" }, | |
| 536 | + }, | |
| 537 | + }); | |
| 538 | + }), | |
| 539 | + ), | |
| 540 | + ) | |
| 541 | + .pipe(Effect.forkDetach); | |
| 542 | + } | |
| 496 | 543 | }), |
| 497 | 544 | ); |
| 498 | 545 | |
| @@ -501,6 +548,80 @@ const program = Effect.gen(function* () { · 2 unmodified lines | ||
| 501 | 548 | const requestedSessionId = String(request.sessionId ?? sessionId); |
| 502 | 549 | promptCount += 1; |
| 503 | 550 | |
| 551 | + if (residualCallbackTriggerPath !== undefined) { | |
| 552 | + yield* Effect.gen(function* () { | |
| 553 | + while (!(yield* Effect.sync(() => NodeFS.existsSync(residualCallbackTriggerPath)))) { | |
| 554 | + yield* Effect.sleep("20 millis"); | |
| 555 | + } | |
| 556 | + yield* Effect.sync(() => { | |
| 557 | + writeJsonRpcNotification("session/update", { | |
| 558 | + sessionId: requestedSessionId, | |
| 559 | + update: { | |
| 560 | + sessionUpdate: "agent_message_chunk", | |
| 561 | + content: { type: "text", text: "residual assistant callback" }, | |
| 562 | + }, | |
| 563 | + }); | |
| 564 | + writeJsonRpcNotification("session/update", { | |
| 565 | + sessionId: requestedSessionId, | |
| 566 | + update: { | |
| 567 | + sessionUpdate: "tool_call", | |
| 568 | + toolCallId: "residual-tool-call", | |
| 569 | + title: "Residual tool callback", | |
| 570 | + kind: "other", | |
| 571 | + status: "pending", | |
| 572 | + rawInput: {}, | |
| 573 | + }, | |
| 574 | + }); | |
| 575 | + writeJsonRpcNotification("session/update", { | |
| 576 | + sessionId: requestedSessionId, | |
| 577 | + update: { | |
| 578 | + sessionUpdate: "plan", | |
| 579 | + entries: [ | |
| 580 | + { content: "Residual plan callback", priority: "high", status: "pending" }, | |
| 581 | + ], | |
| 582 | + }, | |
| 583 | + }); | |
| 584 | + }); | |
| 585 | + yield* agent.client | |
| 586 | + .requestPermission({ | |
| 587 | + sessionId: requestedSessionId, | |
| 588 | + toolCall: { | |
| 589 | + toolCallId: "residual-permission", | |
| 590 | + title: "Residual permission callback", | |
| 591 | + }, | |
| 592 | + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], | |
| 593 | + }) | |
| 594 | + .pipe( | |
| 595 | + Effect.exit, | |
| 596 | + Effect.tap(() => Effect.sync(() => logResidualCallbackResponse("permission"))), | |
| 597 | + Effect.ignore, | |
| 598 | + Effect.forkDetach, | |
| 599 | + ); | |
| 600 | + yield* agent.client | |
| 601 | + .elicit({ | |
| 602 | + sessionId: requestedSessionId, | |
| 603 | + message: "Residual elicitation callback", | |
| 604 | + mode: "form", | |
| 605 | + requestedSchema: { | |
| 606 | + type: "object", | |
| 607 | + properties: { | |
| 608 | + approved: { type: "boolean", title: "Approved" }, | |
| 609 | + }, | |
| 610 | + }, | |
| 611 | + }) | |
| 612 | + .pipe( | |
| 613 | + Effect.exit, | |
| 614 | + Effect.tap(() => Effect.sync(() => logResidualCallbackResponse("elicitation"))), | |
| 615 | + Effect.ignore, | |
| 616 | + Effect.forkDetach, | |
| 617 | + ); | |
| 618 | + if (exitAfterResidualCallbacks) { | |
| 619 | + yield* Effect.sleep("100 millis"); | |
| 620 | + return yield* Effect.sync(() => process.exit(0)); | |
| 621 | + } | |
| 622 | + }).pipe(Effect.forkDetach); | |
| 623 | + } | |
| 624 | + | |
| 504 | 625 | if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) { |
| 505 | 626 | yield* Effect.sleep(`${promptDelayMs} millis`); |
| 506 | 627 | } |
| @@ -562,70 +683,124 @@ const program = Effect.gen(function* () { · 55 unmodified lines | ||
| 562 | 683 | return yield* Effect.never; |
| 563 | 684 | } |
| 564 | 685 | |
| 565 | − if (hangPromptForever || (hangFirstPromptForever && promptCount === 1)) { | |
| 686 | + if ( | |
| 687 | + hangPromptForever || | |
| 688 | + (hangFirstPromptForever && promptCount === 1) || | |
| 689 | + (emitEmptySuccessfulBashThenHang && promptCount === 2) | |
| 690 | + ) { | |
| 566 | 691 | return yield* Effect.never; |
| 567 | 692 | } |
| 568 | 693 | |
| 569 | − if (emitXAiRateLimitThenHang) { | |
| 570 | − writeJsonRpcNotification("_x.ai/session/prompt_complete", { | |
| 571 | − sessionId: requestedSessionId, | |
| 572 | − promptId: promptIdFromRequestMeta(request) ?? "mock-xai-rate-limit-prompt-1", | |
| 573 | − stopReason: "rate_limit", | |
| 574 | − agentResult: null, | |
| 575 | − }); | |
| 576 | − return yield* Effect.never; | |
| 577 | − } | |
| 578 | − | |
| 579 | − if (emitContentThenHang) { | |
| 694 | + if ( | |
| 695 | + emitRunningCommandThenHang || | |
| 696 | + (emitRunningCommandThenHangOnFirstPrompt && promptCount === 1) | |
| 697 | + ) { | |
| 698 | + const toolCallId = "tool-call-running-1"; | |
| 699 | + if (runningCommandPidPath !== undefined) { | |
| 700 | + const command = runningCommandIgnoresTerm | |
| 701 | + ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s\\n" "$$" "$child" > "$1"; wait "$child"' | |
| 702 | + : 'sleep 120 & child=$!; printf "%s %s\\n" "$$" "$child" > "$1"; wait "$child"'; | |
| 703 | + if (runningCommandSeparateSession) { | |
| 704 | + const launcher = [ | |
| 705 | + 'const { spawn } = require("node:child_process");', | |
| 706 | + "const child = spawn(process.argv[1], process.argv.slice(2), { stdio: 'ignore' });", | |
| 707 | + "child.once('exit', (code, signal) => process.exitCode = code ?? (signal ? 1 : 0));", | |
| 708 | + ].join(" "); | |
| 709 | + const detachedCommand = runningCommandIgnoresTerm | |
| 710 | + ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s %s\\n" "$PPID" "$$" "$child" > "$1"; wait "$child"' | |
| 711 | + : 'sleep 120 & child=$!; printf "%s %s %s\\n" "$PPID" "$$" "$child" > "$1"; wait "$child"'; | |
| 712 | + // Nested bash publishes "$PPID $$ $child" once it starts. Do not | |
| 713 | + // write the launcher PID alone here: that races with bash and can | |
| 714 | + // clobber the triple that interrupt tests wait for. | |
| 715 | + const detachedLauncher = NodeChildProcess.spawn( | |
| 716 | + process.execPath, | |
| 717 | + ["-e", launcher, "bash", "-c", detachedCommand, "bash", runningCommandPidPath], | |
| 718 | + { detached: true, stdio: "ignore" }, | |
| 719 | + ); | |
| 720 | + detachedLauncher.unref(); | |
| 721 | + } else { | |
| 722 | + NodeChildProcess.spawn("bash", ["-c", command, "bash", runningCommandPidPath], { | |
| 723 | + stdio: "ignore", | |
| 724 | + }); | |
| 725 | + } | |
| 726 | + } | |
| 580 | 727 | yield* agent.client.sessionUpdate({ |
| 581 | 728 | sessionId: requestedSessionId, |
| 582 | 729 | update: { |
| 583 | − sessionUpdate: "agent_message_chunk", | |
| 584 | − content: { type: "text", text: "partial before stall" }, | |
| 730 | + sessionUpdate: "tool_call", | |
| 731 | + toolCallId, | |
| 732 | + title: "Terminal", | |
| 733 | + kind: "execute", | |
| 734 | + status: "pending", | |
| 735 | + rawInput: { | |
| 736 | + command: ["sleep", "120"], | |
| 737 | + }, | |
| 585 | 738 | }, |
| 586 | 739 | }); |
| 587 | − return yield* Effect.never; | |
| 588 | − } | |
| 589 | − | |
| 590 | − if (emitPlanThenHang) { | |
| 591 | 740 | yield* agent.client.sessionUpdate({ |
| 592 | 741 | sessionId: requestedSessionId, |
| 593 | 742 | update: { |
| 594 | − sessionUpdate: "plan", | |
| 595 | − entries: [ | |
| 596 | − { | |
| 597 | − content: "Wait for more ACP progress", | |
| 598 | − priority: "high", | |
| 599 | − status: "in_progress", | |
| 600 | − }, | |
| 601 | − ], | |
| 743 | + sessionUpdate: "tool_call_update", | |
| 744 | + toolCallId, | |
| 745 | + title: "Terminal", | |
| 746 | + kind: "execute", | |
| 747 | + status: "in_progress", | |
| 748 | + rawInput: { | |
| 749 | + command: ["sleep", "120"], | |
| 750 | + }, | |
| 751 | + // Grok-like mid-stream Bash re-report: exit_code 0 while still running. | |
| 752 | + rawOutput: { type: "Bash", exit_code: 0 }, | |
| 602 | 753 | }, |
| 603 | 754 | }); |
| 604 | − return yield* Effect.never; | |
| 605 | − } | |
| 606 | − | |
| 607 | − if (emitActiveToolThenHang) { | |
| 608 | − const toolCallId = "tool-call-long-running-1"; | |
| 609 | 755 | yield* agent.client.sessionUpdate({ |
| 610 | 756 | sessionId: requestedSessionId, |
| 611 | 757 | update: { |
| 612 | 758 | sessionUpdate: "tool_call", |
| 613 | − toolCallId, | |
| 614 | − title: "Long-running tool", | |
| 615 | − kind: "execute", | |
| 759 | + toolCallId: "tool-call-output-1", | |
| 760 | + title: "get_command_or_subagent_output", | |
| 761 | + kind: "other", | |
| 616 | 762 | status: "pending", |
| 617 | − rawInput: { command: ["long-running-tool"] }, | |
| 763 | + rawInput: { task_id: "task-running-1" }, | |
| 618 | 764 | }, |
| 619 | 765 | }); |
| 620 | 766 | yield* agent.client.sessionUpdate({ |
| 621 | 767 | sessionId: requestedSessionId, |
| 622 | 768 | update: { |
| 623 | 769 | sessionUpdate: "tool_call_update", |
| 624 | − toolCallId, | |
| 770 | + toolCallId: "tool-call-output-1", | |
| 625 | 771 | status: "in_progress", |
| 772 | + rawOutput: { task_id: "task-running-1", status: "running" }, | |
| 626 | 773 | }, |
| 627 | 774 | }); |
| 628 | − return yield* Effect.never; | |
| 775 | + if (exitAfterRunningCommandLaunch) { | |
| 776 | + yield* Effect.sleep("100 millis"); | |
| 777 | + return yield* Effect.sync(() => process.exit(0)); | |
| 778 | + } | |
| 779 | + // Stay open until session/cancel so interrupt tests can observe a running tool. | |
| 780 | + while (!cancelledSessions.has(requestedSessionId)) { | |
| 781 | + yield* Effect.sleep("25 millis"); | |
| 782 | + } | |
| 783 | + cancelledSessions.delete(requestedSessionId); | |
| 784 | + return { stopReason: "cancelled" }; | |
| 785 | + } | |
| 786 | + | |
| 787 | + if (emitEmptySuccessfulBash || (emitEmptySuccessfulBashThenHang && promptCount === 1)) { | |
| 788 | + const update = { | |
| 789 | + sessionId: requestedSessionId, | |
| 790 | + update: { | |
| 791 | + sessionUpdate: "tool_call_update", | |
| 792 | + toolCallId: "tool-call-empty-success-1", | |
| 793 | + title: "Terminal", | |
| 794 | + kind: "execute", | |
| 795 | + status: "completed", | |
| 796 | + rawInput: { command: "true" }, | |
| 797 | + rawOutput: { type: "Bash", exit_code: 0 }, | |
| 798 | + }, | |
| 799 | + } as const; | |
| 800 | + yield* agent.client.sessionUpdate(update); | |
| 801 | + yield* Effect.sleep("25 millis"); | |
| 802 | + yield* agent.client.sessionUpdate(update); | |
| 803 | + return { stopReason: "end_turn" }; | |
| 629 | 804 | } |
| 630 | 805 | |
| 631 | 806 | if (emitXAiPromptCompleteThenHang) { |
| @@ -736,6 +911,32 @@ const program = Effect.gen(function* () { · 104 unmodified lines | ||
| 736 | 911 | return { stopReason: "end_turn" }; |
| 737 | 912 | } |
| 738 | 913 | |
| 914 | + if (emitElicitation) { | |
| 915 | + yield* agent.client.elicit({ | |
| 916 | + sessionId: requestedSessionId, | |
| 917 | + message: "Approve this request?", | |
| 918 | + mode: "form", | |
| 919 | + requestedSchema: { | |
| 920 | + type: "object", | |
| 921 | + properties: { | |
| 922 | + approved: { type: "boolean", title: "Approved" }, | |
| 923 | + }, | |
| 924 | + }, | |
| 925 | + }); | |
| 926 | + return { stopReason: "end_turn" }; | |
| 927 | + } | |
| 928 | + | |
| 929 | + if (emitUrlElicitation) { | |
| 930 | + yield* agent.client.elicit({ | |
| 931 | + sessionId: requestedSessionId, | |
| 932 | + message: "Open authentication page", | |
| 933 | + mode: "url", | |
| 934 | + url: "https://example.com/auth", | |
| 935 | + elicitationId: "url-elicitation-1", | |
| 936 | + }); | |
| 937 | + return { stopReason: "end_turn" }; | |
| 938 | + } | |
| 939 | + | |
| 739 | 940 | if (emitToolCalls) { |
| 740 | 941 | const toolCallId = "tool-call-1"; |
| 741 | 942 | |
| @@ -762,57 +963,40 @@ const program = Effect.gen(function* () { · 20 unmodified lines | ||
| 762 | 963 | }, |
| 763 | 964 | }); |
| 764 | 965 | |
| 765 | − const permissionOptions: Array<AcpSchema.PermissionOption> = [ | |
| 766 | − { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" }, | |
| 767 | − ...(omitAllowAlways | |
| 768 | − ? [] | |
| 769 | − : [ | |
| 770 | − { | |
| 771 | − optionId: permissionOptionIds.allowAlways, | |
| 772 | − name: "Allow always", | |
| 773 | − kind: "allow_always" as const, | |
| 966 | + const permission = yield* agent.client.requestPermission({ | |
| 967 | + sessionId: requestedSessionId, | |
| 968 | + toolCall: { | |
| 969 | + toolCallId, | |
| 970 | + title: "`cat server/package.json`", | |
| 971 | + kind: "execute", | |
| 972 | + status: "pending", | |
| 973 | + content: [ | |
| 974 | + { | |
| 975 | + type: "content", | |
| 976 | + content: { | |
| 977 | + type: "text", | |
| 978 | + text: "Not in allowlist: cat server/package.json", | |
| 774 | 979 | }, |
| 775 | − ]), | |
| 776 | − { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" }, | |
| 777 | − ]; | |
| 778 | − | |
| 779 | − let cancelled = cancelledSessions.delete(requestedSessionId); | |
| 780 | − for (let index = 0; index < permissionRequestCount; index++) { | |
| 781 | − const command = | |
| 782 | − index > 0 | |
| 783 | − ? (process.env.T3_ACP_SECOND_PERMISSION_COMMAND ?? "cat server/package.json") | |
| 784 | − : "cat server/package.json"; | |
| 785 | − const permission = yield* agent.client.requestPermission({ | |
| 786 | − sessionId: requestedSessionId, | |
| 787 | − toolCall: { | |
| 788 | − toolCallId: index === 0 ? toolCallId : `${toolCallId}-${index + 1}`, | |
| 789 | − title: process.env.T3_ACP_PERMISSION_TITLE ?? `\`${command}\``, | |
| 790 | − kind: "execute", | |
| 791 | − status: "pending", | |
| 792 | − rawInput: { | |
| 793 | − variant: "Bash", | |
| 794 | − command, | |
| 795 | − description: index === 0 ? "Read package metadata" : "Read it again", | |
| 796 | 980 | }, |
| 797 | − content: [ | |
| 798 | − { | |
| 799 | − type: "content", | |
| 800 | − content: { | |
| 801 | − type: "text", | |
| 802 | − text: `Not in allowlist: ${command}`, | |
| 803 | − }, | |
| 804 | − }, | |
| 805 | − ], | |
| 981 | + ], | |
| 982 | + }, | |
| 983 | + options: [ | |
| 984 | + { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" }, | |
| 985 | + { | |
| 986 | + optionId: permissionOptionIds.allowAlways, | |
| 987 | + name: "Allow always", | |
| 988 | + kind: "allow_always", | |
| 806 | 989 | }, |
| 807 | − options: permissionOptions, | |
| 808 | − }); | |
| 809 | − cancelled = | |
| 810 | − cancelled || | |
| 811 | − cancelledSessions.delete(requestedSessionId) || | |
| 812 | − permission.outcome.outcome === "cancelled"; | |
| 813 | − if (cancelled) { | |
| 814 | − break; | |
| 815 | − } | |
| 990 | + { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" }, | |
| 991 | + ], | |
| 992 | + }); | |
| 993 | + | |
| 994 | + const cancelled = | |
| 995 | + cancelledSessions.delete(requestedSessionId) || | |
| 996 | + permission.outcome.outcome === "cancelled"; | |
| 997 | + | |
| 998 | + if (hangAfterPermission) { | |
| 999 | + return yield* Effect.never; | |
| 816 | 1000 | } |
| 817 | 1001 | |
| 818 | 1002 | yield* agent.client.sessionUpdate({ |
| @@ -881,6 +1065,159 @@ const program = Effect.gen(function* () { · 62 unmodified lines | ||
| 881 | 1065 | return { stopReason: "end_turn" }; |
| 882 | 1066 | } |
| 883 | 1067 | |
| 1068 | + // In-turn monitor + TaskOutput hydrate, then a late post-finalize | |
| 1069 | + // duplicate terminal TaskOutput for the same task. Exercises the | |
| 1070 | + // already-handled short-circuit: must not pin hasPendingBackgroundWork. | |
| 1071 | + if (emitInTurnTaskOutputThenLateDuplicate) { | |
| 1072 | + const monitorToolCallId = "tool-call-monitor-1"; | |
| 1073 | + yield* agent.client.sessionUpdate({ | |
| 1074 | + sessionId: requestedSessionId, | |
| 1075 | + update: { | |
| 1076 | + sessionUpdate: "tool_call", | |
| 1077 | + toolCallId: monitorToolCallId, | |
| 1078 | + title: "Monitor: mock background task", | |
| 1079 | + kind: "execute", | |
| 1080 | + status: "pending", | |
| 1081 | + rawInput: {}, | |
| 1082 | + }, | |
| 1083 | + }); | |
| 1084 | + yield* agent.client.sessionUpdate({ | |
| 1085 | + sessionId: requestedSessionId, | |
| 1086 | + update: { | |
| 1087 | + sessionUpdate: "tool_call_update", | |
| 1088 | + toolCallId: monitorToolCallId, | |
| 1089 | + status: "in_progress", | |
| 1090 | + }, | |
| 1091 | + }); | |
| 1092 | + yield* agent.client.sessionUpdate({ | |
| 1093 | + sessionId: requestedSessionId, | |
| 1094 | + update: { | |
| 1095 | + sessionUpdate: "tool_call", | |
| 1096 | + toolCallId: "tool-call-fetch-1", | |
| 1097 | + title: "get_command_or_subagent_output", | |
| 1098 | + kind: "other", | |
| 1099 | + status: "pending", | |
| 1100 | + rawInput: { task_id: "task-monitor-1" }, | |
| 1101 | + }, | |
| 1102 | + }); | |
| 1103 | + yield* agent.client.sessionUpdate({ | |
| 1104 | + sessionId: requestedSessionId, | |
| 1105 | + update: { | |
| 1106 | + sessionUpdate: "tool_call_update", | |
| 1107 | + toolCallId: "tool-call-fetch-1", | |
| 1108 | + status: "completed", | |
| 1109 | + rawOutput: { output: "MONITOR_LISTING_TOKEN" }, | |
| 1110 | + }, | |
| 1111 | + }); | |
| 1112 | + yield* agent.client.sessionUpdate({ | |
| 1113 | + sessionId: requestedSessionId, | |
| 1114 | + update: { | |
| 1115 | + sessionUpdate: "agent_message_chunk", | |
| 1116 | + content: { type: "text", text: "Monitor listing ready in-turn." }, | |
| 1117 | + }, | |
| 1118 | + }); | |
| 1119 | + // After deferred finalize (~2s) clears activeTurn, re-emit a terminal | |
| 1120 | + // TaskOutput for the same task so bufferPostSettleWake sees | |
| 1121 | + // alreadyHandledToolUpdate with a non-empty wake path. | |
| 1122 | + yield* Effect.gen(function* () { | |
| 1123 | + yield* Effect.sleep("2500 millis"); | |
| 1124 | + yield* Effect.sync(() => { | |
| 1125 | + writeJsonRpcNotification("session/update", { | |
| 1126 | + sessionId: requestedSessionId, | |
| 1127 | + update: { | |
| 1128 | + sessionUpdate: "tool_call_update", | |
| 1129 | + toolCallId: "tool-call-fetch-1", | |
| 1130 | + title: "get_command_or_subagent_output", | |
| 1131 | + kind: "other", | |
| 1132 | + status: "completed", | |
| 1133 | + rawOutput: { output: "MONITOR_LISTING_TOKEN_LATE" }, | |
| 1134 | + }, | |
| 1135 | + }); | |
| 1136 | + }); | |
| 1137 | + }).pipe(Effect.forkDetach); | |
| 1138 | + return { stopReason: "end_turn" }; | |
| 1139 | + } | |
| 1140 | + | |
| 1141 | + if (emitPostSettleMonitorFlow) { | |
| 1142 | + const monitorToolCallId = "tool-call-monitor-1"; | |
| 1143 | + | |
| 1144 | + yield* agent.client.sessionUpdate({ | |
| 1145 | + sessionId: requestedSessionId, | |
| 1146 | + update: { | |
| 1147 | + sessionUpdate: "tool_call", | |
| 1148 | + toolCallId: monitorToolCallId, | |
| 1149 | + title: "Monitor: mock background task", | |
| 1150 | + kind: "execute", | |
| 1151 | + status: "pending", | |
| 1152 | + rawInput: {}, | |
| 1153 | + }, | |
| 1154 | + }); | |
| 1155 | + | |
| 1156 | + yield* agent.client.sessionUpdate({ | |
| 1157 | + sessionId: requestedSessionId, | |
| 1158 | + update: { | |
| 1159 | + sessionUpdate: "tool_call_update", | |
| 1160 | + toolCallId: monitorToolCallId, | |
| 1161 | + status: "in_progress", | |
| 1162 | + }, | |
| 1163 | + }); | |
| 1164 | + | |
| 1165 | + // After the prompt settles, replay the CLI-injected monitor-event | |
| 1166 | + // turn: end notice, TaskOutput hydration, then (once the trigger | |
| 1167 | + // file exists) the report chunk. Detached fiber on the real clock; | |
| 1168 | + // it outlives the prompt handler. | |
| 1169 | + yield* Effect.gen(function* () { | |
| 1170 | + yield* Effect.sleep("150 millis"); | |
| 1171 | + yield* Effect.sync(() => { | |
| 1172 | + writeJsonRpcNotification("session/update", { | |
| 1173 | + sessionId: requestedSessionId, | |
| 1174 | + update: { | |
| 1175 | + sessionUpdate: "user_message_chunk", | |
| 1176 | + content: { | |
| 1177 | + type: "text", | |
| 1178 | + text: 'Monitor "task-monitor-1" ended: [monitor ended: exit 0]', | |
| 1179 | + }, | |
| 1180 | + }, | |
| 1181 | + }); | |
| 1182 | + writeJsonRpcNotification("session/update", { | |
| 1183 | + sessionId: requestedSessionId, | |
| 1184 | + update: { | |
| 1185 | + sessionUpdate: "tool_call", | |
| 1186 | + toolCallId: "tool-call-fetch-1", | |
| 1187 | + title: "get_command_or_subagent_output", | |
| 1188 | + kind: "other", | |
| 1189 | + status: "pending", | |
| 1190 | + rawInput: { task_id: "task-monitor-1" }, | |
| 1191 | + }, | |
| 1192 | + }); | |
| 1193 | + writeJsonRpcNotification("session/update", { | |
| 1194 | + sessionId: requestedSessionId, | |
| 1195 | + update: { | |
| 1196 | + sessionUpdate: "tool_call_update", | |
| 1197 | + toolCallId: "tool-call-fetch-1", | |
| 1198 | + status: "completed", | |
| 1199 | + rawOutput: { output: "MONITOR_LISTING_TOKEN" }, | |
| 1200 | + }, | |
| 1201 | + }); | |
| 1202 | + }); | |
| 1203 | + if (injectedReportTriggerPath === undefined) return; | |
| 1204 | + while (!(yield* Effect.sync(() => NodeFS.existsSync(injectedReportTriggerPath)))) { | |
| 1205 | + yield* Effect.sleep("20 millis"); | |
| 1206 | + } | |
| 1207 | + yield* Effect.sync(() => { | |
| 1208 | + writeJsonRpcNotification("session/update", { | |
| 1209 | + sessionId: requestedSessionId, | |
| 1210 | + update: { | |
| 1211 | + sessionUpdate: "agent_message_chunk", | |
| 1212 | + content: { type: "text", text: "Monitor finished. MONITOR_REPORT_TOKEN" }, | |
| 1213 | + }, | |
| 1214 | + }); | |
| 1215 | + }); | |
| 1216 | + }).pipe(Effect.forkDetach); | |
| 1217 | + | |
| 1218 | + return { stopReason: "end_turn" }; | |
| 1219 | + } | |
| 1220 | + | |
| 884 | 1221 | if (emitAskQuestion) { |
| 885 | 1222 | yield* agent.client.extRequest("cursor/ask_question", { |
| 886 | 1223 | toolCallId: "ask-question-tool-call-1", |
| @@ -900,7 +1237,7 @@ const program = Effect.gen(function* () { · 13 unmodified lines | ||
| 900 | 1237 | return { stopReason: "end_turn" }; |
| 901 | 1238 | } |
| 902 | 1239 | |
| 903 | − if (emitXAiAskUserQuestion || emitXAiAskUserQuestionThenHang) { | |
| 1240 | + if (emitXAiAskUserQuestion) { | |
| 904 | 1241 | const result = yield* agent.client.extRequest("_x.ai/ask_user_question", { |
| 905 | 1242 | method: "x.ai/ask_user_question", |
| 906 | 1243 | params: { |
| @@ -934,84 +1271,6 @@ const program = Effect.gen(function* () { · 27 unmodified lines | ||
| 934 | 1271 | throw new Error("Expected accepted _x.ai/ask_user_question response answers."); |
| 935 | 1272 | } |
| 936 | 1273 | |
| 937 | − if (emitXAiAskUserQuestionThenHang) { | |
| 938 | − return yield* Effect.never; | |
| 939 | − } | |
| 940 | − | |
| 941 | − return { stopReason: "end_turn" }; | |
| 942 | − } | |
| 943 | − | |
| 944 | − if (emitXAiPlanMdWrite) { | |
| 945 | − // Match Grok's real session layout so isGrokPlanMarkdownPath accepts it. | |
| 946 | − const planRoot = process.env.T3_ACP_PLAN_ROOT ?? "/tmp/mock-home/.grok"; | |
| 947 | − const planPath = `${planRoot}/sessions/${requestedSessionId}/plan.md`; | |
| 948 | − const planBody = "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it\n"; | |
| 949 | − // enter_plan_mode first so the adapter arms planModeActive. | |
| 950 | − yield* agent.client.sessionUpdate({ | |
| 951 | − sessionId: requestedSessionId, | |
| 952 | − update: { | |
| 953 | − sessionUpdate: "tool_call", | |
| 954 | − toolCallId: "enter-plan-mode-1", | |
| 955 | − title: "enter_plan_mode", | |
| 956 | − kind: "other", | |
| 957 | − status: "completed", | |
| 958 | − rawInput: { variant: "EnterPlanMode" }, | |
| 959 | − }, | |
| 960 | − }); | |
| 961 | − yield* agent.client.sessionUpdate({ | |
| 962 | − sessionId: requestedSessionId, | |
| 963 | − update: { | |
| 964 | − sessionUpdate: "tool_call", | |
| 965 | − toolCallId: "plan-md-write-1", | |
| 966 | − title: "write", | |
| 967 | − kind: "edit", | |
| 968 | − status: "pending", | |
| 969 | − rawInput: { file_path: planPath, content: planBody }, | |
| 970 | − }, | |
| 971 | − }); | |
| 972 | − yield* agent.client.sessionUpdate({ | |
| 973 | − sessionId: requestedSessionId, | |
| 974 | − update: { | |
| 975 | − sessionUpdate: "tool_call_update", | |
| 976 | − toolCallId: "plan-md-write-1", | |
| 977 | − kind: "edit", | |
| 978 | − status: "completed", | |
| 979 | − title: `Write \`${planPath}\``, | |
| 980 | − rawInput: { file_path: planPath, content: planBody }, | |
| 981 | − content: [ | |
| 982 | − { | |
| 983 | − type: "diff", | |
| 984 | − path: planPath, | |
| 985 | − oldText: "", | |
| 986 | − newText: planBody, | |
| 987 | − }, | |
| 988 | − ], | |
| 989 | − }, | |
| 990 | − }); | |
| 991 | − return { stopReason: "end_turn" }; | |
| 992 | − } | |
| 993 | − | |
| 994 | − if (emitXAiExitPlanMode) { | |
| 995 | − const result = yield* agent.client.extRequest("_x.ai/exit_plan_mode", { | |
| 996 | − method: "x.ai/exit_plan_mode", | |
| 997 | − params: { | |
| 998 | − sessionId: requestedSessionId, | |
| 999 | − toolCallId: "exit-plan-mode-tool-call-1", | |
| 1000 | − planContent: "# Exit plan\n\n- Step one\n- Step two\n", | |
| 1001 | − }, | |
| 1002 | − }); | |
| 1003 | − if (typeof result !== "object" || result === null || !("outcome" in result)) { | |
| 1004 | − throw new Error("Expected _x.ai/exit_plan_mode response outcome."); | |
| 1005 | − } | |
| 1006 | − if ( | |
| 1007 | − result.outcome !== "abandoned" && | |
| 1008 | − result.outcome !== "approved" && | |
| 1009 | − result.outcome !== "request_changes" | |
| 1010 | − ) { | |
| 1011 | − throw new Error( | |
| 1012 | − `Expected exit_plan_mode outcome abandoned|approved|request_changes, got ${String(result.outcome)}`, | |
| 1013 | − ); | |
| 1014 | − } | |
| 1015 | 1274 | return { stopReason: "end_turn" }; |
| 1016 | 1275 | } |
| 1017 | 1276 | |
| @@ -0,0 +1,37 @@ | ||
| 1 | +#define _GNU_SOURCE | |
| 2 | + | |
| 3 | +#include <pthread.h> | |
| 4 | +#include <stdio.h> | |
| 5 | +#include <stdlib.h> | |
| 6 | +#include <sys/syscall.h> | |
| 7 | +#include <sys/types.h> | |
| 8 | +#include <sys/wait.h> | |
| 9 | +#include <unistd.h> | |
| 10 | + | |
| 11 | +static const char *pid_path; | |
| 12 | + | |
| 13 | +static void *spawn_child(void *unused) { | |
| 14 | + (void)unused; | |
| 15 | + pid_t child = fork(); | |
| 16 | + if (child == 0) { | |
| 17 | + execlp("sleep", "sleep", "120", NULL); | |
| 18 | + _exit(127); | |
| 19 | + } | |
| 20 | + if (child < 0) return NULL; | |
| 21 | + FILE *file = fopen(pid_path, "w"); | |
| 22 | + if (file != NULL) { | |
| 23 | + fprintf(file, "%ld %d\n", syscall(SYS_gettid), child); | |
| 24 | + fclose(file); | |
| 25 | + } | |
| 26 | + waitpid(child, NULL, 0); | |
| 27 | + return NULL; | |
| 28 | +} | |
| 29 | + | |
| 30 | +int main(int argc, char **argv) { | |
| 31 | + if (argc != 2) return 2; | |
| 32 | + pid_path = argv[1]; | |
| 33 | + pthread_t worker; | |
| 34 | + if (pthread_create(&worker, NULL, spawn_child, NULL) != 0) return 3; | |
| 35 | + if (pthread_join(worker, NULL) != 0) return 4; | |
| 36 | + return 0; | |
| 37 | +} | |
| @@ -1,3 +1,7 @@ | ||
| 1 | +// @effect-diagnostics nodeBuiltinImport:off | |
| 2 | +import * as NodeChildProcess from "node:child_process"; | |
| 3 | +import * as NodeFS from "node:fs"; | |
| 4 | + | |
| 1 | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 6 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | 7 | import { |
| @@ -16,31 +20,58 @@ import { · 12 unmodified lines | ||
| 16 | 20 | type OrchestrationV2ProviderThread, |
| 17 | 21 | } from "@t3tools/contracts"; |
| 18 | 22 | import * as DateTime from "effect/DateTime"; |
| 23 | +import * as Crypto from "effect/Crypto"; | |
| 24 | +import * as Deferred from "effect/Deferred"; | |
| 25 | +import type * as Duration from "effect/Duration"; | |
| 26 | +import * as Cause from "effect/Cause"; | |
| 27 | +import * as Clock from "effect/Clock"; | |
| 19 | 28 | import * as Effect from "effect/Effect"; |
| 29 | +import * as Exit from "effect/Exit"; | |
| 20 | 30 | import * as FileSystem from "effect/FileSystem"; |
| 21 | 31 | import * as Fiber from "effect/Fiber"; |
| 22 | 32 | import * as Layer from "effect/Layer"; |
| 23 | 33 | import * as Option from "effect/Option"; |
| 24 | 34 | import * as Path from "effect/Path"; |
| 35 | +import * as PlatformError from "effect/PlatformError"; | |
| 25 | 36 | import * as Queue from "effect/Queue"; |
| 26 | 37 | import * as Schema from "effect/Schema"; |
| 38 | +import * as Sink from "effect/Sink"; | |
| 27 | 39 | import * as Stream from "effect/Stream"; |
| 40 | +import * as Scope from "effect/Scope"; | |
| 28 | 41 | import * as TestClock from "effect/testing/TestClock"; |
| 29 | 42 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 43 | +import * as EffectAcpErrors from "effect-acp/errors"; | |
| 30 | 44 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 45 | +import type * as EffectAcpSchema from "effect-acp/schema"; | |
| 31 | 46 | |
| 32 | 47 | import { ServerConfig } from "../../config.ts"; |
| 33 | 48 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 34 | 49 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; |
| 50 | +import { | |
| 51 | + normalizeXAiAcpToolCallState, | |
| 52 | + registerXAiBackgroundTaskTracking, | |
| 53 | +} from "../../provider/acp/XAiAcpExtension.ts"; | |
| 35 | 54 | import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; |
| 36 | 55 | import { |
| 37 | 56 | ProviderAdapterV2RuntimePolicy, |
| 57 | + type ProviderAdapterV2Event, | |
| 38 | 58 | type ProviderAdapterV2TurnInput, |
| 39 | 59 | } from "../ProviderAdapter.ts"; |
| 60 | +import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; | |
| 40 | 61 | import { |
| 41 | 62 | AcpProviderCapabilitiesV2, |
| 63 | + acpCanonicalJson, | |
| 64 | + acpClaimNativeTransportRequest, | |
| 65 | + acpNativeUserInputRequestMatches, | |
| 66 | + acpPostSettleContinuationOfferEvidence, | |
| 67 | + acpPostSettleMonitorPromptShouldSuppress, | |
| 68 | + acpPostSettleWakeEvidence, | |
| 69 | + acpPostSettleWakeShouldBuffer, | |
| 70 | + acpProjectedCommandExitCode, | |
| 42 | 71 | makeAcpAdapterV2, |
| 72 | + type AcpAdapterV2ExtensionContext, | |
| 43 | 73 | type AcpAdapterV2Flavor, |
| 74 | + type AcpAdapterV2RuntimeInput, | |
| 44 | 75 | } from "./AcpAdapterV2.ts"; |
| 45 | 76 | |
| 46 | 77 | const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { |
| @@ -51,14 +82,202 @@ const testLayer = Layer.mergeAll(NodeServices.layer, idAllocatorLayer, serverCon · 4 unmodified lines | ||
| 51 | 82 | const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); |
| 52 | 83 | const decodeUnknownJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); |
| 53 | 84 | |
| 85 | +describe("acpProjectedCommandExitCode", () => { | |
| 86 | + const successOutput = { type: "Bash", exit_code: 0 }; | |
| 87 | + const failedOutput = { type: "Bash", exit_code: 1 }; | |
| 88 | + | |
| 89 | + it("omits exit codes for non-terminal and interrupted tool statuses", () => { | |
| 90 | + assert.equal(acpProjectedCommandExitCode("pending", successOutput), undefined); | |
| 91 | + assert.equal(acpProjectedCommandExitCode("running", successOutput), undefined); | |
| 92 | + assert.equal(acpProjectedCommandExitCode("interrupted", successOutput), undefined); | |
| 93 | + }); | |
| 94 | + | |
| 95 | + it("projects real exit codes only for completed and failed tools", () => { | |
| 96 | + assert.equal(acpProjectedCommandExitCode("completed", successOutput), 0); | |
| 97 | + assert.equal(acpProjectedCommandExitCode("completed", failedOutput), 1); | |
| 98 | + assert.equal(acpProjectedCommandExitCode("failed", failedOutput), 1); | |
| 99 | + assert.equal(acpProjectedCommandExitCode("completed", {}), undefined); | |
| 100 | + }); | |
| 101 | +}); | |
| 102 | + | |
| 103 | +const taskkillPlatformError = (method: string) => | |
| 104 | + PlatformError.systemError({ _tag: "Unknown", module: "taskkill-test", method }); | |
| 105 | + | |
| 106 | +function makeTaskkillSpawner(input: { | |
| 107 | + readonly exitCode?: number; | |
| 108 | + readonly exitFailure?: boolean; | |
| 109 | + readonly output?: string; | |
| 110 | + readonly outputFailure?: boolean; | |
| 111 | + readonly spawnFailure?: boolean; | |
| 112 | + readonly commands?: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }>; | |
| 113 | +}) { | |
| 114 | + return ChildProcessSpawner.make((command) => { | |
| 115 | + const value = command as unknown as { | |
| 116 | + readonly command: string; | |
| 117 | + readonly args: ReadonlyArray<string>; | |
| 118 | + }; | |
| 119 | + input.commands?.push({ command: value.command, args: value.args }); | |
| 120 | + if (input.spawnFailure === true) return Effect.fail(taskkillPlatformError("spawn")); | |
| 121 | + const output = input.outputFailure | |
| 122 | + ? Stream.fail(taskkillPlatformError("output")) | |
| 123 | + : Stream.encodeText(Stream.make(input.output ?? "")); | |
| 124 | + return Effect.succeed( | |
| 125 | + ChildProcessSpawner.makeHandle({ | |
| 126 | + pid: ChildProcessSpawner.ProcessId(1234), | |
| 127 | + exitCode: input.exitFailure | |
| 128 | + ? Effect.fail(taskkillPlatformError("exitCode")) | |
| 129 | + : Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)), | |
| 130 | + isRunning: Effect.succeed(false), | |
| 131 | + kill: () => Effect.void, | |
| 132 | + unref: Effect.succeed(Effect.void), | |
| 133 | + stdin: Sink.drain, | |
| 134 | + stdout: Stream.empty, | |
| 135 | + stderr: Stream.empty, | |
| 136 | + all: output, | |
| 137 | + getInputFd: () => Sink.drain, | |
| 138 | + getOutputFd: () => Stream.empty, | |
| 139 | + }), | |
| 140 | + ); | |
| 141 | + }); | |
| 142 | +} | |
| 143 | + | |
| 144 | +function processExists(pid: number): boolean { | |
| 145 | + try { | |
| 146 | + process.kill(pid, 0); | |
| 147 | + return true; | |
| 148 | + } catch { | |
| 149 | + return false; | |
| 150 | + } | |
| 151 | +} | |
| 152 | + | |
| 153 | +const waitForProcesses = (pids: ReadonlyArray<number>) => | |
| 154 | + Effect.gen(function* () { | |
| 155 | + while (!pids.every(processExists)) { | |
| 156 | + yield* Effect.sleep("10 millis"); | |
| 157 | + } | |
| 158 | + }).pipe(Effect.timeoutOption("2 seconds")); | |
| 159 | + | |
| 160 | +const waitForProcessesToExit = (pids: ReadonlyArray<number>) => | |
| 161 | + Effect.gen(function* () { | |
| 162 | + while (pids.some(processExists)) { | |
| 163 | + yield* Effect.sleep("10 millis"); | |
| 164 | + } | |
| 165 | + }).pipe(Effect.timeoutOption("2 seconds")); | |
| 166 | + | |
| 167 | +function linuxProcessStart(pid: number): string | undefined { | |
| 168 | + try { | |
| 169 | + const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8"); | |
| 170 | + const commandEnd = stat.lastIndexOf(")"); | |
| 171 | + return commandEnd < 0 | |
| 172 | + ? undefined | |
| 173 | + : stat | |
| 174 | + .slice(commandEnd + 2) | |
| 175 | + .trim() | |
| 176 | + .split(/\s+/)[19]; | |
| 177 | + } catch { | |
| 178 | + return undefined; | |
| 179 | + } | |
| 180 | +} | |
| 181 | + | |
| 182 | +function cleanupPublishedDetachedFixture(path: string): void { | |
| 183 | + let published: Array<number>; | |
| 184 | + try { | |
| 185 | + published = NodeFS.readFileSync(path, "utf8") | |
| 186 | + .trim() | |
| 187 | + .split(/\s+/) | |
| 188 | + .map(Number) | |
| 189 | + .filter((pid) => Number.isSafeInteger(pid) && pid > 1); | |
| 190 | + } catch { | |
| 191 | + return; | |
| 192 | + } | |
| 193 | + const roots = published.filter((pid) => { | |
| 194 | + try { | |
| 195 | + return NodeFS.readFileSync(`/proc/${pid}/cmdline`, "utf8").includes(path); | |
| 196 | + } catch { | |
| 197 | + return false; | |
| 198 | + } | |
| 199 | + }); | |
| 200 | + const owned = new Map<number, string>(); | |
| 201 | + const pending = [...roots]; | |
| 202 | + while (pending.length > 0) { | |
| 203 | + const pid = pending.shift(); | |
| 204 | + if (pid === undefined || owned.has(pid)) continue; | |
| 205 | + const start = linuxProcessStart(pid); | |
| 206 | + if (start === undefined) continue; | |
| 207 | + owned.set(pid, start); | |
| 208 | + try { | |
| 209 | + pending.push( | |
| 210 | + ...NodeFS.readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8") | |
| 211 | + .trim() | |
| 212 | + .split(/\s+/) | |
| 213 | + .filter(Boolean) | |
| 214 | + .map(Number), | |
| 215 | + ); | |
| 216 | + } catch { | |
| 217 | + // The process already exited. | |
| 218 | + } | |
| 219 | + } | |
| 220 | + for (const [pid, start] of [...owned.entries()].toReversed()) { | |
| 221 | + if (linuxProcessStart(pid) !== start) continue; | |
| 222 | + try { | |
| 223 | + process.kill(pid, "SIGKILL"); | |
| 224 | + } catch { | |
| 225 | + // The exact fixture process already exited. | |
| 226 | + } | |
| 227 | + } | |
| 228 | +} | |
| 229 | + | |
| 230 | +const waitForPublishedProcessIds = ( | |
| 231 | + fileSystem: FileSystem.FileSystem, | |
| 232 | + path: string, | |
| 233 | + count: number, | |
| 234 | +) => | |
| 235 | + Effect.gen(function* () { | |
| 236 | + while (true) { | |
| 237 | + const ids = (yield* fileSystem.readFileString(path)) | |
| 238 | + .trim() | |
| 239 | + .split(/\s+/) | |
| 240 | + .filter(Boolean) | |
| 241 | + .map(Number); | |
| 242 | + if (ids.length === count && ids.every((pid) => Number.isSafeInteger(pid) && pid > 1)) { | |
| 243 | + return ids; | |
| 244 | + } | |
| 245 | + yield* Effect.sleep("10 millis"); | |
| 246 | + } | |
| 247 | + }).pipe(Effect.timeoutOption("2 seconds")); | |
| 248 | + | |
| 54 | 249 | function makeMockRuntime(input: { |
| 55 | 250 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; |
| 56 | 251 | readonly mockAgentPath: string; |
| 57 | − readonly environment?: Readonly<Record<string, string>>; | |
| 252 | + readonly environment?: | |
| 253 | + | Readonly<Record<string, string>> | |
| 254 | + | ((runtimeOrdinal: number) => Readonly<Record<string, string>>); | |
| 58 | 255 | readonly protocolEvents?: Queue.Queue<EffectAcpProtocol.AcpProtocolLogEvent>; |
| 256 | + readonly ownDescendantProcessGroups?: boolean; | |
| 257 | + readonly ownDetachedProcessGroup?: boolean; | |
| 258 | + readonly processGroupPlatform?: NodeJS.Platform; | |
| 259 | + readonly processGroupTerminationGrace?: Duration.Input; | |
| 260 | + readonly linuxCgroupController?: AcpSessionRuntime.AcpSessionRuntimeOptions["linuxCgroupController"]; | |
| 261 | + readonly posixProcessTreeController?: AcpSessionRuntime.AcpSessionRuntimeOptions["posixProcessTreeController"]; | |
| 262 | + readonly windowsProcessTreeTerminator?: AcpSessionRuntime.AcpSessionRuntimeOptions["windowsProcessTreeTerminator"]; | |
| 263 | + readonly wrapCancel?: ( | |
| 264 | + cancel: AcpSessionRuntime.AcpSessionRuntime["Service"]["cancel"], | |
| 265 | + ) => AcpSessionRuntime.AcpSessionRuntime["Service"]["cancel"]; | |
| 266 | + readonly wrapOutgoingResponse?: ( | |
| 267 | + onOutgoingResponse: NonNullable<AcpAdapterV2RuntimeInput["onOutgoingResponse"]>, | |
| 268 | + ) => NonNullable<AcpAdapterV2RuntimeInput["onOutgoingResponse"]>; | |
| 269 | + readonly wrapIncomingRequest?: ( | |
| 270 | + onIncomingRequest: NonNullable<AcpAdapterV2RuntimeInput["onIncomingRequest"]>, | |
| 271 | + ) => NonNullable<AcpAdapterV2RuntimeInput["onIncomingRequest"]>; | |
| 272 | + readonly wrapRuntime?: ( | |
| 273 | + runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], | |
| 274 | + runtimeOrdinal: number, | |
| 275 | + ) => AcpSessionRuntime.AcpSessionRuntime["Service"]; | |
| 59 | 276 | }): AcpAdapterV2Flavor["makeRuntime"] { |
| 277 | + let runtimeOrdinal = 0; | |
| 60 | 278 | return (runtimeInput) => |
| 61 | 279 | Effect.gen(function* () { |
| 280 | + runtimeOrdinal += 1; | |
| 62 | 281 | const protocolEvents = input.protocolEvents; |
| 63 | 282 | const protocolLogging = |
| 64 | 283 | protocolEvents === undefined |
| @@ -74,23 +293,66 @@ function makeMockRuntime(input: { · 9 unmodified lines | ||
| 74 | 293 | const context = yield* Layer.build( |
| 75 | 294 | AcpSessionRuntime.layer({ |
| 76 | 295 | ...runtimeInput, |
| 296 | + ...(input.ownDetachedProcessGroup === undefined | |
| 297 | + ? {} | |
| 298 | + : { ownDetachedProcessGroup: input.ownDetachedProcessGroup }), | |
| 299 | + ...(input.ownDescendantProcessGroups === undefined | |
| 300 | + ? {} | |
| 301 | + : { ownDescendantProcessGroups: input.ownDescendantProcessGroups }), | |
| 302 | + ...(input.ownDetachedProcessGroup === true | |
| 303 | + ? { processGroupPlatform: input.processGroupPlatform ?? "linux" } | |
| 304 | + : {}), | |
| 305 | + ...(input.processGroupTerminationGrace === undefined | |
| 306 | + ? {} | |
| 307 | + : { processGroupTerminationGrace: input.processGroupTerminationGrace }), | |
| 308 | + ...(input.linuxCgroupController === undefined | |
| 309 | + ? {} | |
| 310 | + : { linuxCgroupController: input.linuxCgroupController }), | |
| 311 | + ...(input.posixProcessTreeController === undefined | |
| 312 | + ? {} | |
| 313 | + : { posixProcessTreeController: input.posixProcessTreeController }), | |
| 314 | + ...(input.windowsProcessTreeTerminator === undefined | |
| 315 | + ? {} | |
| 316 | + : { windowsProcessTreeTerminator: input.windowsProcessTreeTerminator }), | |
| 77 | 317 | protocolLogging, |
| 78 | 318 | spawn: { |
| 79 | 319 | command: process.execPath, |
| 80 | 320 | args: [input.mockAgentPath], |
| 81 | 321 | cwd: runtimeInput.cwd, |
| 82 | − env: { T3_ACP_SESSION_LIFECYCLE: "1", ...input.environment }, | |
| 322 | + env: { | |
| 323 | + T3_ACP_SESSION_LIFECYCLE: "1", | |
| 324 | + ...(typeof input.environment === "function" | |
| 325 | + ? input.environment(runtimeOrdinal) | |
| 326 | + : input.environment), | |
| 327 | + }, | |
| 83 | 328 | }, |
| 84 | 329 | authMethodId: "test", |
| 330 | + ...(input.wrapIncomingRequest === undefined || | |
| 331 | + runtimeInput.onIncomingRequest === undefined | |
| 332 | + ? {} | |
| 333 | + : { | |
| 334 | + onIncomingRequest: input.wrapIncomingRequest(runtimeInput.onIncomingRequest), | |
| 335 | + }), | |
| 336 | + ...(input.wrapOutgoingResponse === undefined || | |
| 337 | + runtimeInput.onOutgoingResponse === undefined | |
| 338 | + ? {} | |
| 339 | + : { | |
| 340 | + onOutgoingResponse: input.wrapOutgoingResponse(runtimeInput.onOutgoingResponse), | |
| 341 | + }), | |
| 85 | 342 | }).pipe( |
| 86 | 343 | Layer.provide( |
| 87 | 344 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), |
| 88 | 345 | ), |
| 89 | 346 | ), |
| 90 | 347 | ); |
| 91 | − return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | |
| 348 | + const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | |
| 92 | 349 | Effect.provide(context), |
| 93 | 350 | ); |
| 351 | + const wrapped = { | |
| 352 | + ...runtime, | |
| 353 | + ...(input.wrapCancel === undefined ? {} : { cancel: input.wrapCancel(runtime.cancel) }), | |
| 354 | + }; | |
| 355 | + return input.wrapRuntime?.(wrapped, runtimeOrdinal) ?? wrapped; | |
| 94 | 356 | }); |
| 95 | 357 | } |
| 96 | 358 | |
| @@ -123,6 +385,20 @@ function rawProtocolRequest( · 26 unmodified lines | ||
| 123 | 385 | return undefined; |
| 124 | 386 | } |
| 125 | 387 | |
| 388 | +const pollProtocolMethods = (events: Queue.Queue<EffectAcpProtocol.AcpProtocolLogEvent>) => | |
| 389 | + Effect.gen(function* () { | |
| 390 | + const methods: string[] = []; | |
| 391 | + let polled = 0; | |
| 392 | + let event = yield* Queue.poll(events); | |
| 393 | + while (Option.isSome(event) && polled < 256) { | |
| 394 | + polled += 1; | |
| 395 | + const method = rawProtocolMethod(event.value); | |
| 396 | + if (method !== undefined) methods.push(method); | |
| 397 | + event = yield* Queue.poll(events); | |
| 398 | + } | |
| 399 | + return methods; | |
| 400 | + }); | |
| 401 | + | |
| 126 | 402 | function makeTurnInput(input: { |
| 127 | 403 | readonly threadId: ThreadId; |
| 128 | 404 | readonly providerThread: OrchestrationV2ProviderThread; |
| @@ -181,6 +457,418 @@ function makeTurnInput(input: { · 52 unmodified lines | ||
| 181 | 457 | } |
| 182 | 458 | |
| 183 | 459 | describe("AcpAdapterV2", () => { |
| 460 | + it.live("cleans detached fixtures when an assertion aborts the test scope", () => | |
| 461 | + Effect.gen(function* () { | |
| 462 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 463 | + let published: ReadonlyArray<number> = []; | |
| 464 | + const failed = yield* Effect.scoped( | |
| 465 | + Effect.gen(function* () { | |
| 466 | + const commandPidPath = yield* fileSystem.makeTempFileScoped({ | |
| 467 | + prefix: "t3-acp-forced-failure-command-", | |
| 468 | + }); | |
| 469 | + yield* Effect.addFinalizer(() => | |
| 470 | + Effect.sync(() => cleanupPublishedDetachedFixture(commandPidPath)), | |
| 471 | + ); | |
| 472 | + const fixture = NodeChildProcess.spawn( | |
| 473 | + "bash", | |
| 474 | + [ | |
| 475 | + "-c", | |
| 476 | + 'sleep 120 & child=$!; printf "%s %s\\n" "$$" "$child" > "$1"; wait "$child"', | |
| 477 | + "bash", | |
| 478 | + commandPidPath, | |
| 479 | + ], | |
| 480 | + { detached: true, stdio: "ignore" }, | |
| 481 | + ); | |
| 482 | + fixture.unref(); | |
| 483 | + published = Option.getOrThrow( | |
| 484 | + yield* waitForPublishedProcessIds(fileSystem, commandPidPath, 2), | |
| 485 | + ); | |
| 486 | + return yield* Effect.fail("forced assertion failure"); | |
| 487 | + }), | |
| 488 | + ).pipe(Effect.exit); | |
| 489 | + | |
| 490 | + assert.isTrue(Exit.isFailure(failed)); | |
| 491 | + assert.isTrue( | |
| 492 | + Option.isSome(yield* waitForProcessesToExit(published)), | |
| 493 | + "detached cleanup finalizer must reap the Bash and sleep fixture", | |
| 494 | + ); | |
| 495 | + }).pipe(Effect.provide(testLayer)), | |
| 496 | + ); | |
| 497 | + | |
| 498 | + it("matches xAI native request identities exactly across shared prefixes", () => { | |
| 499 | + const request = { | |
| 500 | + nativeMethod: "x.ai/ask_user_question", | |
| 501 | + nativeRequestId: "1", | |
| 502 | + nativeSessionId: "session-1", | |
| 503 | + }; | |
| 504 | + assert.isTrue( | |
| 505 | + acpNativeUserInputRequestMatches(request, { | |
| 506 | + method: "x.ai/ask_user_question", | |
| 507 | + payload: { sessionId: "session-1", toolCallId: 1 }, | |
| 508 | + }), | |
| 509 | + ); | |
| 510 | + assert.isFalse( | |
| 511 | + acpNativeUserInputRequestMatches(request, { | |
| 512 | + method: "x.ai/ask_user_question", | |
| 513 | + payload: { sessionId: "session-1", toolCallId: "10", note: "request 1" }, | |
| 514 | + }), | |
| 515 | + ); | |
| 516 | + for (const incomplete of [ | |
| 517 | + { ...request, nativeMethod: "" }, | |
| 518 | + { ...request, nativeRequestId: "" }, | |
| 519 | + { ...request, nativeSessionId: "" }, | |
| 520 | + ]) { | |
| 521 | + assert.isFalse( | |
| 522 | + acpNativeUserInputRequestMatches(incomplete, { | |
| 523 | + method: "x.ai/ask_user_question", | |
| 524 | + payload: { sessionId: "session-1", toolCallId: "1" }, | |
| 525 | + }), | |
| 526 | + ); | |
| 527 | + } | |
| 528 | + assert.isFalse( | |
| 529 | + acpNativeUserInputRequestMatches(request, { | |
| 530 | + method: "_x.ai/ask_user_question", | |
| 531 | + payload: { sessionId: "session-1", toolCallId: "1" }, | |
| 532 | + }), | |
| 533 | + ); | |
| 534 | + assert.isFalse( | |
| 535 | + acpNativeUserInputRequestMatches(request, { | |
| 536 | + method: "x.ai/ask_user_question", | |
| 537 | + payload: { | |
| 538 | + method: "x.ai/ask_user_question", | |
| 539 | + params: { sessionId: "session-10", toolCallId: "1" }, | |
| 540 | + }, | |
| 541 | + }), | |
| 542 | + ); | |
| 543 | + }); | |
| 544 | + | |
| 545 | + it("claims concurrent identical native requests in per-runtime sequence order", () => { | |
| 546 | + const requests = [ | |
| 547 | + { generation: 2, requestId: "second", sequence: 8, identity: "shared" }, | |
| 548 | + { generation: 1, requestId: "stale", sequence: 1, identity: "shared" }, | |
| 549 | + { generation: 2, requestId: "first", sequence: 7, identity: "shared" }, | |
| 550 | + { generation: 2, requestId: "other", sequence: 6, identity: "other" }, | |
| 551 | + ]; | |
| 552 | + const [firstId, afterFirst] = acpClaimNativeTransportRequest( | |
| 553 | + requests, | |
| 554 | + 2, | |
| 555 | + (request) => request.identity === "shared", | |
| 556 | + ); | |
| 557 | + const [secondId, afterSecond] = acpClaimNativeTransportRequest( | |
| 558 | + afterFirst, | |
| 559 | + 2, | |
| 560 | + (request) => request.identity === "shared", | |
| 561 | + ); | |
| 562 | + | |
| 563 | + assert.equal(firstId, "first"); | |
| 564 | + assert.equal(secondId, "second"); | |
| 565 | + assert.deepEqual( | |
| 566 | + afterSecond.map((request) => request.requestId), | |
| 567 | + ["stale", "other"], | |
| 568 | + ); | |
| 569 | + }); | |
| 570 | + | |
| 571 | + it("canonicalizes nested elicitation schemas independently of object key order", () => { | |
| 572 | + assert.equal( | |
| 573 | + acpCanonicalJson({ | |
| 574 | + type: "object", | |
| 575 | + properties: { answer: { type: "string", title: "Answer", enum: ["a", "b"] } }, | |
| 576 | + }), | |
| 577 | + acpCanonicalJson({ | |
| 578 | + properties: { answer: { enum: ["a", "b"], title: "Answer", type: "string" } }, | |
| 579 | + type: "object", | |
| 580 | + }), | |
| 581 | + ); | |
| 582 | + }); | |
| 583 | + | |
| 584 | + it.live("replaces an unexpectedly terminated ACP runtime before the next turn", () => | |
| 585 | + Effect.gen(function* () { | |
| 586 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 587 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 588 | + const idAllocator = yield* IdAllocatorV2; | |
| 589 | + const path = yield* Path.Path; | |
| 590 | + const serverConfig = yield* ServerConfig; | |
| 591 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 592 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 593 | + ); | |
| 594 | + const runtimeInputs: AcpAdapterV2RuntimeInput[] = []; | |
| 595 | + let runtimeOrdinalSeen = 0; | |
| 596 | + const baseMakeRuntime = makeMockRuntime({ | |
| 597 | + childProcessSpawner, | |
| 598 | + mockAgentPath, | |
| 599 | + environment: (runtimeOrdinal) => { | |
| 600 | + runtimeOrdinalSeen = runtimeOrdinal; | |
| 601 | + return {}; | |
| 602 | + }, | |
| 603 | + }); | |
| 604 | + const instanceId = ProviderInstanceId.make("acp-test-unexpected-termination"); | |
| 605 | + const adapter = makeAcpAdapterV2({ | |
| 606 | + instanceId, | |
| 607 | + flavor: { | |
| 608 | + driver: ACP_TEST_DRIVER, | |
| 609 | + capabilities: AcpProviderCapabilitiesV2, | |
| 610 | + makeRuntime: (runtimeInput) => | |
| 611 | + Effect.sync(() => { | |
| 612 | + runtimeInputs.push(runtimeInput); | |
| 613 | + }).pipe(Effect.andThen(baseMakeRuntime(runtimeInput))), | |
| 614 | + }, | |
| 615 | + fileSystem, | |
| 616 | + idAllocator, | |
| 617 | + serverConfig, | |
| 618 | + }); | |
| 619 | + const threadId = ThreadId.make("thread-acp-unexpected-termination"); | |
| 620 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 621 | + runtimeMode: "full-access", | |
| 622 | + interactionMode: "default", | |
| 623 | + cwd: process.cwd(), | |
| 624 | + }); | |
| 625 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 626 | + const runtime = yield* adapter.openSession({ | |
| 627 | + threadId, | |
| 628 | + providerSessionId: ProviderSessionId.make("provider-session-acp-unexpected-termination"), | |
| 629 | + modelSelection, | |
| 630 | + runtimePolicy, | |
| 631 | + }); | |
| 632 | + const providerThread = yield* runtime.ensureThread({ | |
| 633 | + threadId, | |
| 634 | + modelSelection, | |
| 635 | + runtimePolicy, | |
| 636 | + }); | |
| 637 | + assert.equal(runtimeOrdinalSeen, 1); | |
| 638 | + yield* runtimeInputs[0]!.onTermination!( | |
| 639 | + new EffectAcpErrors.AcpTransportError({ | |
| 640 | + detail: "Injected unexpected writer termination", | |
| 641 | + cause: "test", | |
| 642 | + }), | |
| 643 | + ); | |
| 644 | + | |
| 645 | + yield* runtime.startTurn( | |
| 646 | + makeTurnInput({ | |
| 647 | + threadId, | |
| 648 | + providerThread, | |
| 649 | + instanceId, | |
| 650 | + runtimePolicy, | |
| 651 | + now: yield* DateTime.now, | |
| 652 | + }), | |
| 653 | + ); | |
| 654 | + yield* runtime.events.pipe( | |
| 655 | + Stream.filter((event) => event.type === "turn.terminal"), | |
| 656 | + Stream.runHead, | |
| 657 | + ); | |
| 658 | + assert.equal(runtimeOrdinalSeen, 2); | |
| 659 | + assert.lengthOf(runtimeInputs, 2); | |
| 660 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 661 | + ); | |
| 662 | + | |
| 663 | + it.live("reaps detached native work when the provider exits before explicit teardown", () => | |
| 664 | + Effect.gen(function* () { | |
| 665 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 666 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 667 | + const idAllocator = yield* IdAllocatorV2; | |
| 668 | + const path = yield* Path.Path; | |
| 669 | + const serverConfig = yield* ServerConfig; | |
| 670 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 671 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 672 | + ); | |
| 673 | + const commandPidPath = yield* fileSystem.makeTempFileScoped({ | |
| 674 | + prefix: "t3-acp-provider-exit-command-", | |
| 675 | + }); | |
| 676 | + yield* Effect.addFinalizer(() => | |
| 677 | + Effect.sync(() => cleanupPublishedDetachedFixture(commandPidPath)), | |
| 678 | + ); | |
| 679 | + const instanceId = ProviderInstanceId.make("acp-test-provider-exit"); | |
| 680 | + const adapter = makeAcpAdapterV2({ | |
| 681 | + instanceId, | |
| 682 | + flavor: { | |
| 683 | + driver: ACP_TEST_DRIVER, | |
| 684 | + capabilities: AcpProviderCapabilitiesV2, | |
| 685 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 686 | + makeRuntime: makeMockRuntime({ | |
| 687 | + childProcessSpawner, | |
| 688 | + mockAgentPath, | |
| 689 | + ownDescendantProcessGroups: true, | |
| 690 | + ownDetachedProcessGroup: true, | |
| 691 | + processGroupTerminationGrace: 0, | |
| 692 | + environment: { | |
| 693 | + T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG: "1", | |
| 694 | + T3_ACP_EXIT_AFTER_RUNNING_COMMAND_LAUNCH: "1", | |
| 695 | + T3_ACP_RUNNING_COMMAND_PID_PATH: commandPidPath, | |
| 696 | + T3_ACP_RUNNING_COMMAND_SEPARATE_SESSION: "1", | |
| 697 | + }, | |
| 698 | + }), | |
| 699 | + }, | |
| 700 | + fileSystem, | |
| 701 | + idAllocator, | |
| 702 | + serverConfig, | |
| 703 | + }); | |
| 704 | + const threadId = ThreadId.make("thread-acp-provider-exit-running-command"); | |
| 705 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 706 | + runtimeMode: "full-access", | |
| 707 | + interactionMode: "default", | |
| 708 | + cwd: process.cwd(), | |
| 709 | + }); | |
| 710 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 711 | + const runtime = yield* adapter.openSession({ | |
| 712 | + threadId, | |
| 713 | + providerSessionId: ProviderSessionId.make("provider-session-acp-provider-exit"), | |
| 714 | + modelSelection, | |
| 715 | + runtimePolicy, | |
| 716 | + }); | |
| 717 | + const providerThread = yield* runtime.ensureThread({ | |
| 718 | + threadId, | |
| 719 | + modelSelection, | |
| 720 | + runtimePolicy, | |
| 721 | + }); | |
| 722 | + yield* runtime | |
| 723 | + .startTurn( | |
| 724 | + makeTurnInput({ | |
| 725 | + threadId, | |
| 726 | + providerThread, | |
| 727 | + instanceId, | |
| 728 | + runtimePolicy, | |
| 729 | + now: yield* DateTime.now, | |
| 730 | + }), | |
| 731 | + ) | |
| 732 | + .pipe(Effect.exit, Effect.forkScoped); | |
| 733 | + | |
| 734 | + const published = yield* waitForPublishedProcessIds(fileSystem, commandPidPath, 3); | |
| 735 | + assert.isTrue(Option.isSome(published), "detached fixture must publish all process IDs"); | |
| 736 | + const pids = Option.getOrThrow(published); | |
| 737 | + assert.isTrue( | |
| 738 | + Option.isSome(yield* waitForProcessesToExit(pids)), | |
| 739 | + "provider termination must reap the detached launcher, Bash, and sleep processes", | |
| 740 | + ); | |
| 741 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 742 | + ); | |
| 743 | + | |
| 744 | + it.live("surfaces reduced guarantee when delegated cgroup containment is unavailable", () => | |
| 745 | + Effect.gen(function* () { | |
| 746 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 747 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 748 | + const idAllocator = yield* IdAllocatorV2; | |
| 749 | + const path = yield* Path.Path; | |
| 750 | + const serverConfig = yield* ServerConfig; | |
| 751 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 752 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 753 | + ); | |
| 754 | + let containment: | |
| 755 | + | AcpSessionRuntime.AcpSessionRuntime["Service"]["processContainment"] | |
| 756 | + | undefined; | |
| 757 | + const instanceId = ProviderInstanceId.make("acp-test-cgroup-unavailable"); | |
| 758 | + const adapter = makeAcpAdapterV2({ | |
| 759 | + instanceId, | |
| 760 | + flavor: { | |
| 761 | + driver: ACP_TEST_DRIVER, | |
| 762 | + capabilities: AcpProviderCapabilitiesV2, | |
| 763 | + makeRuntime: makeMockRuntime({ | |
| 764 | + childProcessSpawner, | |
| 765 | + linuxCgroupController: null, | |
| 766 | + mockAgentPath, | |
| 767 | + ownDescendantProcessGroups: true, | |
| 768 | + ownDetachedProcessGroup: true, | |
| 769 | + wrapRuntime: (runtime) => { | |
| 770 | + containment = runtime.processContainment; | |
| 771 | + return runtime; | |
| 772 | + }, | |
| 773 | + }), | |
| 774 | + }, | |
| 775 | + fileSystem, | |
| 776 | + idAllocator, | |
| 777 | + serverConfig, | |
| 778 | + }); | |
| 779 | + const threadId = ThreadId.make("thread-acp-cgroup-unavailable"); | |
| 780 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 781 | + runtimeMode: "full-access", | |
| 782 | + interactionMode: "default", | |
| 783 | + cwd: process.cwd(), | |
| 784 | + }); | |
| 785 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 786 | + const runtime = yield* adapter.openSession({ | |
| 787 | + threadId, | |
| 788 | + providerSessionId: ProviderSessionId.make("provider-session-acp-cgroup-unavailable"), | |
| 789 | + modelSelection, | |
| 790 | + runtimePolicy, | |
| 791 | + }); | |
| 792 | + yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); | |
| 793 | + assert.equal(containment, "process-ledger-reduced-guarantee"); | |
| 794 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 795 | + ); | |
| 796 | + | |
| 797 | + it.live("cleans a cgroup lease when the pre-exec join wrapper fails", () => | |
| 798 | + Effect.gen(function* () { | |
| 799 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 800 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 801 | + const idAllocator = yield* IdAllocatorV2; | |
| 802 | + const path = yield* Path.Path; | |
| 803 | + const serverConfig = yield* ServerConfig; | |
| 804 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 805 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 806 | + ); | |
| 807 | + let createCalls = 0; | |
| 808 | + let cgroupExists = true; | |
| 809 | + let killCalls = 0; | |
| 810 | + let removeCalls = 0; | |
| 811 | + const cgroupController: AcpSessionRuntime.AcpLinuxCgroupController = { | |
| 812 | + create: () => { | |
| 813 | + createCalls += 1; | |
| 814 | + return { | |
| 815 | + contains: () => false, | |
| 816 | + exists: () => cgroupExists, | |
| 817 | + path: "/definitely-missing/t3-acp-cgroup", | |
| 818 | + relativePath: "/definitely-missing/t3-acp-cgroup", | |
| 819 | + kill: () => { | |
| 820 | + killCalls += 1; | |
| 821 | + }, | |
| 822 | + populated: () => false, | |
| 823 | + remove: () => { | |
| 824 | + removeCalls += 1; | |
| 825 | + cgroupExists = false; | |
| 826 | + }, | |
| 827 | + }; | |
| 828 | + }, | |
| 829 | + }; | |
| 830 | + const instanceId = ProviderInstanceId.make("acp-test-cgroup-join-failure"); | |
| 831 | + const adapter = makeAcpAdapterV2({ | |
| 832 | + instanceId, | |
| 833 | + flavor: { | |
| 834 | + driver: ACP_TEST_DRIVER, | |
| 835 | + capabilities: AcpProviderCapabilitiesV2, | |
| 836 | + makeRuntime: makeMockRuntime({ | |
| 837 | + childProcessSpawner, | |
| 838 | + linuxCgroupController: cgroupController, | |
| 839 | + mockAgentPath, | |
| 840 | + ownDescendantProcessGroups: true, | |
| 841 | + ownDetachedProcessGroup: true, | |
| 842 | + }), | |
| 843 | + }, | |
| 844 | + fileSystem, | |
| 845 | + idAllocator, | |
| 846 | + serverConfig, | |
| 847 | + }); | |
| 848 | + const threadId = ThreadId.make("thread-acp-cgroup-join-failure"); | |
| 849 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 850 | + runtimeMode: "full-access", | |
| 851 | + interactionMode: "default", | |
| 852 | + cwd: process.cwd(), | |
| 853 | + }); | |
| 854 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 855 | + const sessionScope = yield* Scope.make(); | |
| 856 | + const opened = yield* adapter | |
| 857 | + .openSession({ | |
| 858 | + threadId, | |
| 859 | + providerSessionId: ProviderSessionId.make("provider-session-acp-cgroup-join-failure"), | |
| 860 | + modelSelection, | |
| 861 | + runtimePolicy, | |
| 862 | + }) | |
| 863 | + .pipe(Effect.provideService(Scope.Scope, sessionScope), Effect.exit); | |
| 864 | + assert.isTrue(Exit.isFailure(opened)); | |
| 865 | + yield* Scope.close(sessionScope, Exit.void); | |
| 866 | + assert.equal(createCalls, 1); | |
| 867 | + assert.isAtLeast(killCalls, 1); | |
| 868 | + assert.isAtLeast(removeCalls, 1); | |
| 869 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 870 | + ); | |
| 871 | + | |
| 184 | 872 | it.effect("negotiates and executes optional native session forks through the ACP runtime", () => |
| 185 | 873 | Effect.gen(function* () { |
| 186 | 874 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| @@ -244,16 +932,18 @@ describe("AcpAdapterV2", () => { · 57 unmodified lines | ||
| 244 | 932 | sourceProviderThread, |
| 245 | 933 | targetThreadId, |
| 246 | 934 | }); |
| 247 | − const forkRequest = Option.getOrThrow( | |
| 935 | + const forkRequestEvent = Option.getOrThrow( | |
| 248 | 936 | yield* Stream.fromQueue(protocolEvents).pipe( |
| 249 | 937 | Stream.filter( |
| 250 | 938 | (event) => |
| 251 | 939 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/fork", |
| 252 | 940 | ), |
| 253 | − Stream.map(rawProtocolRequest), | |
| 254 | 941 | Stream.runHead, |
| 255 | 942 | ), |
| 256 | 943 | ); |
| 944 | + const forkRequest = Option.getOrThrow( | |
| 945 | + Option.fromNullishOr(rawProtocolRequest(forkRequestEvent)), | |
| 946 | + ); | |
| 257 | 947 | |
| 258 | 948 | assert.equal(sourceProviderThread.nativeThreadRef?.nativeId, "mock-session-1"); |
| 259 | 949 | assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, "mock-session-1-fork"); |
| @@ -274,7 +964,7 @@ describe("AcpAdapterV2", () => { · 14 unmodified lines | ||
| 274 | 964 | }).pipe(Effect.provide(testLayer), Effect.scoped), |
| 275 | 965 | ); |
| 276 | 966 | |
| 277 | − it.effect("rejects requested options that the active ACP session does not expose", () => | |
| 967 | + it.effect("closes an idle ACP session exactly once through the transition permit", () => | |
| 278 | 968 | Effect.gen(function* () { |
| 279 | 969 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 280 | 970 | const fileSystem = yield* FileSystem.FileSystem; |
| @@ -284,44 +974,43 @@ describe("AcpAdapterV2", () => { · 3 unmodified lines | ||
| 284 | 974 | const mockAgentPath = yield* path.fromFileUrl( |
| 285 | 975 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| 286 | 976 | ); |
| 977 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 287 | 978 | const instanceId = ProviderInstanceId.make("acp-test"); |
| 288 | 979 | const adapter = makeAcpAdapterV2({ |
| 289 | 980 | instanceId, |
| 290 | 981 | flavor: { |
| 291 | 982 | driver: ACP_TEST_DRIVER, |
| 292 | 983 | capabilities: AcpProviderCapabilitiesV2, |
| 293 | − makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath }), | |
| 984 | + makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }), | |
| 294 | 985 | }, |
| 295 | 986 | fileSystem, |
| 296 | 987 | idAllocator, |
| 297 | 988 | serverConfig, |
| 298 | 989 | }); |
| 299 | − const threadId = ThreadId.make("thread-acp-unsupported-option"); | |
| 990 | + const threadId = ThreadId.make("thread-acp-idle-finalizer"); | |
| 300 | 991 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ |
| 301 | 992 | runtimeMode: "full-access", |
| 302 | 993 | interactionMode: "default", |
| 303 | 994 | cwd: process.cwd(), |
| 304 | 995 | }); |
| 305 | − const error = yield* adapter | |
| 996 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 997 | + const sessionScope = yield* Scope.make(); | |
| 998 | + yield* adapter | |
| 306 | 999 | .openSession({ |
| 307 | 1000 | threadId, |
| 308 | − providerSessionId: ProviderSessionId.make("provider-session-acp-unsupported-option"), | |
| 309 | − modelSelection: { | |
| 310 | − instanceId, | |
| 311 | − model: "default", | |
| 312 | − options: [{ id: "missing-option", value: "high" }], | |
| 313 | − }, | |
| 1001 | + providerSessionId: ProviderSessionId.make("provider-session-acp-idle-finalizer"), | |
| 1002 | + modelSelection, | |
| 314 | 1003 | runtimePolicy, |
| 315 | 1004 | }) |
| 316 | − .pipe(Effect.flip); | |
| 317 | − | |
| 318 | − assert.equal(error._tag, "ProviderAdapterOpenSessionError"); | |
| 319 | − assert.include(String(error.cause), "does not expose requested configuration option(s)"); | |
| 320 | − assert.include(String(error.cause), "missing-option"); | |
| 321 | − }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 1005 | + .pipe(Effect.provideService(Scope.Scope, sessionScope)); | |
| 1006 | + yield* pollProtocolMethods(protocolEvents); | |
| 1007 | + yield* Scope.close(sessionScope, Exit.void); | |
| 1008 | + const finalizerMethods = yield* pollProtocolMethods(protocolEvents); | |
| 1009 | + assert.equal(finalizerMethods.filter((method) => method === "session/close").length, 1); | |
| 1010 | + }).pipe(Effect.provide(testLayer)), | |
| 322 | 1011 | ); |
| 323 | 1012 | |
| 324 | − it.effect("reconfigures a loaded ACP session from its own active setup metadata", () => | |
| 1013 | + it.effect("rejects requested options that the active ACP session does not expose", () => | |
| 325 | 1014 | Effect.gen(function* () { |
| 326 | 1015 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 327 | 1016 | const fileSystem = yield* FileSystem.FileSystem; |
| @@ -331,20 +1020,67 @@ describe("AcpAdapterV2", () => { · 3 unmodified lines | ||
| 331 | 1020 | const mockAgentPath = yield* path.fromFileUrl( |
| 332 | 1021 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| 333 | 1022 | ); |
| 334 | − const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 335 | 1023 | const instanceId = ProviderInstanceId.make("acp-test"); |
| 336 | 1024 | const adapter = makeAcpAdapterV2({ |
| 337 | 1025 | instanceId, |
| 338 | 1026 | flavor: { |
| 339 | 1027 | driver: ACP_TEST_DRIVER, |
| 340 | 1028 | capabilities: AcpProviderCapabilitiesV2, |
| 341 | − makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }), | |
| 1029 | + makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath }), | |
| 342 | 1030 | }, |
| 343 | 1031 | fileSystem, |
| 344 | 1032 | idAllocator, |
| 345 | 1033 | serverConfig, |
| 346 | 1034 | }); |
| 347 | − const firstThreadId = ThreadId.make("thread-acp-active-setup:first"); | |
| 1035 | + const threadId = ThreadId.make("thread-acp-unsupported-option"); | |
| 1036 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 1037 | + runtimeMode: "full-access", | |
| 1038 | + interactionMode: "default", | |
| 1039 | + cwd: process.cwd(), | |
| 1040 | + }); | |
| 1041 | + const error = yield* adapter | |
| 1042 | + .openSession({ | |
| 1043 | + threadId, | |
| 1044 | + providerSessionId: ProviderSessionId.make("provider-session-acp-unsupported-option"), | |
| 1045 | + modelSelection: { | |
| 1046 | + instanceId, | |
| 1047 | + model: "default", | |
| 1048 | + options: [{ id: "missing-option", value: "high" }], | |
| 1049 | + }, | |
| 1050 | + runtimePolicy, | |
| 1051 | + }) | |
| 1052 | + .pipe(Effect.flip); | |
| 1053 | + | |
| 1054 | + assert.equal(error._tag, "ProviderAdapterOpenSessionError"); | |
| 1055 | + assert.include(String(error.cause), "does not expose requested configuration option(s)"); | |
| 1056 | + assert.include(String(error.cause), "missing-option"); | |
| 1057 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 1058 | + ); | |
| 1059 | + | |
| 1060 | + it.effect("reconfigures a loaded ACP session from its own active setup metadata", () => | |
| 1061 | + Effect.gen(function* () { | |
| 1062 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 1063 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 1064 | + const idAllocator = yield* IdAllocatorV2; | |
| 1065 | + const path = yield* Path.Path; | |
| 1066 | + const serverConfig = yield* ServerConfig; | |
| 1067 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 1068 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 1069 | + ); | |
| 1070 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 1071 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 1072 | + const adapter = makeAcpAdapterV2({ | |
| 1073 | + instanceId, | |
| 1074 | + flavor: { | |
| 1075 | + driver: ACP_TEST_DRIVER, | |
| 1076 | + capabilities: AcpProviderCapabilitiesV2, | |
| 1077 | + makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }), | |
| 1078 | + }, | |
| 1079 | + fileSystem, | |
| 1080 | + idAllocator, | |
| 1081 | + serverConfig, | |
| 1082 | + }); | |
| 1083 | + const firstThreadId = ThreadId.make("thread-acp-active-setup:first"); | |
| 348 | 1084 | const secondThreadId = ThreadId.make("thread-acp-active-setup:second"); |
| 349 | 1085 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ |
| 350 | 1086 | runtimeMode: "full-access", |
| @@ -424,6 +1160,149 @@ describe("AcpAdapterV2", () => { · 73 unmodified lines | ||
| 424 | 1160 | }).pipe(Effect.provide(testLayer), Effect.scoped), |
| 425 | 1161 | ); |
| 426 | 1162 | |
| 1163 | + it.live("terminalizes an empty successful foreground Bash tool when the turn completes", () => | |
| 1164 | + Effect.gen(function* () { | |
| 1165 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 1166 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 1167 | + const idAllocator = yield* IdAllocatorV2; | |
| 1168 | + const path = yield* Path.Path; | |
| 1169 | + const serverConfig = yield* ServerConfig; | |
| 1170 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 1171 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 1172 | + ); | |
| 1173 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 1174 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 1175 | + const adapter = makeAcpAdapterV2({ | |
| 1176 | + instanceId, | |
| 1177 | + flavor: { | |
| 1178 | + driver: ACP_TEST_DRIVER, | |
| 1179 | + capabilities: AcpProviderCapabilitiesV2, | |
| 1180 | + makeRuntime: makeMockRuntime({ | |
| 1181 | + childProcessSpawner, | |
| 1182 | + mockAgentPath, | |
| 1183 | + environment: (runtimeOrdinal) => | |
| 1184 | + runtimeOrdinal === 1 ? { T3_ACP_EMIT_EMPTY_SUCCESSFUL_BASH_THEN_HANG: "1" } : {}, | |
| 1185 | + ownDetachedProcessGroup: true, | |
| 1186 | + protocolEvents, | |
| 1187 | + }), | |
| 1188 | + normalizeToolCall: normalizeXAiAcpToolCallState, | |
| 1189 | + restartRuntimeAfterInterrupt: true, | |
| 1190 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 1191 | + }, | |
| 1192 | + fileSystem, | |
| 1193 | + idAllocator, | |
| 1194 | + serverConfig, | |
| 1195 | + }); | |
| 1196 | + const threadId = ThreadId.make("thread-acp-empty-successful-bash"); | |
| 1197 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 1198 | + runtimeMode: "full-access", | |
| 1199 | + interactionMode: "default", | |
| 1200 | + cwd: process.cwd(), | |
| 1201 | + }); | |
| 1202 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 1203 | + const runtime = yield* adapter.openSession({ | |
| 1204 | + threadId, | |
| 1205 | + providerSessionId: ProviderSessionId.make("provider-session-acp-empty-successful-bash"), | |
| 1206 | + modelSelection, | |
| 1207 | + runtimePolicy, | |
| 1208 | + }); | |
| 1209 | + const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 1210 | + yield* runtime.events.pipe( | |
| 1211 | + Stream.runForEach((event) => Queue.offer(events, event)), | |
| 1212 | + Effect.forkScoped, | |
| 1213 | + ); | |
| 1214 | + const providerThread = yield* runtime.ensureThread({ | |
| 1215 | + threadId, | |
| 1216 | + modelSelection, | |
| 1217 | + runtimePolicy, | |
| 1218 | + }); | |
| 1219 | + const now = yield* DateTime.now; | |
| 1220 | + yield* runtime.startTurn( | |
| 1221 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | |
| 1222 | + ); | |
| 1223 | + | |
| 1224 | + const statuses: string[] = []; | |
| 1225 | + let runningStartedAt: DateTime.Utc | null = null; | |
| 1226 | + let completedStartedAt: DateTime.Utc | null = null; | |
| 1227 | + let completedAt: DateTime.Utc | null = null; | |
| 1228 | + let completedInput: string | null = null; | |
| 1229 | + let completedOutput: string | null | undefined; | |
| 1230 | + let completedExitCode: number | null | undefined; | |
| 1231 | + let runningProjectedExitCode: number | undefined = undefined; | |
| 1232 | + let terminal = false; | |
| 1233 | + while (!terminal) { | |
| 1234 | + const event = yield* Queue.take(events); | |
| 1235 | + if ( | |
| 1236 | + event.type === "turn_item.updated" && | |
| 1237 | + event.turnItem.nativeItemRef?.nativeId === "tool-call-empty-success-1" | |
| 1238 | + ) { | |
| 1239 | + statuses.push(event.turnItem.status); | |
| 1240 | + if (event.turnItem.status === "running") { | |
| 1241 | + runningStartedAt ??= event.turnItem.startedAt; | |
| 1242 | + if (event.turnItem.type === "command_execution") { | |
| 1243 | + runningProjectedExitCode = event.turnItem.exitCode; | |
| 1244 | + } | |
| 1245 | + } | |
| 1246 | + if (event.turnItem.status === "completed") { | |
| 1247 | + completedStartedAt = event.turnItem.startedAt; | |
| 1248 | + completedAt = event.turnItem.completedAt; | |
| 1249 | + if (event.turnItem.type === "command_execution") { | |
| 1250 | + completedInput = event.turnItem.input; | |
| 1251 | + completedOutput = event.turnItem.output; | |
| 1252 | + completedExitCode = event.turnItem.exitCode; | |
| 1253 | + } | |
| 1254 | + } | |
| 1255 | + } | |
| 1256 | + if (event.type === "turn.terminal") terminal = true; | |
| 1257 | + } | |
| 1258 | + | |
| 1259 | + assert.deepEqual(statuses, ["running", "running", "completed"]); | |
| 1260 | + assert.deepEqual(completedStartedAt, runningStartedAt); | |
| 1261 | + assert.isNotNull(completedAt); | |
| 1262 | + assert.equal(completedInput, "true"); | |
| 1263 | + assert.equal(completedOutput, undefined); | |
| 1264 | + assert.equal( | |
| 1265 | + runningProjectedExitCode, | |
| 1266 | + undefined, | |
| 1267 | + "mid-stream exit_code must not project until the tool is terminal", | |
| 1268 | + ); | |
| 1269 | + assert.equal(completedExitCode, 0); | |
| 1270 | + | |
| 1271 | + yield* Queue.takeAll(protocolEvents); | |
| 1272 | + yield* runtime | |
| 1273 | + .startTurn( | |
| 1274 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), | |
| 1275 | + ) | |
| 1276 | + .pipe(Effect.forkScoped); | |
| 1277 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 1278 | + Stream.filter( | |
| 1279 | + (event) => | |
| 1280 | + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | |
| 1281 | + ), | |
| 1282 | + Stream.runHead, | |
| 1283 | + ); | |
| 1284 | + const secondProviderTurnId = idAllocator.derive.providerTurn({ | |
| 1285 | + driver: ACP_TEST_DRIVER, | |
| 1286 | + nativeTurnId: "mock-session-1:turn:2", | |
| 1287 | + }); | |
| 1288 | + yield* runtime.interruptTurn({ | |
| 1289 | + providerThread, | |
| 1290 | + providerTurnId: secondProviderTurnId, | |
| 1291 | + requestRuntimeRestart: true, | |
| 1292 | + }); | |
| 1293 | + yield* runtime.startTurn( | |
| 1294 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 3 }), | |
| 1295 | + ); | |
| 1296 | + const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe( | |
| 1297 | + Stream.filter( | |
| 1298 | + (event) => event.direction === "outgoing" && rawProtocolMethod(event) === "session/load", | |
| 1299 | + ), | |
| 1300 | + Stream.runHead, | |
| 1301 | + ); | |
| 1302 | + assert.isTrue(Option.isSome(loadAfterRestart)); | |
| 1303 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 1304 | + ); | |
| 1305 | + | |
| 427 | 1306 | it.effect("cancels pending permission requests while interrupting an ACP turn", () => |
| 428 | 1307 | Effect.gen(function* () { |
| 429 | 1308 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| @@ -434,6 +1313,7 @@ describe("AcpAdapterV2", () => { · 4 unmodified lines | ||
| 434 | 1313 | const mockAgentPath = yield* path.fromFileUrl( |
| 435 | 1314 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| 436 | 1315 | ); |
| 1316 | + const releaseCancel = yield* Deferred.make<void>(); | |
| 437 | 1317 | const instanceId = ProviderInstanceId.make("acp-test"); |
| 438 | 1318 | const adapter = makeAcpAdapterV2({ |
| 439 | 1319 | instanceId, |
| @@ -444,6 +1324,7 @@ describe("AcpAdapterV2", () => { · 4 unmodified lines | ||
| 444 | 1324 | childProcessSpawner, |
| 445 | 1325 | mockAgentPath, |
| 446 | 1326 | environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, |
| 1327 | + wrapCancel: (cancel) => Deferred.await(releaseCancel).pipe(Effect.andThen(cancel)), | |
| 447 | 1328 | }), |
| 448 | 1329 | }, |
| 449 | 1330 | fileSystem, |
| @@ -489,10 +1370,12 @@ describe("AcpAdapterV2", () => { · 39 unmodified lines | ||
| 489 | 1370 | return yield* Effect.die("Expected a pending ACP permission request with a provider turn"); |
| 490 | 1371 | } |
| 491 | 1372 | |
| 492 | − yield* runtime.interruptTurn({ | |
| 493 | − providerThread, | |
| 494 | − providerTurnId: pendingRequest.runtimeRequest.providerTurnId, | |
| 495 | − }); | |
| 1373 | + const interruptFiber = yield* runtime | |
| 1374 | + .interruptTurn({ | |
| 1375 | + providerThread, | |
| 1376 | + providerTurnId: pendingRequest.runtimeRequest.providerTurnId, | |
| 1377 | + }) | |
| 1378 | + .pipe(Effect.forkScoped); | |
| 496 | 1379 | |
| 497 | 1380 | const cancelledRequest = Option.getOrThrow( |
| 498 | 1381 | yield* runtime.events.pipe( |
| @@ -506,10 +1389,19 @@ describe("AcpAdapterV2", () => { · 7 unmodified lines | ||
| 506 | 1389 | ), |
| 507 | 1390 | ); |
| 508 | 1391 | assert.equal(cancelledRequest.type, "runtime_request.updated"); |
| 1392 | + yield* Deferred.succeed(releaseCancel, undefined); | |
| 1393 | + yield* Fiber.join(interruptFiber); | |
| 1394 | + const terminal = Option.getOrThrow( | |
| 1395 | + yield* runtime.events.pipe( | |
| 1396 | + Stream.filter((event) => event.type === "turn.terminal"), | |
| 1397 | + Stream.runHead, | |
| 1398 | + ), | |
| 1399 | + ); | |
| 1400 | + assert.equal(terminal.type, "turn.terminal"); | |
| 509 | 1401 | }).pipe(Effect.provide(testLayer), Effect.scoped), |
| 510 | 1402 | ); |
| 511 | 1403 | |
| 512 | − it.effect("releases an ACP turn when cancellation times out", () => | |
| 1404 | + it.live("keeps hard teardown excluded until a permission response is enqueued", () => | |
| 513 | 1405 | Effect.gen(function* () { |
| 514 | 1406 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 515 | 1407 | const fileSystem = yield* FileSystem.FileSystem; |
| @@ -519,8 +1411,9 @@ describe("AcpAdapterV2", () => { · 3 unmodified lines | ||
| 519 | 1411 | const mockAgentPath = yield* path.fromFileUrl( |
| 520 | 1412 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| 521 | 1413 | ); |
| 1414 | + const responseEnqueued = yield* Deferred.make<void>(); | |
| 1415 | + const releaseResponseAcknowledgement = yield* Deferred.make<void>(); | |
| 522 | 1416 | const instanceId = ProviderInstanceId.make("acp-test"); |
| 523 | − const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 524 | 1417 | const adapter = makeAcpAdapterV2({ |
| 525 | 1418 | instanceId, |
| 526 | 1419 | flavor: { |
| @@ -529,24 +1422,28 @@ describe("AcpAdapterV2", () => { · 2 unmodified lines | ||
| 529 | 1422 | makeRuntime: makeMockRuntime({ |
| 530 | 1423 | childProcessSpawner, |
| 531 | 1424 | mockAgentPath, |
| 532 | − environment: { T3_ACP_PROMPT_DELAY_MS: "5000" }, | |
| 533 | − protocolEvents, | |
| 1425 | + environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, | |
| 1426 | + wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => | |
| 1427 | + Deferred.succeed(responseEnqueued, undefined).pipe( | |
| 1428 | + Effect.andThen(Deferred.await(releaseResponseAcknowledgement)), | |
| 1429 | + Effect.andThen(onOutgoingResponse(requestId)), | |
| 1430 | + ), | |
| 534 | 1431 | }), |
| 535 | 1432 | }, |
| 536 | 1433 | fileSystem, |
| 537 | 1434 | idAllocator, |
| 538 | 1435 | serverConfig, |
| 539 | 1436 | }); |
| 540 | − const threadId = ThreadId.make("thread-acp-cancel-timeout"); | |
| 1437 | + const threadId = ThreadId.make("thread-acp-response-wins-permission"); | |
| 541 | 1438 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ |
| 542 | − runtimeMode: "full-access", | |
| 1439 | + runtimeMode: "approval-required", | |
| 543 | 1440 | interactionMode: "default", |
| 544 | 1441 | cwd: process.cwd(), |
| 545 | 1442 | }); |
| 546 | 1443 | const modelSelection = { instanceId, model: "default" } as const; |
| 547 | 1444 | const runtime = yield* adapter.openSession({ |
| 548 | 1445 | threadId, |
| 549 | − providerSessionId: ProviderSessionId.make("provider-session-acp-cancel-timeout"), | |
| 1446 | + providerSessionId: ProviderSessionId.make("provider-session-acp-response-wins-permission"), | |
| 550 | 1447 | modelSelection, |
| 551 | 1448 | runtimePolicy, |
| 552 | 1449 | }); |
| @@ -555,57 +1452,5168 @@ describe("AcpAdapterV2", () => { · 2 unmodified lines | ||
| 555 | 1452 | modelSelection, |
| 556 | 1453 | runtimePolicy, |
| 557 | 1454 | }); |
| 558 | − const now = yield* DateTime.now; | |
| 559 | − const firstTurn = makeTurnInput({ | |
| 560 | − threadId, | |
| 561 | − providerThread, | |
| 562 | − instanceId, | |
| 563 | − runtimePolicy, | |
| 564 | − now, | |
| 565 | − }); | |
| 566 | − yield* runtime.startTurn(firstTurn); | |
| 567 | − yield* Stream.fromQueue(protocolEvents).pipe( | |
| 568 | − Stream.filter( | |
| 569 | − (event) => | |
| 570 | − event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | |
| 571 | − ), | |
| 572 | − Stream.runHead, | |
| 1455 | + yield* runtime.startTurn( | |
| 1456 | + makeTurnInput({ | |
| 1457 | + threadId, | |
| 1458 | + providerThread, | |
| 1459 | + instanceId, | |
| 1460 | + runtimePolicy, | |
| 1461 | + now: yield* DateTime.now, | |
| 1462 | + }), | |
| 573 | 1463 | ); |
| 574 | − const providerTurnId = idAllocator.derive.providerTurn({ | |
| 575 | − driver: ACP_TEST_DRIVER, | |
| 576 | − nativeTurnId: "mock-session-1:turn:1", | |
| 577 | − }); | |
| 578 | − const interruptFiber = yield* runtime | |
| 579 | − .interruptTurn({ providerThread, providerTurnId }) | |
| 580 | − .pipe(Effect.flip, Effect.forkScoped); | |
| 581 | − yield* Stream.fromQueue(protocolEvents).pipe( | |
| 582 | − Stream.filter( | |
| 583 | − (event) => | |
| 584 | − event.direction === "outgoing" && rawProtocolMethod(event) === "session/cancel", | |
| 1464 | + const pending = Option.getOrThrow( | |
| 1465 | + yield* runtime.events.pipe( | |
| 1466 | + Stream.filter( | |
| 1467 | + (event) => | |
| 1468 | + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | |
| 1469 | + ), | |
| 1470 | + Stream.runHead, | |
| 585 | 1471 | ), |
| 586 | − Stream.runHead, | |
| 587 | 1472 | ); |
| 588 | − yield* TestClock.adjust("10 seconds"); | |
| 589 | − const interruptError = yield* Fiber.join(interruptFiber); | |
| 590 | − assert.equal(interruptError._tag, "ProviderAdapterInterruptError"); | |
| 1473 | + if ( | |
| 1474 | + pending.type !== "runtime_request.updated" || | |
| 1475 | + pending.runtimeRequest.providerTurnId === null | |
| 1476 | + ) { | |
| 1477 | + return yield* Effect.die("Expected a pending permission request"); | |
| 1478 | + } | |
| 1479 | + const responseFiber = yield* runtime | |
| 1480 | + .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" }) | |
| 1481 | + .pipe(Effect.forkScoped); | |
| 1482 | + yield* Deferred.await(responseEnqueued); | |
| 1483 | + const interruptFiber = yield* runtime | |
| 1484 | + .interruptTurn({ | |
| 1485 | + providerThread, | |
| 1486 | + providerTurnId: pending.runtimeRequest.providerTurnId, | |
| 1487 | + requestRuntimeRestart: true, | |
| 1488 | + }) | |
| 1489 | + .pipe(Effect.forkScoped); | |
| 1490 | + yield* Effect.yieldNow; | |
| 1491 | + assert.isUndefined(responseFiber.pollUnsafe()); | |
| 1492 | + assert.isUndefined(interruptFiber.pollUnsafe()); | |
| 1493 | + | |
| 1494 | + yield* Deferred.succeed(releaseResponseAcknowledgement, undefined); | |
| 1495 | + yield* Fiber.join(responseFiber); | |
| 1496 | + yield* Fiber.join(interruptFiber); | |
| 1497 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 1498 | + ); | |
| 591 | 1499 | |
| 1500 | + it.live("correlates reordered elicitation schemas through the completed stdout write", () => | |
| 1501 | + Effect.gen(function* () { | |
| 1502 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 1503 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 1504 | + const idAllocator = yield* IdAllocatorV2; | |
| 1505 | + const path = yield* Path.Path; | |
| 1506 | + const serverConfig = yield* ServerConfig; | |
| 1507 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 1508 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 1509 | + ); | |
| 1510 | + const responseWritten = yield* Deferred.make<void>(); | |
| 1511 | + const releaseResponseAcknowledgement = yield* Deferred.make<void>(); | |
| 1512 | + const instanceId = ProviderInstanceId.make("acp-test-reordered-elicitation"); | |
| 1513 | + const adapter = makeAcpAdapterV2({ | |
| 1514 | + instanceId, | |
| 1515 | + flavor: { | |
| 1516 | + driver: ACP_TEST_DRIVER, | |
| 1517 | + capabilities: AcpProviderCapabilitiesV2, | |
| 1518 | + makeRuntime: makeMockRuntime({ | |
| 1519 | + childProcessSpawner, | |
| 1520 | + mockAgentPath, | |
| 1521 | + environment: { T3_ACP_EMIT_ELICITATION: "1" }, | |
| 1522 | + wrapIncomingRequest: (onIncomingRequest) => (requestId, method, payload) => { | |
| 1523 | + if (method !== "session/elicitation") { | |
| 1524 | + return onIncomingRequest(requestId, method, payload); | |
| 1525 | + } | |
| 1526 | + const record = payload as Record<string, unknown>; | |
| 1527 | + return onIncomingRequest(requestId, method, { | |
| 1528 | + ...record, | |
| 1529 | + requestedSchema: { | |
| 1530 | + properties: { | |
| 1531 | + approved: { title: "Approved", type: "boolean" }, | |
| 1532 | + }, | |
| 1533 | + type: "object", | |
| 1534 | + }, | |
| 1535 | + }); | |
| 1536 | + }, | |
| 1537 | + wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => | |
| 1538 | + Deferred.succeed(responseWritten, undefined).pipe( | |
| 1539 | + Effect.andThen(Deferred.await(releaseResponseAcknowledgement)), | |
| 1540 | + Effect.andThen(onOutgoingResponse(requestId)), | |
| 1541 | + ), | |
| 1542 | + }), | |
| 1543 | + }, | |
| 1544 | + fileSystem, | |
| 1545 | + idAllocator, | |
| 1546 | + serverConfig, | |
| 1547 | + }); | |
| 1548 | + const threadId = ThreadId.make("thread-acp-reordered-elicitation"); | |
| 1549 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 1550 | + runtimeMode: "approval-required", | |
| 1551 | + interactionMode: "default", | |
| 1552 | + cwd: process.cwd(), | |
| 1553 | + }); | |
| 1554 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 1555 | + const runtime = yield* adapter.openSession({ | |
| 1556 | + threadId, | |
| 1557 | + providerSessionId: ProviderSessionId.make("provider-session-acp-reordered-elicitation"), | |
| 1558 | + modelSelection, | |
| 1559 | + runtimePolicy, | |
| 1560 | + }); | |
| 1561 | + const providerThread = yield* runtime.ensureThread({ | |
| 1562 | + threadId, | |
| 1563 | + modelSelection, | |
| 1564 | + runtimePolicy, | |
| 1565 | + }); | |
| 592 | 1566 | yield* runtime.startTurn( |
| 593 | 1567 | makeTurnInput({ |
| 594 | 1568 | threadId, |
| 595 | 1569 | providerThread, |
| 596 | 1570 | instanceId, |
| 597 | 1571 | runtimePolicy, |
| 598 | − now, | |
| 599 | − ordinal: 2, | |
| 1572 | + now: yield* DateTime.now, | |
| 600 | 1573 | }), |
| 601 | 1574 | ); |
| 602 | − yield* Stream.fromQueue(protocolEvents).pipe( | |
| 603 | − Stream.filter( | |
| 604 | − (event) => | |
| 605 | − event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | |
| 1575 | + const pending = Option.getOrThrow( | |
| 1576 | + yield* runtime.events.pipe( | |
| 1577 | + Stream.filter( | |
| 1578 | + (event) => | |
| 1579 | + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | |
| 1580 | + ), | |
| 1581 | + Stream.runHead, | |
| 606 | 1582 | ), |
| 607 | − Stream.runHead, | |
| 608 | 1583 | ); |
| 1584 | + if (pending.type !== "runtime_request.updated") { | |
| 1585 | + return yield* Effect.die("Expected a pending elicitation request"); | |
| 1586 | + } | |
| 1587 | + const responseFiber = yield* runtime | |
| 1588 | + .respondToRuntimeRequest({ | |
| 1589 | + requestId: pending.runtimeRequest.id, | |
| 1590 | + answers: { approved: ["true"] }, | |
| 1591 | + }) | |
| 1592 | + .pipe(Effect.forkScoped); | |
| 1593 | + | |
| 1594 | + yield* Deferred.await(responseWritten); | |
| 1595 | + assert.isUndefined(responseFiber.pollUnsafe()); | |
| 1596 | + yield* Deferred.succeed(releaseResponseAcknowledgement, undefined); | |
| 1597 | + yield* Fiber.join(responseFiber); | |
| 609 | 1598 | }).pipe(Effect.provide(testLayer), Effect.scoped), |
| 610 | 1599 | ); |
| 1600 | + | |
| 1601 | + it.live("fails a held native response acknowledgement before normal session close", () => | |
| 1602 | + Effect.gen(function* () { | |
| 1603 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 1604 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 1605 | + const idAllocator = yield* IdAllocatorV2; | |
| 1606 | + const path = yield* Path.Path; | |
| 1607 | + const serverConfig = yield* ServerConfig; | |
| 1608 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 1609 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 1610 | + ); | |
| 1611 | + const responseWritten = yield* Deferred.make<void>(); | |
| 1612 | + const releaseResponseAcknowledgement = yield* Deferred.make<void>(); | |
| 1613 | + const responseLifecycle: Array<string> = []; | |
| 1614 | + const instanceId = ProviderInstanceId.make("acp-test-normal-close-held-response"); | |
| 1615 | + const adapter = makeAcpAdapterV2({ | |
| 1616 | + instanceId, | |
| 1617 | + flavor: { | |
| 1618 | + driver: ACP_TEST_DRIVER, | |
| 1619 | + capabilities: AcpProviderCapabilitiesV2, | |
| 1620 | + makeRuntime: makeMockRuntime({ | |
| 1621 | + childProcessSpawner, | |
| 1622 | + mockAgentPath, | |
| 1623 | + environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, | |
| 1624 | + wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => | |
| 1625 | + Deferred.succeed(responseWritten, undefined).pipe( | |
| 1626 | + Effect.andThen(Deferred.await(releaseResponseAcknowledgement)), | |
| 1627 | + Effect.andThen(onOutgoingResponse(requestId)), | |
| 1628 | + ), | |
| 1629 | + }), | |
| 1630 | + }, | |
| 1631 | + fileSystem, | |
| 1632 | + idAllocator, | |
| 1633 | + serverConfig, | |
| 1634 | + testHooks: { | |
| 1635 | + onNativeResponseLifecycle: (event) => | |
| 1636 | + Effect.sync(() => { | |
| 1637 | + responseLifecycle.push(event.type); | |
| 1638 | + }), | |
| 1639 | + }, | |
| 1640 | + }); | |
| 1641 | + const threadId = ThreadId.make("thread-acp-normal-close-held-response"); | |
| 1642 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 1643 | + runtimeMode: "approval-required", | |
| 1644 | + interactionMode: "default", | |
| 1645 | + cwd: process.cwd(), | |
| 1646 | + }); | |
| 1647 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 1648 | + const sessionScope = yield* Scope.make(); | |
| 1649 | + const runtime = yield* adapter | |
| 1650 | + .openSession({ | |
| 1651 | + threadId, | |
| 1652 | + providerSessionId: ProviderSessionId.make( | |
| 1653 | + "provider-session-acp-normal-close-held-response", | |
| 1654 | + ), | |
| 1655 | + modelSelection, | |
| 1656 | + runtimePolicy, | |
| 1657 | + }) | |
| 1658 | + .pipe(Effect.provideService(Scope.Scope, sessionScope)); | |
| 1659 | + const providerThread = yield* runtime.ensureThread({ | |
| 1660 | + threadId, | |
| 1661 | + modelSelection, | |
| 1662 | + runtimePolicy, | |
| 1663 | + }); | |
| 1664 | + yield* runtime.startTurn( | |
| 1665 | + makeTurnInput({ | |
| 1666 | + threadId, | |
| 1667 | + providerThread, | |
| 1668 | + instanceId, | |
| 1669 | + runtimePolicy, | |
| 1670 | + now: yield* DateTime.now, | |
| 1671 | + }), | |
| 1672 | + ); | |
| 1673 | + const pending = Option.getOrThrow( | |
| 1674 | + yield* runtime.events.pipe( | |
| 1675 | + Stream.filter( | |
| 1676 | + (event) => | |
| 1677 | + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | |
| 1678 | + ), | |
| 1679 | + Stream.runHead, | |
| 1680 | + ), | |
| 1681 | + ); | |
| 1682 | + if (pending.type !== "runtime_request.updated") { | |
| 1683 | + return yield* Effect.die("Expected a pending permission request"); | |
| 1684 | + } | |
| 1685 | + const responseFiber = yield* runtime | |
| 1686 | + .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" }) | |
| 1687 | + .pipe(Effect.exit, Effect.forkScoped); | |
| 1688 | + yield* Deferred.await(responseWritten); | |
| 1689 | + | |
| 1690 | + const closeFiber = yield* Scope.close(sessionScope, Exit.void).pipe(Effect.forkScoped); | |
| 1691 | + while (!responseLifecycle.includes("failed")) { | |
| 1692 | + yield* Effect.yieldNow; | |
| 1693 | + } | |
| 1694 | + const responseExit = yield* Fiber.join(responseFiber); | |
| 1695 | + if (Exit.isSuccess(responseExit)) { | |
| 1696 | + assert.fail("normal close must fail a response whose transport acknowledgement is held"); | |
| 1697 | + } | |
| 1698 | + assert.include(Cause.pretty(responseExit.cause), "ACP session transport closed"); | |
| 1699 | + assert.include(responseLifecycle, "removed"); | |
| 1700 | + assert.include(responseLifecycle, "failed"); | |
| 1701 | + yield* Deferred.succeed(releaseResponseAcknowledgement, undefined); | |
| 1702 | + yield* Fiber.join(closeFiber); | |
| 1703 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 1704 | + ); | |
| 1705 | + | |
| 1706 | + it.live("rejects delayed native response registration when normal close wins the permit", () => | |
| 1707 | + Effect.gen(function* () { | |
| 1708 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 1709 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 1710 | + const idAllocator = yield* IdAllocatorV2; | |
| 1711 | + const path = yield* Path.Path; | |
| 1712 | + const serverConfig = yield* ServerConfig; | |
| 1713 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 1714 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 1715 | + ); | |
| 1716 | + const registrationStarted = yield* Deferred.make<void>(); | |
| 1717 | + const releaseRegistration = yield* Deferred.make<void>(); | |
| 1718 | + const transportClosed = yield* Deferred.make<void>(); | |
| 1719 | + const releaseTransportClose = yield* Deferred.make<void>(); | |
| 1720 | + const responseLifecycle: Array<string> = []; | |
| 1721 | + const instanceId = ProviderInstanceId.make("acp-test-close-wins-registration"); | |
| 1722 | + const adapter = makeAcpAdapterV2({ | |
| 1723 | + instanceId, | |
| 1724 | + flavor: { | |
| 1725 | + driver: ACP_TEST_DRIVER, | |
| 1726 | + capabilities: AcpProviderCapabilitiesV2, | |
| 1727 | + makeRuntime: makeMockRuntime({ | |
| 1728 | + childProcessSpawner, | |
| 1729 | + mockAgentPath, | |
| 1730 | + environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, | |
| 1731 | + }), | |
| 1732 | + }, | |
| 1733 | + fileSystem, | |
| 1734 | + idAllocator, | |
| 1735 | + serverConfig, | |
| 1736 | + testHooks: { | |
| 1737 | + afterNativeResponseTransportClosed: () => | |
| 1738 | + Deferred.succeed(transportClosed, undefined).pipe( | |
| 1739 | + Effect.andThen(Deferred.await(releaseTransportClose)), | |
| 1740 | + ), | |
| 1741 | + beforeNativeResponseAdmissionCheck: () => | |
| 1742 | + Deferred.succeed(registrationStarted, undefined).pipe( | |
| 1743 | + Effect.andThen(Deferred.await(releaseRegistration)), | |
| 1744 | + ), | |
| 1745 | + onNativeResponseLifecycle: (event) => | |
| 1746 | + Effect.sync(() => { | |
| 1747 | + responseLifecycle.push(event.type); | |
| 1748 | + }), | |
| 1749 | + }, | |
| 1750 | + }); | |
| 1751 | + const threadId = ThreadId.make("thread-acp-close-wins-registration"); | |
| 1752 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 1753 | + runtimeMode: "approval-required", | |
| 1754 | + interactionMode: "default", | |
| 1755 | + cwd: process.cwd(), | |
| 1756 | + }); | |
| 1757 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 1758 | + const sessionScope = yield* Scope.make(); | |
| 1759 | + const runtime = yield* adapter | |
| 1760 | + .openSession({ | |
| 1761 | + threadId, | |
| 1762 | + providerSessionId: ProviderSessionId.make("provider-session-acp-close-wins-registration"), | |
| 1763 | + modelSelection, | |
| 1764 | + runtimePolicy, | |
| 1765 | + }) | |
| 1766 | + .pipe(Effect.provideService(Scope.Scope, sessionScope)); | |
| 1767 | + const providerThread = yield* runtime.ensureThread({ | |
| 1768 | + threadId, | |
| 1769 | + modelSelection, | |
| 1770 | + runtimePolicy, | |
| 1771 | + }); | |
| 1772 | + yield* runtime.startTurn( | |
| 1773 | + makeTurnInput({ | |
| 1774 | + threadId, | |
| 1775 | + providerThread, | |
| 1776 | + instanceId, | |
| 1777 | + runtimePolicy, | |
| 1778 | + now: yield* DateTime.now, | |
| 1779 | + }), | |
| 1780 | + ); | |
| 1781 | + const pending = Option.getOrThrow( | |
| 1782 | + yield* runtime.events.pipe( | |
| 1783 | + Stream.filter( | |
| 1784 | + (event) => | |
| 1785 | + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | |
| 1786 | + ), | |
| 1787 | + Stream.runHead, | |
| 1788 | + ), | |
| 1789 | + ); | |
| 1790 | + if (pending.type !== "runtime_request.updated") { | |
| 1791 | + return yield* Effect.die("Expected a pending permission request"); | |
| 1792 | + } | |
| 1793 | + const responseFiber = yield* runtime | |
| 1794 | + .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" }) | |
| 1795 | + .pipe(Effect.exit, Effect.forkScoped); | |
| 1796 | + yield* Deferred.await(registrationStarted); | |
| 1797 | + | |
| 1798 | + const closeFiber = yield* Scope.close(sessionScope, Exit.void).pipe(Effect.forkScoped); | |
| 1799 | + yield* Deferred.await(transportClosed); | |
| 1800 | + yield* Deferred.succeed(releaseRegistration, undefined); | |
| 1801 | + while (!responseLifecycle.includes("admission_rejected")) { | |
| 1802 | + yield* Effect.yieldNow; | |
| 1803 | + } | |
| 1804 | + const responseExit = yield* Fiber.join(responseFiber); | |
| 1805 | + if (Exit.isSuccess(responseExit)) { | |
| 1806 | + assert.fail("normal close must reject a response delayed before transport registration"); | |
| 1807 | + } | |
| 1808 | + assert.include(Cause.pretty(responseExit.cause), "ACP session transport closed"); | |
| 1809 | + assert.equal(responseLifecycle.filter((event) => event === "registered").length, 1); | |
| 1810 | + assert.equal(responseLifecycle.filter((event) => event === "removed").length, 1); | |
| 1811 | + assert.equal(responseLifecycle.filter((event) => event === "admission_rejected").length, 1); | |
| 1812 | + yield* Deferred.succeed(releaseTransportClose, undefined); | |
| 1813 | + yield* Fiber.join(closeFiber); | |
| 1814 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 1815 | + ); | |
| 1816 | + | |
| 1817 | + it.live("bounds a missing pending permission response acknowledgement", () => | |
| 1818 | + Effect.gen(function* () { | |
| 1819 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 1820 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 1821 | + const idAllocator = yield* IdAllocatorV2; | |
| 1822 | + const path = yield* Path.Path; | |
| 1823 | + const serverConfig = yield* ServerConfig; | |
| 1824 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 1825 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 1826 | + ); | |
| 1827 | + const responseEnqueued = yield* Deferred.make<void>(); | |
| 1828 | + const releaseNativeHook = yield* Deferred.make<void>(); | |
| 1829 | + const responseLifecycle: Array<string> = []; | |
| 1830 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 1831 | + const instanceId = ProviderInstanceId.make("acp-test-pending-response-timeout"); | |
| 1832 | + const adapter = makeAcpAdapterV2({ | |
| 1833 | + instanceId, | |
| 1834 | + flavor: { | |
| 1835 | + driver: ACP_TEST_DRIVER, | |
| 1836 | + capabilities: AcpProviderCapabilitiesV2, | |
| 1837 | + restartRuntimeAfterInterrupt: true, | |
| 1838 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 1839 | + makeRuntime: makeMockRuntime({ | |
| 1840 | + childProcessSpawner, | |
| 1841 | + mockAgentPath, | |
| 1842 | + environment: { | |
| 1843 | + T3_ACP_EMIT_TOOL_CALLS: "1", | |
| 1844 | + T3_ACP_HANG_AFTER_PERMISSION: "1", | |
| 1845 | + }, | |
| 1846 | + ownDetachedProcessGroup: true, | |
| 1847 | + processGroupPlatform: "win32", | |
| 1848 | + protocolEvents, | |
| 1849 | + windowsProcessTreeTerminator: (pid) => | |
| 1850 | + Deferred.succeed(releaseNativeHook, undefined).pipe( | |
| 1851 | + Effect.andThen( | |
| 1852 | + Effect.sync(() => { | |
| 1853 | + process.kill(pid, "SIGTERM"); | |
| 1854 | + }), | |
| 1855 | + ), | |
| 1856 | + ), | |
| 1857 | + wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => | |
| 1858 | + Deferred.succeed(responseEnqueued, undefined).pipe( | |
| 1859 | + Effect.andThen(Deferred.await(releaseNativeHook)), | |
| 1860 | + Effect.andThen(onOutgoingResponse(requestId)), | |
| 1861 | + ), | |
| 1862 | + }), | |
| 1863 | + }, | |
| 1864 | + fileSystem, | |
| 1865 | + idAllocator, | |
| 1866 | + serverConfig, | |
| 1867 | + testHooks: { | |
| 1868 | + onNativeResponseLifecycle: (event) => | |
| 1869 | + Effect.sync(() => { | |
| 1870 | + responseLifecycle.push(event.type); | |
| 1871 | + }), | |
| 1872 | + }, | |
| 1873 | + }); | |
| 1874 | + const threadId = ThreadId.make("thread-acp-pending-response-timeout"); | |
| 1875 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 1876 | + runtimeMode: "approval-required", | |
| 1877 | + interactionMode: "default", | |
| 1878 | + cwd: process.cwd(), | |
| 1879 | + }); | |
| 1880 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 1881 | + const sessionScope = yield* Scope.make(); | |
| 1882 | + const runtime = yield* adapter | |
| 1883 | + .openSession({ | |
| 1884 | + threadId, | |
| 1885 | + providerSessionId: ProviderSessionId.make( | |
| 1886 | + "provider-session-acp-pending-response-timeout", | |
| 1887 | + ), | |
| 1888 | + modelSelection, | |
| 1889 | + runtimePolicy, | |
| 1890 | + }) | |
| 1891 | + .pipe(Effect.provideService(Scope.Scope, sessionScope)); | |
| 1892 | + yield* pollProtocolMethods(protocolEvents); | |
| 1893 | + const providerThread = yield* runtime.ensureThread({ | |
| 1894 | + threadId, | |
| 1895 | + modelSelection, | |
| 1896 | + runtimePolicy, | |
| 1897 | + }); | |
| 1898 | + yield* runtime.startTurn( | |
| 1899 | + makeTurnInput({ | |
| 1900 | + threadId, | |
| 1901 | + providerThread, | |
| 1902 | + instanceId, | |
| 1903 | + runtimePolicy, | |
| 1904 | + now: yield* DateTime.now, | |
| 1905 | + }), | |
| 1906 | + ); | |
| 1907 | + const pending = Option.getOrThrow( | |
| 1908 | + yield* runtime.events.pipe( | |
| 1909 | + Stream.filter( | |
| 1910 | + (event) => | |
| 1911 | + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | |
| 1912 | + ), | |
| 1913 | + Stream.runHead, | |
| 1914 | + ), | |
| 1915 | + ); | |
| 1916 | + if ( | |
| 1917 | + pending.type !== "runtime_request.updated" || | |
| 1918 | + pending.runtimeRequest.providerTurnId === null | |
| 1919 | + ) { | |
| 1920 | + return yield* Effect.die("Expected a pending permission request"); | |
| 1921 | + } | |
| 1922 | + const responseExit = yield* runtime | |
| 1923 | + .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" }) | |
| 1924 | + .pipe(Effect.exit); | |
| 1925 | + if (Exit.isSuccess(responseExit)) { | |
| 1926 | + assert.fail("missing native response acknowledgement must fail the pending response"); | |
| 1927 | + } | |
| 1928 | + assert.include(Cause.pretty(responseExit.cause), "Native response acknowledgement timed out"); | |
| 1929 | + assert.isTrue(yield* Deferred.isDone(responseEnqueued)); | |
| 1930 | + assert.isFalse(yield* Deferred.isDone(releaseNativeHook)); | |
| 1931 | + assert.isBelow(responseLifecycle.indexOf("removed"), responseLifecycle.indexOf("failed")); | |
| 1932 | + assert.includeMembers(responseLifecycle, [ | |
| 1933 | + "registered", | |
| 1934 | + "removed", | |
| 1935 | + "failed", | |
| 1936 | + "timer_exited", | |
| 1937 | + "timer_started", | |
| 1938 | + "watcher_exited", | |
| 1939 | + "watcher_started", | |
| 1940 | + ]); | |
| 1941 | + | |
| 1942 | + yield* runtime.interruptTurn({ | |
| 1943 | + providerThread, | |
| 1944 | + providerTurnId: pending.runtimeRequest.providerTurnId, | |
| 1945 | + requestRuntimeRestart: true, | |
| 1946 | + }); | |
| 1947 | + assert.isTrue(yield* Deferred.isDone(releaseNativeHook)); | |
| 1948 | + yield* Effect.sleep("50 millis"); | |
| 1949 | + assert.include(responseLifecycle, "late_noop"); | |
| 1950 | + yield* Scope.close(sessionScope, Exit.void); | |
| 1951 | + assert.notInclude(yield* pollProtocolMethods(protocolEvents), "session/close"); | |
| 1952 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 1953 | + ); | |
| 1954 | + | |
| 1955 | + it.live("defers caller cancellation until a pending response acknowledgement is bounded", () => | |
| 1956 | + Effect.gen(function* () { | |
| 1957 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 1958 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 1959 | + const idAllocator = yield* IdAllocatorV2; | |
| 1960 | + const path = yield* Path.Path; | |
| 1961 | + const serverConfig = yield* ServerConfig; | |
| 1962 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 1963 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 1964 | + ); | |
| 1965 | + const responseEnqueued = yield* Deferred.make<void>(); | |
| 1966 | + const releaseNativeHook = yield* Deferred.make<void>(); | |
| 1967 | + const instanceId = ProviderInstanceId.make("acp-test-pending-response-cancel"); | |
| 1968 | + const adapter = makeAcpAdapterV2({ | |
| 1969 | + instanceId, | |
| 1970 | + flavor: { | |
| 1971 | + driver: ACP_TEST_DRIVER, | |
| 1972 | + capabilities: AcpProviderCapabilitiesV2, | |
| 1973 | + restartRuntimeAfterInterrupt: true, | |
| 1974 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 1975 | + makeRuntime: makeMockRuntime({ | |
| 1976 | + childProcessSpawner, | |
| 1977 | + mockAgentPath, | |
| 1978 | + environment: { | |
| 1979 | + T3_ACP_EMIT_TOOL_CALLS: "1", | |
| 1980 | + T3_ACP_HANG_AFTER_PERMISSION: "1", | |
| 1981 | + }, | |
| 1982 | + ownDetachedProcessGroup: true, | |
| 1983 | + processGroupPlatform: "win32", | |
| 1984 | + windowsProcessTreeTerminator: (pid) => | |
| 1985 | + Deferred.succeed(releaseNativeHook, undefined).pipe( | |
| 1986 | + Effect.andThen( | |
| 1987 | + Effect.sync(() => { | |
| 1988 | + process.kill(pid, "SIGTERM"); | |
| 1989 | + }), | |
| 1990 | + ), | |
| 1991 | + ), | |
| 1992 | + wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => | |
| 1993 | + Deferred.succeed(responseEnqueued, undefined).pipe( | |
| 1994 | + Effect.andThen(Deferred.await(releaseNativeHook)), | |
| 1995 | + Effect.andThen(onOutgoingResponse(requestId)), | |
| 1996 | + ), | |
| 1997 | + }), | |
| 1998 | + }, | |
| 1999 | + fileSystem, | |
| 2000 | + idAllocator, | |
| 2001 | + serverConfig, | |
| 2002 | + }); | |
| 2003 | + const threadId = ThreadId.make("thread-acp-pending-response-cancel"); | |
| 2004 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 2005 | + runtimeMode: "approval-required", | |
| 2006 | + interactionMode: "default", | |
| 2007 | + cwd: process.cwd(), | |
| 2008 | + }); | |
| 2009 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 2010 | + const runtime = yield* adapter.openSession({ | |
| 2011 | + threadId, | |
| 2012 | + providerSessionId: ProviderSessionId.make("provider-session-acp-pending-response-cancel"), | |
| 2013 | + modelSelection, | |
| 2014 | + runtimePolicy, | |
| 2015 | + }); | |
| 2016 | + const providerThread = yield* runtime.ensureThread({ | |
| 2017 | + threadId, | |
| 2018 | + modelSelection, | |
| 2019 | + runtimePolicy, | |
| 2020 | + }); | |
| 2021 | + yield* runtime.startTurn( | |
| 2022 | + makeTurnInput({ | |
| 2023 | + threadId, | |
| 2024 | + providerThread, | |
| 2025 | + instanceId, | |
| 2026 | + runtimePolicy, | |
| 2027 | + now: yield* DateTime.now, | |
| 2028 | + }), | |
| 2029 | + ); | |
| 2030 | + const pending = Option.getOrThrow( | |
| 2031 | + yield* runtime.events.pipe( | |
| 2032 | + Stream.filter( | |
| 2033 | + (event) => | |
| 2034 | + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | |
| 2035 | + ), | |
| 2036 | + Stream.runHead, | |
| 2037 | + ), | |
| 2038 | + ); | |
| 2039 | + if ( | |
| 2040 | + pending.type !== "runtime_request.updated" || | |
| 2041 | + pending.runtimeRequest.providerTurnId === null | |
| 2042 | + ) { | |
| 2043 | + return yield* Effect.die("Expected a pending permission request"); | |
| 2044 | + } | |
| 2045 | + const responseFiber = yield* runtime | |
| 2046 | + .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" }) | |
| 2047 | + .pipe(Effect.forkScoped); | |
| 2048 | + yield* Deferred.await(responseEnqueued); | |
| 2049 | + const cancellationFiber = yield* Fiber.interrupt(responseFiber).pipe(Effect.forkScoped); | |
| 2050 | + const interruptFiber = yield* runtime | |
| 2051 | + .interruptTurn({ | |
| 2052 | + providerThread, | |
| 2053 | + providerTurnId: pending.runtimeRequest.providerTurnId, | |
| 2054 | + requestRuntimeRestart: true, | |
| 2055 | + }) | |
| 2056 | + .pipe(Effect.forkScoped); | |
| 2057 | + yield* Effect.sleep("100 millis"); | |
| 2058 | + assert.isUndefined(cancellationFiber.pollUnsafe()); | |
| 2059 | + assert.isUndefined(interruptFiber.pollUnsafe()); | |
| 2060 | + assert.isFalse(yield* Deferred.isDone(releaseNativeHook)); | |
| 2061 | + | |
| 2062 | + yield* Fiber.join(cancellationFiber); | |
| 2063 | + yield* Fiber.join(interruptFiber); | |
| 2064 | + assert.isTrue(yield* Deferred.isDone(releaseNativeHook)); | |
| 2065 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 2066 | + ); | |
| 2067 | + | |
| 2068 | + it.live("waits for immediate allow and deny permission responses before hard teardown", () => | |
| 2069 | + Effect.gen(function* () { | |
| 2070 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 2071 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 2072 | + const idAllocator = yield* IdAllocatorV2; | |
| 2073 | + const path = yield* Path.Path; | |
| 2074 | + const serverConfig = yield* ServerConfig; | |
| 2075 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 2076 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 2077 | + ); | |
| 2078 | + | |
| 2079 | + for (const [name, sandboxPolicy] of [ | |
| 2080 | + ["allow", undefined], | |
| 2081 | + ["deny", { type: "readOnly" } as const], | |
| 2082 | + ] as const) { | |
| 2083 | + yield* Effect.gen(function* () { | |
| 2084 | + const responseEnqueued = yield* Deferred.make<void>(); | |
| 2085 | + const releaseResponseAcknowledgement = yield* Deferred.make<void>(); | |
| 2086 | + const instanceId = ProviderInstanceId.make(`acp-test-${name}`); | |
| 2087 | + const adapter = makeAcpAdapterV2({ | |
| 2088 | + instanceId, | |
| 2089 | + flavor: { | |
| 2090 | + driver: ACP_TEST_DRIVER, | |
| 2091 | + capabilities: AcpProviderCapabilitiesV2, | |
| 2092 | + restartRuntimeAfterInterrupt: true, | |
| 2093 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 2094 | + makeRuntime: makeMockRuntime({ | |
| 2095 | + childProcessSpawner, | |
| 2096 | + mockAgentPath, | |
| 2097 | + environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, | |
| 2098 | + ownDetachedProcessGroup: true, | |
| 2099 | + processGroupPlatform: "win32", | |
| 2100 | + windowsProcessTreeTerminator: (pid) => | |
| 2101 | + Effect.sync(() => { | |
| 2102 | + process.kill(pid, "SIGTERM"); | |
| 2103 | + }), | |
| 2104 | + wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => | |
| 2105 | + Deferred.succeed(responseEnqueued, undefined).pipe( | |
| 2106 | + Effect.andThen(Deferred.await(releaseResponseAcknowledgement)), | |
| 2107 | + Effect.andThen(onOutgoingResponse(requestId)), | |
| 2108 | + ), | |
| 2109 | + }), | |
| 2110 | + }, | |
| 2111 | + fileSystem, | |
| 2112 | + idAllocator, | |
| 2113 | + serverConfig, | |
| 2114 | + }); | |
| 2115 | + const threadId = ThreadId.make(`thread-acp-immediate-permission-${name}`); | |
| 2116 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 2117 | + runtimeMode: "full-access", | |
| 2118 | + interactionMode: "default", | |
| 2119 | + approvalPolicy: "never", | |
| 2120 | + cwd: process.cwd(), | |
| 2121 | + ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }), | |
| 2122 | + }); | |
| 2123 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 2124 | + const runtime = yield* adapter.openSession({ | |
| 2125 | + threadId, | |
| 2126 | + providerSessionId: ProviderSessionId.make( | |
| 2127 | + `provider-session-acp-immediate-permission-${name}`, | |
| 2128 | + ), | |
| 2129 | + modelSelection, | |
| 2130 | + runtimePolicy, | |
| 2131 | + }); | |
| 2132 | + const providerThread = yield* runtime.ensureThread({ | |
| 2133 | + threadId, | |
| 2134 | + modelSelection, | |
| 2135 | + runtimePolicy, | |
| 2136 | + }); | |
| 2137 | + const turnFiber = yield* runtime | |
| 2138 | + .startTurn( | |
| 2139 | + makeTurnInput({ | |
| 2140 | + threadId, | |
| 2141 | + providerThread, | |
| 2142 | + instanceId, | |
| 2143 | + runtimePolicy, | |
| 2144 | + now: yield* DateTime.now, | |
| 2145 | + }), | |
| 2146 | + ) | |
| 2147 | + .pipe(Effect.forkDetach); | |
| 2148 | + yield* Deferred.await(responseEnqueued); | |
| 2149 | + const interruptFiber = yield* runtime | |
| 2150 | + .interruptTurn({ | |
| 2151 | + providerThread, | |
| 2152 | + providerTurnId: idAllocator.derive.providerTurn({ | |
| 2153 | + driver: ACP_TEST_DRIVER, | |
| 2154 | + nativeTurnId: "mock-session-1:turn:1", | |
| 2155 | + }), | |
| 2156 | + requestRuntimeRestart: true, | |
| 2157 | + }) | |
| 2158 | + .pipe(Effect.forkScoped); | |
| 2159 | + yield* Effect.yieldNow; | |
| 2160 | + assert.isUndefined(interruptFiber.pollUnsafe()); | |
| 2161 | + | |
| 2162 | + yield* Deferred.succeed(releaseResponseAcknowledgement, undefined); | |
| 2163 | + yield* Fiber.join(interruptFiber); | |
| 2164 | + yield* Fiber.interrupt(turnFiber).pipe(Effect.forkDetach); | |
| 2165 | + }).pipe(Effect.scoped); | |
| 2166 | + } | |
| 2167 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 2168 | + ); | |
| 2169 | + | |
| 2170 | + it.live("waits for immediate URL elicitation responses before hard teardown", () => | |
| 2171 | + Effect.gen(function* () { | |
| 2172 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 2173 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 2174 | + const idAllocator = yield* IdAllocatorV2; | |
| 2175 | + const path = yield* Path.Path; | |
| 2176 | + const serverConfig = yield* ServerConfig; | |
| 2177 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 2178 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 2179 | + ); | |
| 2180 | + const responseEnqueued = yield* Deferred.make<void>(); | |
| 2181 | + const releaseResponseAcknowledgement = yield* Deferred.make<void>(); | |
| 2182 | + const instanceId = ProviderInstanceId.make("acp-test-url-elicitation"); | |
| 2183 | + const adapter = makeAcpAdapterV2({ | |
| 2184 | + instanceId, | |
| 2185 | + flavor: { | |
| 2186 | + driver: ACP_TEST_DRIVER, | |
| 2187 | + capabilities: AcpProviderCapabilitiesV2, | |
| 2188 | + restartRuntimeAfterInterrupt: true, | |
| 2189 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 2190 | + makeRuntime: makeMockRuntime({ | |
| 2191 | + childProcessSpawner, | |
| 2192 | + mockAgentPath, | |
| 2193 | + environment: { T3_ACP_EMIT_URL_ELICITATION: "1" }, | |
| 2194 | + ownDetachedProcessGroup: true, | |
| 2195 | + processGroupPlatform: "win32", | |
| 2196 | + windowsProcessTreeTerminator: (pid) => | |
| 2197 | + Effect.sync(() => { | |
| 2198 | + process.kill(pid, "SIGTERM"); | |
| 2199 | + }), | |
| 2200 | + wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => | |
| 2201 | + Deferred.succeed(responseEnqueued, undefined).pipe( | |
| 2202 | + Effect.andThen(Deferred.await(releaseResponseAcknowledgement)), | |
| 2203 | + Effect.andThen(onOutgoingResponse(requestId)), | |
| 2204 | + ), | |
| 2205 | + }), | |
| 2206 | + }, | |
| 2207 | + fileSystem, | |
| 2208 | + idAllocator, | |
| 2209 | + serverConfig, | |
| 2210 | + }); | |
| 2211 | + const threadId = ThreadId.make("thread-acp-immediate-url-elicitation"); | |
| 2212 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 2213 | + runtimeMode: "full-access", | |
| 2214 | + interactionMode: "default", | |
| 2215 | + cwd: process.cwd(), | |
| 2216 | + }); | |
| 2217 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 2218 | + const runtime = yield* adapter.openSession({ | |
| 2219 | + threadId, | |
| 2220 | + providerSessionId: ProviderSessionId.make("provider-session-acp-immediate-url-elicitation"), | |
| 2221 | + modelSelection, | |
| 2222 | + runtimePolicy, | |
| 2223 | + }); | |
| 2224 | + const providerThread = yield* runtime.ensureThread({ | |
| 2225 | + threadId, | |
| 2226 | + modelSelection, | |
| 2227 | + runtimePolicy, | |
| 2228 | + }); | |
| 2229 | + const turnFiber = yield* runtime | |
| 2230 | + .startTurn( | |
| 2231 | + makeTurnInput({ | |
| 2232 | + threadId, | |
| 2233 | + providerThread, | |
| 2234 | + instanceId, | |
| 2235 | + runtimePolicy, | |
| 2236 | + now: yield* DateTime.now, | |
| 2237 | + }), | |
| 2238 | + ) | |
| 2239 | + .pipe(Effect.forkDetach); | |
| 2240 | + yield* Deferred.await(responseEnqueued); | |
| 2241 | + const interruptFiber = yield* runtime | |
| 2242 | + .interruptTurn({ | |
| 2243 | + providerThread, | |
| 2244 | + providerTurnId: idAllocator.derive.providerTurn({ | |
| 2245 | + driver: ACP_TEST_DRIVER, | |
| 2246 | + nativeTurnId: "mock-session-1:turn:1", | |
| 2247 | + }), | |
| 2248 | + requestRuntimeRestart: true, | |
| 2249 | + }) | |
| 2250 | + .pipe(Effect.forkScoped); | |
| 2251 | + yield* Effect.yieldNow; | |
| 2252 | + assert.isUndefined(interruptFiber.pollUnsafe()); | |
| 2253 | + | |
| 2254 | + yield* Deferred.succeed(releaseResponseAcknowledgement, undefined); | |
| 2255 | + yield* Fiber.join(interruptFiber); | |
| 2256 | + yield* Fiber.interrupt(turnFiber).pipe(Effect.forkDetach); | |
| 2257 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 2258 | + ); | |
| 2259 | + | |
| 2260 | + it.live("bounds a missing immediate response acknowledgement before hard teardown", () => | |
| 2261 | + Effect.gen(function* () { | |
| 2262 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 2263 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 2264 | + const idAllocator = yield* IdAllocatorV2; | |
| 2265 | + const path = yield* Path.Path; | |
| 2266 | + const serverConfig = yield* ServerConfig; | |
| 2267 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 2268 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 2269 | + ); | |
| 2270 | + const responseEnqueued = yield* Deferred.make<void>(); | |
| 2271 | + const releaseNativeHook = yield* Deferred.make<void>(); | |
| 2272 | + const instanceId = ProviderInstanceId.make("acp-test-missing-response-ack"); | |
| 2273 | + const adapter = makeAcpAdapterV2({ | |
| 2274 | + instanceId, | |
| 2275 | + flavor: { | |
| 2276 | + driver: ACP_TEST_DRIVER, | |
| 2277 | + capabilities: AcpProviderCapabilitiesV2, | |
| 2278 | + restartRuntimeAfterInterrupt: true, | |
| 2279 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 2280 | + makeRuntime: makeMockRuntime({ | |
| 2281 | + childProcessSpawner, | |
| 2282 | + mockAgentPath, | |
| 2283 | + environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, | |
| 2284 | + ownDetachedProcessGroup: true, | |
| 2285 | + processGroupPlatform: "win32", | |
| 2286 | + windowsProcessTreeTerminator: (pid) => | |
| 2287 | + Deferred.succeed(releaseNativeHook, undefined).pipe( | |
| 2288 | + Effect.andThen( | |
| 2289 | + Effect.sync(() => { | |
| 2290 | + process.kill(pid, "SIGTERM"); | |
| 2291 | + }), | |
| 2292 | + ), | |
| 2293 | + ), | |
| 2294 | + wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => | |
| 2295 | + Deferred.succeed(responseEnqueued, undefined).pipe( | |
| 2296 | + Effect.andThen(Deferred.await(releaseNativeHook)), | |
| 2297 | + Effect.andThen(onOutgoingResponse(requestId)), | |
| 2298 | + ), | |
| 2299 | + }), | |
| 2300 | + }, | |
| 2301 | + fileSystem, | |
| 2302 | + idAllocator, | |
| 2303 | + serverConfig, | |
| 2304 | + }); | |
| 2305 | + const threadId = ThreadId.make("thread-acp-missing-response-ack"); | |
| 2306 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 2307 | + runtimeMode: "full-access", | |
| 2308 | + interactionMode: "default", | |
| 2309 | + approvalPolicy: "never", | |
| 2310 | + cwd: process.cwd(), | |
| 2311 | + }); | |
| 2312 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 2313 | + const runtime = yield* adapter.openSession({ | |
| 2314 | + threadId, | |
| 2315 | + providerSessionId: ProviderSessionId.make("provider-session-acp-missing-response-ack"), | |
| 2316 | + modelSelection, | |
| 2317 | + runtimePolicy, | |
| 2318 | + }); | |
| 2319 | + const providerThread = yield* runtime.ensureThread({ | |
| 2320 | + threadId, | |
| 2321 | + modelSelection, | |
| 2322 | + runtimePolicy, | |
| 2323 | + }); | |
| 2324 | + const turnFiber = yield* runtime | |
| 2325 | + .startTurn( | |
| 2326 | + makeTurnInput({ | |
| 2327 | + threadId, | |
| 2328 | + providerThread, | |
| 2329 | + instanceId, | |
| 2330 | + runtimePolicy, | |
| 2331 | + now: yield* DateTime.now, | |
| 2332 | + }), | |
| 2333 | + ) | |
| 2334 | + .pipe(Effect.forkDetach); | |
| 2335 | + yield* Deferred.await(responseEnqueued); | |
| 2336 | + const startedAt = yield* Clock.currentTimeMillis; | |
| 2337 | + yield* runtime.interruptTurn({ | |
| 2338 | + providerThread, | |
| 2339 | + providerTurnId: idAllocator.derive.providerTurn({ | |
| 2340 | + driver: ACP_TEST_DRIVER, | |
| 2341 | + nativeTurnId: "mock-session-1:turn:1", | |
| 2342 | + }), | |
| 2343 | + requestRuntimeRestart: true, | |
| 2344 | + }); | |
| 2345 | + assert.isAtLeast((yield* Clock.currentTimeMillis) - startedAt, 1_500); | |
| 2346 | + yield* Fiber.interrupt(turnFiber).pipe(Effect.forkDetach); | |
| 2347 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 2348 | + ); | |
| 2349 | + | |
| 2350 | + it.live("rejects an elicitation response when hard teardown wins admission", () => | |
| 2351 | + Effect.gen(function* () { | |
| 2352 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 2353 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 2354 | + const idAllocator = yield* IdAllocatorV2; | |
| 2355 | + const path = yield* Path.Path; | |
| 2356 | + const serverConfig = yield* ServerConfig; | |
| 2357 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 2358 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 2359 | + ); | |
| 2360 | + const teardownStarted = yield* Deferred.make<void>(); | |
| 2361 | + const releaseTeardown = yield* Deferred.make<void>(); | |
| 2362 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 2363 | + const adapter = makeAcpAdapterV2({ | |
| 2364 | + instanceId, | |
| 2365 | + flavor: { | |
| 2366 | + driver: ACP_TEST_DRIVER, | |
| 2367 | + capabilities: AcpProviderCapabilitiesV2, | |
| 2368 | + restartRuntimeAfterInterrupt: true, | |
| 2369 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 2370 | + makeRuntime: makeMockRuntime({ | |
| 2371 | + childProcessSpawner, | |
| 2372 | + mockAgentPath, | |
| 2373 | + environment: { T3_ACP_EMIT_ELICITATION: "1" }, | |
| 2374 | + ownDetachedProcessGroup: true, | |
| 2375 | + processGroupPlatform: "win32", | |
| 2376 | + windowsProcessTreeTerminator: (pid) => | |
| 2377 | + Deferred.succeed(teardownStarted, undefined).pipe( | |
| 2378 | + Effect.andThen(Deferred.await(releaseTeardown)), | |
| 2379 | + Effect.andThen( | |
| 2380 | + Effect.sync(() => { | |
| 2381 | + process.kill(pid, "SIGTERM"); | |
| 2382 | + }), | |
| 2383 | + ), | |
| 2384 | + ), | |
| 2385 | + }), | |
| 2386 | + }, | |
| 2387 | + fileSystem, | |
| 2388 | + idAllocator, | |
| 2389 | + serverConfig, | |
| 2390 | + }); | |
| 2391 | + const threadId = ThreadId.make("thread-acp-teardown-wins-elicitation"); | |
| 2392 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 2393 | + runtimeMode: "approval-required", | |
| 2394 | + interactionMode: "default", | |
| 2395 | + cwd: process.cwd(), | |
| 2396 | + }); | |
| 2397 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 2398 | + const runtime = yield* adapter.openSession({ | |
| 2399 | + threadId, | |
| 2400 | + providerSessionId: ProviderSessionId.make("provider-session-acp-teardown-wins-elicitation"), | |
| 2401 | + modelSelection, | |
| 2402 | + runtimePolicy, | |
| 2403 | + }); | |
| 2404 | + const providerThread = yield* runtime.ensureThread({ | |
| 2405 | + threadId, | |
| 2406 | + modelSelection, | |
| 2407 | + runtimePolicy, | |
| 2408 | + }); | |
| 2409 | + yield* runtime.startTurn( | |
| 2410 | + makeTurnInput({ | |
| 2411 | + threadId, | |
| 2412 | + providerThread, | |
| 2413 | + instanceId, | |
| 2414 | + runtimePolicy, | |
| 2415 | + now: yield* DateTime.now, | |
| 2416 | + }), | |
| 2417 | + ); | |
| 2418 | + const pending = Option.getOrThrow( | |
| 2419 | + yield* runtime.events.pipe( | |
| 2420 | + Stream.filter( | |
| 2421 | + (event) => | |
| 2422 | + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | |
| 2423 | + ), | |
| 2424 | + Stream.runHead, | |
| 2425 | + ), | |
| 2426 | + ); | |
| 2427 | + if ( | |
| 2428 | + pending.type !== "runtime_request.updated" || | |
| 2429 | + pending.runtimeRequest.providerTurnId === null | |
| 2430 | + ) { | |
| 2431 | + return yield* Effect.die("Expected a pending elicitation request"); | |
| 2432 | + } | |
| 2433 | + const interruptFiber = yield* runtime | |
| 2434 | + .interruptTurn({ | |
| 2435 | + providerThread, | |
| 2436 | + providerTurnId: pending.runtimeRequest.providerTurnId, | |
| 2437 | + requestRuntimeRestart: true, | |
| 2438 | + }) | |
| 2439 | + .pipe(Effect.forkScoped); | |
| 2440 | + yield* Deferred.await(teardownStarted); | |
| 2441 | + const responseFiber = yield* runtime | |
| 2442 | + .respondToRuntimeRequest({ | |
| 2443 | + requestId: pending.runtimeRequest.id, | |
| 2444 | + answers: { approved: ["true"] }, | |
| 2445 | + }) | |
| 2446 | + .pipe(Effect.exit, Effect.forkScoped); | |
| 2447 | + yield* Effect.yieldNow; | |
| 2448 | + assert.isUndefined(responseFiber.pollUnsafe()); | |
| 2449 | + | |
| 2450 | + yield* Deferred.succeed(releaseTeardown, undefined); | |
| 2451 | + yield* Fiber.join(interruptFiber); | |
| 2452 | + const responseExit = yield* Fiber.join(responseFiber); | |
| 2453 | + if (Exit.isSuccess(responseExit)) { | |
| 2454 | + assert.fail("teardown winning admission must reject the elicitation response"); | |
| 2455 | + } | |
| 2456 | + assert.include(Cause.pretty(responseExit.cause), "No pending ACP runtime request"); | |
| 2457 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 2458 | + ); | |
| 2459 | + | |
| 2460 | + it.effect("releases an ACP turn when cancellation times out", () => | |
| 2461 | + Effect.gen(function* () { | |
| 2462 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 2463 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 2464 | + const idAllocator = yield* IdAllocatorV2; | |
| 2465 | + const path = yield* Path.Path; | |
| 2466 | + const serverConfig = yield* ServerConfig; | |
| 2467 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 2468 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 2469 | + ); | |
| 2470 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 2471 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 2472 | + const adapter = makeAcpAdapterV2({ | |
| 2473 | + instanceId, | |
| 2474 | + flavor: { | |
| 2475 | + driver: ACP_TEST_DRIVER, | |
| 2476 | + capabilities: AcpProviderCapabilitiesV2, | |
| 2477 | + makeRuntime: makeMockRuntime({ | |
| 2478 | + childProcessSpawner, | |
| 2479 | + mockAgentPath, | |
| 2480 | + environment: { T3_ACP_PROMPT_DELAY_MS: "5000" }, | |
| 2481 | + protocolEvents, | |
| 2482 | + }), | |
| 2483 | + }, | |
| 2484 | + fileSystem, | |
| 2485 | + idAllocator, | |
| 2486 | + serverConfig, | |
| 2487 | + }); | |
| 2488 | + const threadId = ThreadId.make("thread-acp-cancel-timeout"); | |
| 2489 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 2490 | + runtimeMode: "full-access", | |
| 2491 | + interactionMode: "default", | |
| 2492 | + cwd: process.cwd(), | |
| 2493 | + }); | |
| 2494 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 2495 | + const runtime = yield* adapter.openSession({ | |
| 2496 | + threadId, | |
| 2497 | + providerSessionId: ProviderSessionId.make("provider-session-acp-cancel-timeout"), | |
| 2498 | + modelSelection, | |
| 2499 | + runtimePolicy, | |
| 2500 | + }); | |
| 2501 | + const providerThread = yield* runtime.ensureThread({ | |
| 2502 | + threadId, | |
| 2503 | + modelSelection, | |
| 2504 | + runtimePolicy, | |
| 2505 | + }); | |
| 2506 | + const now = yield* DateTime.now; | |
| 2507 | + const firstTurn = makeTurnInput({ | |
| 2508 | + threadId, | |
| 2509 | + providerThread, | |
| 2510 | + instanceId, | |
| 2511 | + runtimePolicy, | |
| 2512 | + now, | |
| 2513 | + }); | |
| 2514 | + yield* runtime.startTurn(firstTurn); | |
| 2515 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 2516 | + Stream.filter( | |
| 2517 | + (event) => | |
| 2518 | + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | |
| 2519 | + ), | |
| 2520 | + Stream.runHead, | |
| 2521 | + ); | |
| 2522 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 2523 | + driver: ACP_TEST_DRIVER, | |
| 2524 | + nativeTurnId: "mock-session-1:turn:1", | |
| 2525 | + }); | |
| 2526 | + const interruptFiber = yield* runtime | |
| 2527 | + .interruptTurn({ providerThread, providerTurnId }) | |
| 2528 | + .pipe(Effect.flip, Effect.forkScoped); | |
| 2529 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 2530 | + Stream.filter( | |
| 2531 | + (event) => | |
| 2532 | + event.direction === "outgoing" && rawProtocolMethod(event) === "session/cancel", | |
| 2533 | + ), | |
| 2534 | + Stream.runHead, | |
| 2535 | + ); | |
| 2536 | + yield* TestClock.adjust("10 seconds"); | |
| 2537 | + const interruptError = yield* Fiber.join(interruptFiber); | |
| 2538 | + assert.equal(interruptError._tag, "ProviderAdapterInterruptError"); | |
| 2539 | + | |
| 2540 | + yield* runtime.startTurn( | |
| 2541 | + makeTurnInput({ | |
| 2542 | + threadId, | |
| 2543 | + providerThread, | |
| 2544 | + instanceId, | |
| 2545 | + runtimePolicy, | |
| 2546 | + now, | |
| 2547 | + ordinal: 2, | |
| 2548 | + }), | |
| 2549 | + ); | |
| 2550 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 2551 | + Stream.filter( | |
| 2552 | + (event) => | |
| 2553 | + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | |
| 2554 | + ), | |
| 2555 | + Stream.runHead, | |
| 2556 | + ); | |
| 2557 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 2558 | + ); | |
| 2559 | + | |
| 2560 | + it.live("treats a second hard Stop as success when the turn is already gone", () => | |
| 2561 | + Effect.gen(function* () { | |
| 2562 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 2563 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 2564 | + const idAllocator = yield* IdAllocatorV2; | |
| 2565 | + const path = yield* Path.Path; | |
| 2566 | + const serverConfig = yield* ServerConfig; | |
| 2567 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 2568 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 2569 | + ); | |
| 2570 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 2571 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 2572 | + const adapter = makeAcpAdapterV2({ | |
| 2573 | + instanceId, | |
| 2574 | + flavor: { | |
| 2575 | + driver: ACP_TEST_DRIVER, | |
| 2576 | + capabilities: AcpProviderCapabilitiesV2, | |
| 2577 | + interruptPromptOnCancel: false, | |
| 2578 | + restartRuntimeAfterInterrupt: true, | |
| 2579 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 2580 | + makeRuntime: makeMockRuntime({ | |
| 2581 | + childProcessSpawner, | |
| 2582 | + mockAgentPath, | |
| 2583 | + ownDetachedProcessGroup: true, | |
| 2584 | + environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, | |
| 2585 | + protocolEvents, | |
| 2586 | + }), | |
| 2587 | + }, | |
| 2588 | + fileSystem, | |
| 2589 | + idAllocator, | |
| 2590 | + serverConfig, | |
| 2591 | + }); | |
| 2592 | + const threadId = ThreadId.make("thread-acp-double-stop"); | |
| 2593 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 2594 | + runtimeMode: "full-access", | |
| 2595 | + interactionMode: "default", | |
| 2596 | + cwd: process.cwd(), | |
| 2597 | + }); | |
| 2598 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 2599 | + const runtime = yield* adapter.openSession({ | |
| 2600 | + threadId, | |
| 2601 | + providerSessionId: ProviderSessionId.make("provider-session-acp-double-stop"), | |
| 2602 | + modelSelection, | |
| 2603 | + runtimePolicy, | |
| 2604 | + }); | |
| 2605 | + const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 2606 | + yield* runtime.events.pipe( | |
| 2607 | + Stream.runForEach((event) => Queue.offer(events, event)), | |
| 2608 | + Effect.forkScoped, | |
| 2609 | + ); | |
| 2610 | + const providerThread = yield* runtime.ensureThread({ | |
| 2611 | + threadId, | |
| 2612 | + modelSelection, | |
| 2613 | + runtimePolicy, | |
| 2614 | + }); | |
| 2615 | + const now = yield* DateTime.now; | |
| 2616 | + yield* runtime | |
| 2617 | + .startTurn(makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now })) | |
| 2618 | + .pipe(Effect.forkScoped); | |
| 2619 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 2620 | + Stream.filter( | |
| 2621 | + (event) => | |
| 2622 | + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | |
| 2623 | + ), | |
| 2624 | + Stream.runHead, | |
| 2625 | + ); | |
| 2626 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 2627 | + driver: ACP_TEST_DRIVER, | |
| 2628 | + nativeTurnId: "mock-session-1:turn:1", | |
| 2629 | + }); | |
| 2630 | + yield* runtime.interruptTurn({ | |
| 2631 | + providerThread, | |
| 2632 | + providerTurnId, | |
| 2633 | + requestRuntimeRestart: true, | |
| 2634 | + }); | |
| 2635 | + // Second durable interrupt after activeTurn is cleared must not fail. | |
| 2636 | + const second = yield* Effect.exit( | |
| 2637 | + runtime.interruptTurn({ | |
| 2638 | + providerThread, | |
| 2639 | + providerTurnId, | |
| 2640 | + requestRuntimeRestart: true, | |
| 2641 | + }), | |
| 2642 | + ); | |
| 2643 | + assert.isTrue(Exit.isSuccess(second), "duplicate hard Stop must be idempotent"); | |
| 2644 | + let terminal: string | null = null; | |
| 2645 | + while (terminal === null) { | |
| 2646 | + const event = yield* Queue.take(events); | |
| 2647 | + if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { | |
| 2648 | + terminal = event.status; | |
| 2649 | + } | |
| 2650 | + } | |
| 2651 | + assert.equal(terminal, "interrupted"); | |
| 2652 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 2653 | + ); | |
| 2654 | + | |
| 2655 | + it.effect("finalizes a settled turn held open for background work when interrupted", () => | |
| 2656 | + Effect.gen(function* () { | |
| 2657 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 2658 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 2659 | + const idAllocator = yield* IdAllocatorV2; | |
| 2660 | + const path = yield* Path.Path; | |
| 2661 | + const serverConfig = yield* ServerConfig; | |
| 2662 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 2663 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 2664 | + ); | |
| 2665 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 2666 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 2667 | + const adapter = makeAcpAdapterV2({ | |
| 2668 | + instanceId, | |
| 2669 | + flavor: { | |
| 2670 | + driver: ACP_TEST_DRIVER, | |
| 2671 | + capabilities: AcpProviderCapabilitiesV2, | |
| 2672 | + deferFinalizeForBackgroundWork: true, | |
| 2673 | + extractSubagentUpdate: (toolCall) => | |
| 2674 | + toolCall.toolCallId === "tool-call-generic-1" | |
| 2675 | + ? { | |
| 2676 | + nativeTaskId: "task-generic-1", | |
| 2677 | + prompt: "background subagent", | |
| 2678 | + title: "background subagent", | |
| 2679 | + model: null, | |
| 2680 | + status: "running", | |
| 2681 | + childSessionId: null, | |
| 2682 | + result: null, | |
| 2683 | + } | |
| 2684 | + : undefined, | |
| 2685 | + makeRuntime: makeMockRuntime({ | |
| 2686 | + childProcessSpawner, | |
| 2687 | + mockAgentPath, | |
| 2688 | + environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, | |
| 2689 | + protocolEvents, | |
| 2690 | + }), | |
| 2691 | + }, | |
| 2692 | + fileSystem, | |
| 2693 | + idAllocator, | |
| 2694 | + serverConfig, | |
| 2695 | + }); | |
| 2696 | + const threadId = ThreadId.make("thread-acp-interrupt-background-hold"); | |
| 2697 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 2698 | + runtimeMode: "full-access", | |
| 2699 | + interactionMode: "default", | |
| 2700 | + cwd: process.cwd(), | |
| 2701 | + }); | |
| 2702 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 2703 | + const runtime = yield* adapter.openSession({ | |
| 2704 | + threadId, | |
| 2705 | + providerSessionId: ProviderSessionId.make("provider-session-acp-interrupt-background"), | |
| 2706 | + modelSelection, | |
| 2707 | + runtimePolicy, | |
| 2708 | + }); | |
| 2709 | + const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 2710 | + yield* runtime.events.pipe( | |
| 2711 | + Stream.runForEach((event) => Queue.offer(events, event)), | |
| 2712 | + Effect.forkScoped, | |
| 2713 | + ); | |
| 2714 | + const providerThread = yield* runtime.ensureThread({ | |
| 2715 | + threadId, | |
| 2716 | + modelSelection, | |
| 2717 | + runtimePolicy, | |
| 2718 | + }); | |
| 2719 | + const now = yield* DateTime.now; | |
| 2720 | + yield* runtime.startTurn( | |
| 2721 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | |
| 2722 | + ); | |
| 2723 | + // The still-running subagent defers finalize after session/prompt returns. | |
| 2724 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 2725 | + Stream.filter( | |
| 2726 | + (event) => | |
| 2727 | + event.direction === "incoming" && | |
| 2728 | + event.stage === "raw" && | |
| 2729 | + typeof event.payload === "string" && | |
| 2730 | + event.payload.includes('"stopReason"'), | |
| 2731 | + ), | |
| 2732 | + Stream.runHead, | |
| 2733 | + ); | |
| 2734 | + yield* Effect.yieldNow; | |
| 2735 | + yield* Effect.yieldNow; | |
| 2736 | + | |
| 2737 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 2738 | + driver: ACP_TEST_DRIVER, | |
| 2739 | + nativeTurnId: "mock-session-1:turn:1", | |
| 2740 | + }); | |
| 2741 | + const interruptFiber = yield* runtime | |
| 2742 | + .interruptTurn({ providerThread, providerTurnId }) | |
| 2743 | + .pipe(Effect.forkScoped); | |
| 2744 | + yield* TestClock.adjust("10 seconds"); | |
| 2745 | + yield* Fiber.join(interruptFiber); | |
| 2746 | + | |
| 2747 | + let terminalStatus: string | null = null; | |
| 2748 | + while (terminalStatus === null) { | |
| 2749 | + const event = yield* Queue.take(events); | |
| 2750 | + if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { | |
| 2751 | + terminalStatus = event.status; | |
| 2752 | + } | |
| 2753 | + } | |
| 2754 | + assert.equal(terminalStatus, "interrupted"); | |
| 2755 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 2756 | + ); | |
| 2757 | + | |
| 2758 | + it.effect( | |
| 2759 | + "carries a live subagent lineage across an interrupt so the next turn can complete it", | |
| 2760 | + () => | |
| 2761 | + Effect.gen(function* () { | |
| 2762 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 2763 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 2764 | + const idAllocator = yield* IdAllocatorV2; | |
| 2765 | + const path = yield* Path.Path; | |
| 2766 | + const serverConfig = yield* ServerConfig; | |
| 2767 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 2768 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 2769 | + ); | |
| 2770 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 2771 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 2772 | + let subagentPhase: "spawn" | "complete" = "spawn"; | |
| 2773 | + const adapter = makeAcpAdapterV2({ | |
| 2774 | + instanceId, | |
| 2775 | + flavor: { | |
| 2776 | + driver: ACP_TEST_DRIVER, | |
| 2777 | + capabilities: AcpProviderCapabilitiesV2, | |
| 2778 | + deferFinalizeForBackgroundWork: true, | |
| 2779 | + extractSubagentUpdate: (toolCall) => | |
| 2780 | + toolCall.toolCallId !== "tool-call-generic-1" | |
| 2781 | + ? undefined | |
| 2782 | + : subagentPhase === "spawn" | |
| 2783 | + ? { | |
| 2784 | + nativeTaskId: "task-generic-1", | |
| 2785 | + prompt: "background subagent", | |
| 2786 | + title: "background subagent", | |
| 2787 | + model: null, | |
| 2788 | + status: "running", | |
| 2789 | + childSessionId: null, | |
| 2790 | + result: null, | |
| 2791 | + } | |
| 2792 | + : // Hydration-only shape (empty prompt, null title): without a | |
| 2793 | + // carried-over lineage this update is dropped and the item | |
| 2794 | + // stays running forever. | |
| 2795 | + { | |
| 2796 | + nativeTaskId: "task-generic-1", | |
| 2797 | + prompt: "", | |
| 2798 | + title: null, | |
| 2799 | + model: null, | |
| 2800 | + status: "completed", | |
| 2801 | + childSessionId: null, | |
| 2802 | + result: "SUB_DONE", | |
| 2803 | + }, | |
| 2804 | + makeRuntime: makeMockRuntime({ | |
| 2805 | + childProcessSpawner, | |
| 2806 | + mockAgentPath, | |
| 2807 | + environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, | |
| 2808 | + protocolEvents, | |
| 2809 | + }), | |
| 2810 | + }, | |
| 2811 | + fileSystem, | |
| 2812 | + idAllocator, | |
| 2813 | + serverConfig, | |
| 2814 | + }); | |
| 2815 | + const threadId = ThreadId.make("thread-acp-subagent-carryover"); | |
| 2816 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 2817 | + runtimeMode: "full-access", | |
| 2818 | + interactionMode: "default", | |
| 2819 | + cwd: process.cwd(), | |
| 2820 | + }); | |
| 2821 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 2822 | + const runtime = yield* adapter.openSession({ | |
| 2823 | + threadId, | |
| 2824 | + providerSessionId: ProviderSessionId.make("provider-session-acp-subagent-carryover"), | |
| 2825 | + modelSelection, | |
| 2826 | + runtimePolicy, | |
| 2827 | + }); | |
| 2828 | + const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 2829 | + yield* runtime.events.pipe( | |
| 2830 | + Stream.runForEach((event) => Queue.offer(events, event)), | |
| 2831 | + Effect.forkScoped, | |
| 2832 | + ); | |
| 2833 | + const providerThread = yield* runtime.ensureThread({ | |
| 2834 | + threadId, | |
| 2835 | + modelSelection, | |
| 2836 | + runtimePolicy, | |
| 2837 | + }); | |
| 2838 | + const now = yield* DateTime.now; | |
| 2839 | + yield* runtime.startTurn( | |
| 2840 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | |
| 2841 | + ); | |
| 2842 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 2843 | + Stream.filter( | |
| 2844 | + (event) => | |
| 2845 | + event.direction === "incoming" && | |
| 2846 | + event.stage === "raw" && | |
| 2847 | + typeof event.payload === "string" && | |
| 2848 | + event.payload.includes('"stopReason"'), | |
| 2849 | + ), | |
| 2850 | + Stream.runHead, | |
| 2851 | + ); | |
| 2852 | + yield* Effect.yieldNow; | |
| 2853 | + yield* Effect.yieldNow; | |
| 2854 | + | |
| 2855 | + const firstProviderTurnId = idAllocator.derive.providerTurn({ | |
| 2856 | + driver: ACP_TEST_DRIVER, | |
| 2857 | + nativeTurnId: "mock-session-1:turn:1", | |
| 2858 | + }); | |
| 2859 | + const interruptFiber = yield* runtime | |
| 2860 | + .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) | |
| 2861 | + .pipe(Effect.forkScoped); | |
| 2862 | + yield* TestClock.adjust("10 seconds"); | |
| 2863 | + yield* Fiber.join(interruptFiber); | |
| 2864 | + | |
| 2865 | + let subagentTurnItemId: string | null = null; | |
| 2866 | + let firstTerminalStatus: string | null = null; | |
| 2867 | + while (firstTerminalStatus === null) { | |
| 2868 | + const event = yield* Queue.take(events); | |
| 2869 | + if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { | |
| 2870 | + subagentTurnItemId = event.turnItem.id; | |
| 2871 | + } | |
| 2872 | + if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { | |
| 2873 | + firstTerminalStatus = event.status; | |
| 2874 | + } | |
| 2875 | + } | |
| 2876 | + assert.equal(firstTerminalStatus, "interrupted"); | |
| 2877 | + assert.notEqual(subagentTurnItemId, null); | |
| 2878 | + | |
| 2879 | + subagentPhase = "complete"; | |
| 2880 | + const secondNow = yield* DateTime.now; | |
| 2881 | + yield* runtime.startTurn( | |
| 2882 | + makeTurnInput({ | |
| 2883 | + threadId, | |
| 2884 | + providerThread, | |
| 2885 | + instanceId, | |
| 2886 | + runtimePolicy, | |
| 2887 | + now: secondNow, | |
| 2888 | + ordinal: 2, | |
| 2889 | + }), | |
| 2890 | + ); | |
| 2891 | + const secondProviderTurnId = idAllocator.derive.providerTurn({ | |
| 2892 | + driver: ACP_TEST_DRIVER, | |
| 2893 | + nativeTurnId: "mock-session-1:turn:2", | |
| 2894 | + }); | |
| 2895 | + let carriedItemStatus: string | null = null; | |
| 2896 | + let secondTerminalStatus: string | null = null; | |
| 2897 | + while (secondTerminalStatus === null) { | |
| 2898 | + const event = yield* Queue.take(events); | |
| 2899 | + if ( | |
| 2900 | + event.type === "turn_item.updated" && | |
| 2901 | + event.turnItem.type === "subagent" && | |
| 2902 | + event.turnItem.id === subagentTurnItemId | |
| 2903 | + ) { | |
| 2904 | + carriedItemStatus = event.turnItem.status; | |
| 2905 | + } | |
| 2906 | + if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { | |
| 2907 | + secondTerminalStatus = event.status; | |
| 2908 | + } | |
| 2909 | + } | |
| 2910 | + assert.equal(carriedItemStatus, "completed"); | |
| 2911 | + assert.equal(secondTerminalStatus, "completed"); | |
| 2912 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 2913 | + ); | |
| 2914 | + | |
| 2915 | + it.effect( | |
| 2916 | + "preserveRuntimeOnSettledInterrupt keeps the process alive and carries subagents through a settled steering interrupt", | |
| 2917 | + () => | |
| 2918 | + Effect.gen(function* () { | |
| 2919 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 2920 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 2921 | + const idAllocator = yield* IdAllocatorV2; | |
| 2922 | + const path = yield* Path.Path; | |
| 2923 | + const serverConfig = yield* ServerConfig; | |
| 2924 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 2925 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 2926 | + ); | |
| 2927 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 2928 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 2929 | + let subagentPhase: "spawn" | "complete" = "spawn"; | |
| 2930 | + let cancelCalled = false; | |
| 2931 | + let runtimeOrdinalSeen = 0; | |
| 2932 | + const adapter = makeAcpAdapterV2({ | |
| 2933 | + instanceId, | |
| 2934 | + flavor: { | |
| 2935 | + driver: ACP_TEST_DRIVER, | |
| 2936 | + capabilities: AcpProviderCapabilitiesV2, | |
| 2937 | + deferFinalizeForBackgroundWork: true, | |
| 2938 | + // Hard interrupt flags (stricter than production Grok, which no | |
| 2939 | + // longer sets restartRuntimeOnEveryInterrupt): every interrupt | |
| 2940 | + // would hard-kill the process group without the settled-soft gate | |
| 2941 | + // under test. | |
| 2942 | + restartRuntimeAfterInterrupt: true, | |
| 2943 | + restartRuntimeOnEveryInterrupt: true, | |
| 2944 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 2945 | + preserveRuntimeOnSettledInterrupt: true, | |
| 2946 | + extractSubagentUpdate: (toolCall) => | |
| 2947 | + toolCall.toolCallId !== "tool-call-generic-1" | |
| 2948 | + ? undefined | |
| 2949 | + : subagentPhase === "spawn" | |
| 2950 | + ? { | |
| 2951 | + nativeTaskId: "task-generic-1", | |
| 2952 | + prompt: "background subagent", | |
| 2953 | + title: "background subagent", | |
| 2954 | + model: null, | |
| 2955 | + status: "running", | |
| 2956 | + childSessionId: null, | |
| 2957 | + result: null, | |
| 2958 | + } | |
| 2959 | + : { | |
| 2960 | + nativeTaskId: "task-generic-1", | |
| 2961 | + prompt: "", | |
| 2962 | + title: null, | |
| 2963 | + model: null, | |
| 2964 | + status: "completed", | |
| 2965 | + childSessionId: null, | |
| 2966 | + result: "SUB_DONE", | |
| 2967 | + }, | |
| 2968 | + // No ownDetachedProcessGroup: if the interrupt wrongly takes the | |
| 2969 | + // hard path, terminateProcessGroup is missing and the interrupt | |
| 2970 | + // fails loudly with a poisoned session. | |
| 2971 | + makeRuntime: makeMockRuntime({ | |
| 2972 | + childProcessSpawner, | |
| 2973 | + mockAgentPath, | |
| 2974 | + environment: (runtimeOrdinal) => { | |
| 2975 | + runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); | |
| 2976 | + return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }; | |
| 2977 | + }, | |
| 2978 | + protocolEvents, | |
| 2979 | + wrapCancel: (cancel) => | |
| 2980 | + Effect.sync(() => { | |
| 2981 | + cancelCalled = true; | |
| 2982 | + }).pipe(Effect.andThen(cancel)), | |
| 2983 | + }), | |
| 2984 | + }, | |
| 2985 | + fileSystem, | |
| 2986 | + idAllocator, | |
| 2987 | + serverConfig, | |
| 2988 | + }); | |
| 2989 | + const threadId = ThreadId.make("thread-acp-settled-soft-steer"); | |
| 2990 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 2991 | + runtimeMode: "full-access", | |
| 2992 | + interactionMode: "default", | |
| 2993 | + cwd: process.cwd(), | |
| 2994 | + }); | |
| 2995 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 2996 | + const runtime = yield* adapter.openSession({ | |
| 2997 | + threadId, | |
| 2998 | + providerSessionId: ProviderSessionId.make("provider-session-acp-settled-soft-steer"), | |
| 2999 | + modelSelection, | |
| 3000 | + runtimePolicy, | |
| 3001 | + }); | |
| 3002 | + const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 3003 | + yield* runtime.events.pipe( | |
| 3004 | + Stream.runForEach((event) => Queue.offer(events, event)), | |
| 3005 | + Effect.forkScoped, | |
| 3006 | + ); | |
| 3007 | + const providerThread = yield* runtime.ensureThread({ | |
| 3008 | + threadId, | |
| 3009 | + modelSelection, | |
| 3010 | + runtimePolicy, | |
| 3011 | + }); | |
| 3012 | + const now = yield* DateTime.now; | |
| 3013 | + yield* runtime.startTurn( | |
| 3014 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | |
| 3015 | + ); | |
| 3016 | + // The still-running subagent defers finalize after session/prompt returns, | |
| 3017 | + // so the interrupt below hits a settled turn held open for background work. | |
| 3018 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 3019 | + Stream.filter( | |
| 3020 | + (event) => | |
| 3021 | + event.direction === "incoming" && | |
| 3022 | + event.stage === "raw" && | |
| 3023 | + typeof event.payload === "string" && | |
| 3024 | + event.payload.includes('"stopReason"'), | |
| 3025 | + ), | |
| 3026 | + Stream.runHead, | |
| 3027 | + ); | |
| 3028 | + yield* Effect.yieldNow; | |
| 3029 | + yield* Effect.yieldNow; | |
| 3030 | + | |
| 3031 | + const firstProviderTurnId = idAllocator.derive.providerTurn({ | |
| 3032 | + driver: ACP_TEST_DRIVER, | |
| 3033 | + nativeTurnId: "mock-session-1:turn:1", | |
| 3034 | + }); | |
| 3035 | + const interruptFiber = yield* runtime | |
| 3036 | + .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) | |
| 3037 | + .pipe(Effect.forkScoped); | |
| 3038 | + yield* TestClock.adjust("10 seconds"); | |
| 3039 | + yield* Fiber.join(interruptFiber); | |
| 3040 | + assert.isFalse( | |
| 3041 | + cancelCalled, | |
| 3042 | + "settled soft steer must not send session/cancel (the real Grok CLI kills background subagents on cancel)", | |
| 3043 | + ); | |
| 3044 | + | |
| 3045 | + let subagentTurnItemId: string | null = null; | |
| 3046 | + let firstTerminalStatus: string | null = null; | |
| 3047 | + while (firstTerminalStatus === null) { | |
| 3048 | + const event = yield* Queue.take(events); | |
| 3049 | + if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { | |
| 3050 | + subagentTurnItemId = event.turnItem.id; | |
| 3051 | + } | |
| 3052 | + if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { | |
| 3053 | + firstTerminalStatus = event.status; | |
| 3054 | + } | |
| 3055 | + } | |
| 3056 | + assert.equal(firstTerminalStatus, "interrupted"); | |
| 3057 | + assert.notEqual(subagentTurnItemId, null); | |
| 3058 | + | |
| 3059 | + subagentPhase = "complete"; | |
| 3060 | + const secondNow = yield* DateTime.now; | |
| 3061 | + yield* runtime.startTurn( | |
| 3062 | + makeTurnInput({ | |
| 3063 | + threadId, | |
| 3064 | + providerThread, | |
| 3065 | + instanceId, | |
| 3066 | + runtimePolicy, | |
| 3067 | + now: secondNow, | |
| 3068 | + ordinal: 2, | |
| 3069 | + }), | |
| 3070 | + ); | |
| 3071 | + // Same runtime process (mock-session-1): a respawn would start | |
| 3072 | + // mock-session-2 and drop the carryover on the session mismatch. | |
| 3073 | + const secondProviderTurnId = idAllocator.derive.providerTurn({ | |
| 3074 | + driver: ACP_TEST_DRIVER, | |
| 3075 | + nativeTurnId: "mock-session-1:turn:2", | |
| 3076 | + }); | |
| 3077 | + let carriedItemStatus: string | null = null; | |
| 3078 | + let secondTerminalStatus: string | null = null; | |
| 3079 | + while (secondTerminalStatus === null) { | |
| 3080 | + const event = yield* Queue.take(events); | |
| 3081 | + if ( | |
| 3082 | + event.type === "turn_item.updated" && | |
| 3083 | + event.turnItem.type === "subagent" && | |
| 3084 | + event.turnItem.id === subagentTurnItemId | |
| 3085 | + ) { | |
| 3086 | + carriedItemStatus = event.turnItem.status; | |
| 3087 | + } | |
| 3088 | + if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { | |
| 3089 | + secondTerminalStatus = event.status; | |
| 3090 | + } | |
| 3091 | + } | |
| 3092 | + assert.equal(carriedItemStatus, "completed"); | |
| 3093 | + assert.equal(secondTerminalStatus, "completed"); | |
| 3094 | + assert.equal( | |
| 3095 | + runtimeOrdinalSeen, | |
| 3096 | + 1, | |
| 3097 | + "settled soft steer must not respawn the ACP runtime process", | |
| 3098 | + ); | |
| 3099 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 3100 | + ); | |
| 3101 | + | |
| 3102 | + it.live("preserveRuntimeOnSettledInterrupt does not soften a mid-prompt steering interrupt", () => | |
| 3103 | + Effect.gen(function* () { | |
| 3104 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 3105 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 3106 | + const idAllocator = yield* IdAllocatorV2; | |
| 3107 | + const path = yield* Path.Path; | |
| 3108 | + const serverConfig = yield* ServerConfig; | |
| 3109 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 3110 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 3111 | + ); | |
| 3112 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 3113 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 3114 | + const adapter = makeAcpAdapterV2({ | |
| 3115 | + instanceId, | |
| 3116 | + flavor: { | |
| 3117 | + driver: ACP_TEST_DRIVER, | |
| 3118 | + capabilities: AcpProviderCapabilitiesV2, | |
| 3119 | + restartRuntimeAfterInterrupt: true, | |
| 3120 | + // Local hard-flavor gate: production Grok no longer sets | |
| 3121 | + // restartRuntimeOnEveryInterrupt, but when a flavor does, the | |
| 3122 | + // settled-soft gate must not leak onto an unsettled prompt. | |
| 3123 | + restartRuntimeOnEveryInterrupt: true, | |
| 3124 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 3125 | + preserveRuntimeOnSettledInterrupt: true, | |
| 3126 | + // No ownDetachedProcessGroup: the expected hard path fails loudly | |
| 3127 | + // on the missing terminateProcessGroup, proving the settled-soft | |
| 3128 | + // gate did not apply to an unsettled prompt. | |
| 3129 | + makeRuntime: makeMockRuntime({ | |
| 3130 | + childProcessSpawner, | |
| 3131 | + mockAgentPath, | |
| 3132 | + environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, | |
| 3133 | + protocolEvents, | |
| 3134 | + }), | |
| 3135 | + }, | |
| 3136 | + fileSystem, | |
| 3137 | + idAllocator, | |
| 3138 | + serverConfig, | |
| 3139 | + }); | |
| 3140 | + const threadId = ThreadId.make("thread-acp-unsettled-steer-stays-hard"); | |
| 3141 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 3142 | + runtimeMode: "full-access", | |
| 3143 | + interactionMode: "default", | |
| 3144 | + cwd: process.cwd(), | |
| 3145 | + }); | |
| 3146 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 3147 | + const runtime = yield* adapter.openSession({ | |
| 3148 | + threadId, | |
| 3149 | + providerSessionId: ProviderSessionId.make( | |
| 3150 | + "provider-session-acp-unsettled-steer-stays-hard", | |
| 3151 | + ), | |
| 3152 | + modelSelection, | |
| 3153 | + runtimePolicy, | |
| 3154 | + }); | |
| 3155 | + const providerThread = yield* runtime.ensureThread({ | |
| 3156 | + threadId, | |
| 3157 | + modelSelection, | |
| 3158 | + runtimePolicy, | |
| 3159 | + }); | |
| 3160 | + const now = yield* DateTime.now; | |
| 3161 | + yield* runtime | |
| 3162 | + .startTurn( | |
| 3163 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | |
| 3164 | + ) | |
| 3165 | + .pipe(Effect.forkScoped); | |
| 3166 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 3167 | + Stream.filter( | |
| 3168 | + (event) => | |
| 3169 | + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | |
| 3170 | + ), | |
| 3171 | + Stream.runHead, | |
| 3172 | + ); | |
| 3173 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 3174 | + driver: ACP_TEST_DRIVER, | |
| 3175 | + nativeTurnId: "mock-session-1:turn:1", | |
| 3176 | + }); | |
| 3177 | + const interruptExit = yield* runtime | |
| 3178 | + .interruptTurn({ providerThread, providerTurnId }) | |
| 3179 | + .pipe(Effect.exit); | |
| 3180 | + if (Exit.isSuccess(interruptExit)) { | |
| 3181 | + assert.fail("mid-prompt steering interrupt must still take the hard teardown path"); | |
| 3182 | + } | |
| 3183 | + assert.include(Cause.pretty(interruptExit.cause), "session is poisoned"); | |
| 3184 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 3185 | + ); | |
| 3186 | + | |
| 3187 | + it.live( | |
| 3188 | + "soft mid-prompt interrupt cancels in place, reuses the runtime, and tracks cancel-backgrounded work", | |
| 3189 | + () => | |
| 3190 | + Effect.gen(function* () { | |
| 3191 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 3192 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 3193 | + const idAllocator = yield* IdAllocatorV2; | |
| 3194 | + const path = yield* Path.Path; | |
| 3195 | + const serverConfig = yield* ServerConfig; | |
| 3196 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 3197 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 3198 | + ); | |
| 3199 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 3200 | + const continuationRequests: Array<ProviderContinuationRequest> = []; | |
| 3201 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 3202 | + let cancelCalled = false; | |
| 3203 | + let runtimeOrdinalSeen = 0; | |
| 3204 | + const adapter = makeAcpAdapterV2({ | |
| 3205 | + instanceId, | |
| 3206 | + flavor: { | |
| 3207 | + driver: ACP_TEST_DRIVER, | |
| 3208 | + capabilities: AcpProviderCapabilitiesV2, | |
| 3209 | + enablePostSettleContinuation: true, | |
| 3210 | + // Production Grok interrupt flags: hard teardown only with | |
| 3211 | + // requestRuntimeRestart (user Stop). Without | |
| 3212 | + // restartRuntimeOnEveryInterrupt a mid-prompt steering interrupt | |
| 3213 | + // stays soft: session/cancel, same process, session reuse. | |
| 3214 | + restartRuntimeAfterInterrupt: true, | |
| 3215 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 3216 | + preserveRuntimeOnSettledInterrupt: true, | |
| 3217 | + registerExtensions: ({ runtime: extensionRuntime, applyBackgroundTaskMutation }) => | |
| 3218 | + registerXAiBackgroundTaskTracking(extensionRuntime, applyBackgroundTaskMutation), | |
| 3219 | + // No ownDetachedProcessGroup: if the interrupt wrongly takes the | |
| 3220 | + // hard path, terminateProcessGroup is missing and the interrupt | |
| 3221 | + // fails loudly with a poisoned session. | |
| 3222 | + makeRuntime: makeMockRuntime({ | |
| 3223 | + childProcessSpawner, | |
| 3224 | + mockAgentPath, | |
| 3225 | + environment: (runtimeOrdinal) => { | |
| 3226 | + runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); | |
| 3227 | + return { | |
| 3228 | + T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG_FIRST_PROMPT: "1", | |
| 3229 | + T3_ACP_EMIT_TASK_BACKGROUNDED_AFTER_CANCEL: "1", | |
| 3230 | + }; | |
| 3231 | + }, | |
| 3232 | + protocolEvents, | |
| 3233 | + wrapCancel: (cancel) => | |
| 3234 | + Effect.sync(() => { | |
| 3235 | + cancelCalled = true; | |
| 3236 | + }).pipe(Effect.andThen(cancel)), | |
| 3237 | + }), | |
| 3238 | + }, | |
| 3239 | + fileSystem, | |
| 3240 | + idAllocator, | |
| 3241 | + serverConfig, | |
| 3242 | + continuationRequests: { | |
| 3243 | + offer: (request) => | |
| 3244 | + Effect.sync(() => { | |
| 3245 | + continuationRequests.push(request); | |
| 3246 | + }), | |
| 3247 | + }, | |
| 3248 | + }); | |
| 3249 | + const threadId = ThreadId.make("thread-acp-soft-mid-prompt-steer"); | |
| 3250 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 3251 | + runtimeMode: "full-access", | |
| 3252 | + interactionMode: "default", | |
| 3253 | + cwd: process.cwd(), | |
| 3254 | + }); | |
| 3255 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 3256 | + const runtime = yield* adapter.openSession({ | |
| 3257 | + threadId, | |
| 3258 | + providerSessionId: ProviderSessionId.make("provider-session-acp-soft-mid-prompt-steer"), | |
| 3259 | + modelSelection, | |
| 3260 | + runtimePolicy, | |
| 3261 | + }); | |
| 3262 | + if (runtime.hasPendingBackgroundWork === undefined) { | |
| 3263 | + return yield* Effect.die( | |
| 3264 | + "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", | |
| 3265 | + ); | |
| 3266 | + } | |
| 3267 | + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; | |
| 3268 | + const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 3269 | + yield* runtime.events.pipe( | |
| 3270 | + Stream.runForEach((event) => Queue.offer(events, event)), | |
| 3271 | + Effect.forkScoped, | |
| 3272 | + ); | |
| 3273 | + const providerThread = yield* runtime.ensureThread({ | |
| 3274 | + threadId, | |
| 3275 | + modelSelection, | |
| 3276 | + runtimePolicy, | |
| 3277 | + }); | |
| 3278 | + const now = yield* DateTime.now; | |
| 3279 | + yield* runtime | |
| 3280 | + .startTurn( | |
| 3281 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | |
| 3282 | + ) | |
| 3283 | + .pipe(Effect.forkScoped); | |
| 3284 | + // Wait for the running command tool so the interrupt lands mid-prompt. | |
| 3285 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 3286 | + Stream.filter( | |
| 3287 | + (event) => | |
| 3288 | + event.direction === "incoming" && | |
| 3289 | + event.stage === "raw" && | |
| 3290 | + typeof event.payload === "string" && | |
| 3291 | + event.payload.includes("tool-call-running-1"), | |
| 3292 | + ), | |
| 3293 | + Stream.runHead, | |
| 3294 | + ); | |
| 3295 | + | |
| 3296 | + const firstProviderTurnId = idAllocator.derive.providerTurn({ | |
| 3297 | + driver: ACP_TEST_DRIVER, | |
| 3298 | + nativeTurnId: "mock-session-1:turn:1", | |
| 3299 | + }); | |
| 3300 | + // No requestRuntimeRestart: a steering interrupt, not a user Stop. | |
| 3301 | + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); | |
| 3302 | + assert.isTrue( | |
| 3303 | + cancelCalled, | |
| 3304 | + "soft mid-prompt interrupt must send session/cancel to detach the running work", | |
| 3305 | + ); | |
| 3306 | + | |
| 3307 | + let firstTerminalStatus: string | null = null; | |
| 3308 | + while (firstTerminalStatus === null) { | |
| 3309 | + const event = yield* Queue.take(events); | |
| 3310 | + if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { | |
| 3311 | + firstTerminalStatus = event.status; | |
| 3312 | + } | |
| 3313 | + } | |
| 3314 | + assert.equal(firstTerminalStatus, "interrupted"); | |
| 3315 | + // The cancel handler emitted _x.ai/task_backgrounded for the detached | |
| 3316 | + // command; the tracked task must report as pending background work. | |
| 3317 | + let backgroundTracked = false; | |
| 3318 | + for (let attempt = 0; attempt < 80 && !backgroundTracked; attempt += 1) { | |
| 3319 | + backgroundTracked = yield* hasPendingBackgroundWork; | |
| 3320 | + if (!backgroundTracked) { | |
| 3321 | + yield* Effect.sleep("25 millis"); | |
| 3322 | + } | |
| 3323 | + } | |
| 3324 | + assert.isTrue( | |
| 3325 | + backgroundTracked, | |
| 3326 | + "cancel-backgrounded task must be tracked as running background work", | |
| 3327 | + ); | |
| 3328 | + | |
| 3329 | + // The second prompt reuses the same process and session. | |
| 3330 | + const secondNow = yield* DateTime.now; | |
| 3331 | + yield* runtime.startTurn( | |
| 3332 | + makeTurnInput({ | |
| 3333 | + threadId, | |
| 3334 | + providerThread, | |
| 3335 | + instanceId, | |
| 3336 | + runtimePolicy, | |
| 3337 | + now: secondNow, | |
| 3338 | + ordinal: 2, | |
| 3339 | + }), | |
| 3340 | + ); | |
| 3341 | + const secondProviderTurnId = idAllocator.derive.providerTurn({ | |
| 3342 | + driver: ACP_TEST_DRIVER, | |
| 3343 | + nativeTurnId: "mock-session-1:turn:2", | |
| 3344 | + }); | |
| 3345 | + let secondTerminalStatus: string | null = null; | |
| 3346 | + while (secondTerminalStatus === null) { | |
| 3347 | + const event = yield* Queue.take(events); | |
| 3348 | + if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { | |
| 3349 | + secondTerminalStatus = event.status; | |
| 3350 | + } | |
| 3351 | + } | |
| 3352 | + assert.equal(secondTerminalStatus, "completed"); | |
| 3353 | + assert.equal( | |
| 3354 | + runtimeOrdinalSeen, | |
| 3355 | + 1, | |
| 3356 | + "soft mid-prompt interrupt must not respawn the ACP runtime process", | |
| 3357 | + ); | |
| 3358 | + | |
| 3359 | + // _x.ai/task_completed lands ~1.2s after the cancel and clears the | |
| 3360 | + // tracked task without opening a synthetic continuation run. | |
| 3361 | + let backgroundPending = true; | |
| 3362 | + for (let attempt = 0; attempt < 50 && backgroundPending; attempt += 1) { | |
| 3363 | + backgroundPending = yield* hasPendingBackgroundWork; | |
| 3364 | + if (backgroundPending) { | |
| 3365 | + yield* Effect.sleep("100 millis"); | |
| 3366 | + } | |
| 3367 | + } | |
| 3368 | + assert.isFalse( | |
| 3369 | + backgroundPending, | |
| 3370 | + "tracked background task must clear after _x.ai/task_completed", | |
| 3371 | + ); | |
| 3372 | + assert.lengthOf( | |
| 3373 | + continuationRequests, | |
| 3374 | + 0, | |
| 3375 | + "a cancel-backgrounded task completion must not wake a synthetic continuation run", | |
| 3376 | + ); | |
| 3377 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 3378 | + ); | |
| 3379 | + | |
| 3380 | + it.live("direct Stop quarantine drops late background task mutations from the stopped run", () => | |
| 3381 | + Effect.gen(function* () { | |
| 3382 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 3383 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 3384 | + const idAllocator = yield* IdAllocatorV2; | |
| 3385 | + const path = yield* Path.Path; | |
| 3386 | + const serverConfig = yield* ServerConfig; | |
| 3387 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 3388 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 3389 | + ); | |
| 3390 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 3391 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 3392 | + const capturedMutation: { | |
| 3393 | + current: | |
| 3394 | + | ((mutation: { | |
| 3395 | + readonly sessionId: string; | |
| 3396 | + readonly taskId: string; | |
| 3397 | + readonly status: "running" | "completed" | "failed"; | |
| 3398 | + }) => Effect.Effect<void>) | |
| 3399 | + | null; | |
| 3400 | + } = { current: null }; | |
| 3401 | + const adapter = makeAcpAdapterV2({ | |
| 3402 | + instanceId, | |
| 3403 | + flavor: { | |
| 3404 | + driver: ACP_TEST_DRIVER, | |
| 3405 | + capabilities: AcpProviderCapabilitiesV2, | |
| 3406 | + enablePostSettleContinuation: true, | |
| 3407 | + restartRuntimeAfterInterrupt: true, | |
| 3408 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 3409 | + preserveRuntimeOnSettledInterrupt: true, | |
| 3410 | + registerExtensions: (context) => | |
| 3411 | + Effect.sync(() => { | |
| 3412 | + capturedMutation.current = context.applyBackgroundTaskMutation; | |
| 3413 | + }), | |
| 3414 | + makeRuntime: makeMockRuntime({ | |
| 3415 | + childProcessSpawner, | |
| 3416 | + mockAgentPath, | |
| 3417 | + environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, | |
| 3418 | + ownDetachedProcessGroup: true, | |
| 3419 | + protocolEvents, | |
| 3420 | + }), | |
| 3421 | + }, | |
| 3422 | + fileSystem, | |
| 3423 | + idAllocator, | |
| 3424 | + serverConfig, | |
| 3425 | + continuationRequests: { offer: () => Effect.void }, | |
| 3426 | + }); | |
| 3427 | + const threadId = ThreadId.make("thread-acp-stop-quarantine-late-task"); | |
| 3428 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 3429 | + runtimeMode: "full-access", | |
| 3430 | + interactionMode: "default", | |
| 3431 | + cwd: process.cwd(), | |
| 3432 | + }); | |
| 3433 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 3434 | + const runtime = yield* adapter.openSession({ | |
| 3435 | + threadId, | |
| 3436 | + providerSessionId: ProviderSessionId.make("provider-session-acp-stop-quarantine-late-task"), | |
| 3437 | + modelSelection, | |
| 3438 | + runtimePolicy, | |
| 3439 | + }); | |
| 3440 | + if (runtime.hasPendingBackgroundWork === undefined) { | |
| 3441 | + return yield* Effect.die( | |
| 3442 | + "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", | |
| 3443 | + ); | |
| 3444 | + } | |
| 3445 | + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; | |
| 3446 | + const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 3447 | + yield* runtime.events.pipe( | |
| 3448 | + Stream.runForEach((event) => Queue.offer(events, event)), | |
| 3449 | + Effect.forkScoped, | |
| 3450 | + ); | |
| 3451 | + const providerThread = yield* runtime.ensureThread({ | |
| 3452 | + threadId, | |
| 3453 | + modelSelection, | |
| 3454 | + runtimePolicy, | |
| 3455 | + }); | |
| 3456 | + const now = yield* DateTime.now; | |
| 3457 | + yield* runtime | |
| 3458 | + .startTurn( | |
| 3459 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | |
| 3460 | + ) | |
| 3461 | + .pipe(Effect.forkScoped); | |
| 3462 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 3463 | + Stream.filter( | |
| 3464 | + (event) => | |
| 3465 | + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | |
| 3466 | + ), | |
| 3467 | + Stream.runHead, | |
| 3468 | + ); | |
| 3469 | + const applyMutation = capturedMutation.current; | |
| 3470 | + if (applyMutation === null) { | |
| 3471 | + return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); | |
| 3472 | + } | |
| 3473 | + // Plumbing sanity: pre-Stop mutations on the root session track and | |
| 3474 | + // clear pending background work through the extension callback. | |
| 3475 | + yield* applyMutation({ | |
| 3476 | + sessionId: "mock-session-1", | |
| 3477 | + taskId: "task-pre-stop", | |
| 3478 | + status: "running", | |
| 3479 | + }); | |
| 3480 | + assert.isTrue( | |
| 3481 | + yield* hasPendingBackgroundWork, | |
| 3482 | + "a running background task mutation on the root session must track before Stop", | |
| 3483 | + ); | |
| 3484 | + yield* applyMutation({ | |
| 3485 | + sessionId: "mock-session-1", | |
| 3486 | + taskId: "task-pre-stop", | |
| 3487 | + status: "completed", | |
| 3488 | + }); | |
| 3489 | + assert.isFalse( | |
| 3490 | + yield* hasPendingBackgroundWork, | |
| 3491 | + "a completed background task mutation must clear pending background work", | |
| 3492 | + ); | |
| 3493 | + | |
| 3494 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 3495 | + driver: ACP_TEST_DRIVER, | |
| 3496 | + nativeTurnId: "mock-session-1:turn:1", | |
| 3497 | + }); | |
| 3498 | + yield* runtime.interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }); | |
| 3499 | + let terminalStatus: string | null = null; | |
| 3500 | + while (terminalStatus === null) { | |
| 3501 | + const event = yield* Queue.take(events); | |
| 3502 | + if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { | |
| 3503 | + terminalStatus = event.status; | |
| 3504 | + } | |
| 3505 | + } | |
| 3506 | + assert.equal(terminalStatus, "interrupted"); | |
| 3507 | + | |
| 3508 | + // Residual lifecycle from the stopped run: activeSessionId still points | |
| 3509 | + // at the stopped session until the next turn respawns the runtime, so | |
| 3510 | + // only the direct Stop quarantine stands between this mutation and the | |
| 3511 | + // wake machinery. | |
| 3512 | + yield* applyMutation({ | |
| 3513 | + sessionId: "mock-session-1", | |
| 3514 | + taskId: "task-late-after-stop", | |
| 3515 | + status: "running", | |
| 3516 | + }); | |
| 3517 | + assert.isFalse( | |
| 3518 | + yield* hasPendingBackgroundWork, | |
| 3519 | + "direct Stop quarantine must drop residual background task mutations from the stopped run", | |
| 3520 | + ); | |
| 3521 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 3522 | + ); | |
| 3523 | + | |
| 3524 | + it.live("production Grok interrupt flags still hard-kill and respawn on user Stop", () => | |
| 3525 | + Effect.gen(function* () { | |
| 3526 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 3527 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 3528 | + const idAllocator = yield* IdAllocatorV2; | |
| 3529 | + const path = yield* Path.Path; | |
| 3530 | + const serverConfig = yield* ServerConfig; | |
| 3531 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 3532 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 3533 | + ); | |
| 3534 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 3535 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 3536 | + let runtimeOrdinalSeen = 0; | |
| 3537 | + const adapter = makeAcpAdapterV2({ | |
| 3538 | + instanceId, | |
| 3539 | + flavor: { | |
| 3540 | + driver: ACP_TEST_DRIVER, | |
| 3541 | + capabilities: AcpProviderCapabilitiesV2, | |
| 3542 | + enablePostSettleContinuation: true, | |
| 3543 | + // The full production Grok interrupt flag set after the non-Stop | |
| 3544 | + // softening: no restartRuntimeOnEveryInterrupt. User Stop | |
| 3545 | + // (requestRuntimeRestart) must still take the hard teardown and | |
| 3546 | + // respawn path, not the soft cancel path. | |
| 3547 | + restartRuntimeAfterInterrupt: true, | |
| 3548 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 3549 | + preserveRuntimeOnSettledInterrupt: true, | |
| 3550 | + registerExtensions: ({ runtime: extensionRuntime, applyBackgroundTaskMutation }) => | |
| 3551 | + registerXAiBackgroundTaskTracking(extensionRuntime, applyBackgroundTaskMutation), | |
| 3552 | + makeRuntime: makeMockRuntime({ | |
| 3553 | + childProcessSpawner, | |
| 3554 | + mockAgentPath, | |
| 3555 | + // Only hang the interrupted first turn. The replacement process must | |
| 3556 | + // complete normally so startTurn / session-load assertions can finish. | |
| 3557 | + environment: (runtimeOrdinal) => { | |
| 3558 | + runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); | |
| 3559 | + return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {}; | |
| 3560 | + }, | |
| 3561 | + ownDetachedProcessGroup: true, | |
| 3562 | + protocolEvents, | |
| 3563 | + }), | |
| 3564 | + }, | |
| 3565 | + fileSystem, | |
| 3566 | + idAllocator, | |
| 3567 | + serverConfig, | |
| 3568 | + continuationRequests: { offer: () => Effect.void }, | |
| 3569 | + }); | |
| 3570 | + const threadId = ThreadId.make("thread-acp-production-stop-hard-kill"); | |
| 3571 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 3572 | + runtimeMode: "full-access", | |
| 3573 | + interactionMode: "default", | |
| 3574 | + cwd: process.cwd(), | |
| 3575 | + }); | |
| 3576 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 3577 | + const runtime = yield* adapter.openSession({ | |
| 3578 | + threadId, | |
| 3579 | + providerSessionId: ProviderSessionId.make("provider-session-acp-production-stop-hard-kill"), | |
| 3580 | + modelSelection, | |
| 3581 | + runtimePolicy, | |
| 3582 | + }); | |
| 3583 | + const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 3584 | + yield* runtime.events.pipe( | |
| 3585 | + Stream.runForEach((event) => Queue.offer(events, event)), | |
| 3586 | + Effect.forkScoped, | |
| 3587 | + ); | |
| 3588 | + const providerThread = yield* runtime.ensureThread({ | |
| 3589 | + threadId, | |
| 3590 | + modelSelection, | |
| 3591 | + runtimePolicy, | |
| 3592 | + }); | |
| 3593 | + const now = yield* DateTime.now; | |
| 3594 | + yield* runtime | |
| 3595 | + .startTurn( | |
| 3596 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | |
| 3597 | + ) | |
| 3598 | + .pipe(Effect.forkScoped); | |
| 3599 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 3600 | + Stream.filter( | |
| 3601 | + (event) => | |
| 3602 | + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | |
| 3603 | + ), | |
| 3604 | + Stream.runHead, | |
| 3605 | + ); | |
| 3606 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 3607 | + driver: ACP_TEST_DRIVER, | |
| 3608 | + nativeTurnId: "mock-session-1:turn:1", | |
| 3609 | + }); | |
| 3610 | + yield* runtime.interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }); | |
| 3611 | + let terminalStatus: string | null = null; | |
| 3612 | + while (terminalStatus === null) { | |
| 3613 | + const event = yield* Queue.take(events); | |
| 3614 | + if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { | |
| 3615 | + terminalStatus = event.status; | |
| 3616 | + } | |
| 3617 | + } | |
| 3618 | + assert.equal(terminalStatus, "interrupted"); | |
| 3619 | + | |
| 3620 | + // Effect 4 Queue.takeAll waits for at least one element when empty. After | |
| 3621 | + // the session/prompt stream drain the protocol queue is often empty, so | |
| 3622 | + // takeAll would hang forever. Use clear (non-blocking drain) instead so | |
| 3623 | + // the session/load wait cannot match residual pre-restart traffic. | |
| 3624 | + yield* Queue.clear(protocolEvents); | |
| 3625 | + yield* runtime.startTurn( | |
| 3626 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), | |
| 3627 | + ); | |
| 3628 | + const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe( | |
| 3629 | + Stream.filter( | |
| 3630 | + (event) => event.direction === "outgoing" && rawProtocolMethod(event) === "session/load", | |
| 3631 | + ), | |
| 3632 | + Stream.runHead, | |
| 3633 | + ); | |
| 3634 | + assert.isTrue( | |
| 3635 | + Option.isSome(loadAfterRestart), | |
| 3636 | + "user Stop must respawn the runtime and reload the session on the next turn", | |
| 3637 | + ); | |
| 3638 | + assert.equal( | |
| 3639 | + runtimeOrdinalSeen, | |
| 3640 | + 2, | |
| 3641 | + "user Stop with production Grok flags must replace the ACP runtime process", | |
| 3642 | + ); | |
| 3643 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 3644 | + ); | |
| 3645 | + | |
| 3646 | + // it.live: ownDetachedProcessGroup teardown uses wall-clock sleeps; under | |
| 3647 | + // it.effect the interrupt timeout on context.completed still needs real time | |
| 3648 | + // after the soft steer clears the turn. | |
| 3649 | + it.live( | |
| 3650 | + "Stop after settled soft steer contains the orphan runtime and respawns without subagent carryover", | |
| 3651 | + () => | |
| 3652 | + Effect.gen(function* () { | |
| 3653 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 3654 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 3655 | + const idAllocator = yield* IdAllocatorV2; | |
| 3656 | + const path = yield* Path.Path; | |
| 3657 | + const serverConfig = yield* ServerConfig; | |
| 3658 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 3659 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 3660 | + ); | |
| 3661 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 3662 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 3663 | + let subagentPhase: "spawn" | "complete" = "spawn"; | |
| 3664 | + let cancelCalled = false; | |
| 3665 | + let runtimeOrdinalSeen = 0; | |
| 3666 | + const childSessionId = "mock-child-session-1"; | |
| 3667 | + const streamedSubagentText = "streamed subagent carryover text"; | |
| 3668 | + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; | |
| 3669 | + let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined; | |
| 3670 | + const adapter = makeAcpAdapterV2({ | |
| 3671 | + instanceId, | |
| 3672 | + flavor: { | |
| 3673 | + driver: ACP_TEST_DRIVER, | |
| 3674 | + capabilities: AcpProviderCapabilitiesV2, | |
| 3675 | + deferFinalizeForBackgroundWork: true, | |
| 3676 | + // Production Grok interrupt flags: soft settle keeps the process; | |
| 3677 | + // only requestRuntimeRestart (user Stop) hard-kills. | |
| 3678 | + restartRuntimeAfterInterrupt: true, | |
| 3679 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 3680 | + preserveRuntimeOnSettledInterrupt: true, | |
| 3681 | + extractSubagentUpdate: (toolCall) => | |
| 3682 | + toolCall.toolCallId !== "tool-call-generic-1" | |
| 3683 | + ? undefined | |
| 3684 | + : subagentPhase === "spawn" | |
| 3685 | + ? { | |
| 3686 | + nativeTaskId: "task-generic-1", | |
| 3687 | + prompt: "background subagent", | |
| 3688 | + title: "background subagent", | |
| 3689 | + model: null, | |
| 3690 | + status: "running", | |
| 3691 | + childSessionId, | |
| 3692 | + result: null, | |
| 3693 | + } | |
| 3694 | + : { | |
| 3695 | + nativeTaskId: "task-generic-1", | |
| 3696 | + prompt: "", | |
| 3697 | + title: null, | |
| 3698 | + model: null, | |
| 3699 | + status: "completed", | |
| 3700 | + childSessionId: null, | |
| 3701 | + result: "SUB_DONE", | |
| 3702 | + }, | |
| 3703 | + makeRuntime: makeMockRuntime({ | |
| 3704 | + childProcessSpawner, | |
| 3705 | + mockAgentPath, | |
| 3706 | + environment: (runtimeOrdinal) => { | |
| 3707 | + runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); | |
| 3708 | + return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }; | |
| 3709 | + }, | |
| 3710 | + ownDetachedProcessGroup: true, | |
| 3711 | + protocolEvents, | |
| 3712 | + wrapCancel: (cancel) => | |
| 3713 | + Effect.sync(() => { | |
| 3714 | + cancelCalled = true; | |
| 3715 | + }).pipe(Effect.andThen(cancel)), | |
| 3716 | + wrapRuntime: (runtime) => ({ | |
| 3717 | + ...runtime, | |
| 3718 | + handleSessionUpdate: (handler) => | |
| 3719 | + Effect.sync(() => { | |
| 3720 | + sessionUpdateHandler = handler; | |
| 3721 | + }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), | |
| 3722 | + }), | |
| 3723 | + }), | |
| 3724 | + }, | |
| 3725 | + fileSystem, | |
| 3726 | + idAllocator, | |
| 3727 | + serverConfig, | |
| 3728 | + }); | |
| 3729 | + const threadId = ThreadId.make("thread-acp-stop-after-soft-steer-orphan"); | |
| 3730 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 3731 | + runtimeMode: "full-access", | |
| 3732 | + interactionMode: "default", | |
| 3733 | + cwd: process.cwd(), | |
| 3734 | + }); | |
| 3735 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 3736 | + const runtime = yield* adapter.openSession({ | |
| 3737 | + threadId, | |
| 3738 | + providerSessionId: ProviderSessionId.make( | |
| 3739 | + "provider-session-acp-stop-after-soft-steer-orphan", | |
| 3740 | + ), | |
| 3741 | + modelSelection, | |
| 3742 | + runtimePolicy, | |
| 3743 | + }); | |
| 3744 | + const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 3745 | + yield* runtime.events.pipe( | |
| 3746 | + Stream.runForEach((event) => Queue.offer(events, event)), | |
| 3747 | + Effect.forkScoped, | |
| 3748 | + ); | |
| 3749 | + const providerThread = yield* runtime.ensureThread({ | |
| 3750 | + threadId, | |
| 3751 | + modelSelection, | |
| 3752 | + runtimePolicy, | |
| 3753 | + }); | |
| 3754 | + const now = yield* DateTime.now; | |
| 3755 | + yield* runtime.startTurn( | |
| 3756 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | |
| 3757 | + ); | |
| 3758 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 3759 | + Stream.filter( | |
| 3760 | + (event) => | |
| 3761 | + event.direction === "incoming" && | |
| 3762 | + event.stage === "raw" && | |
| 3763 | + typeof event.payload === "string" && | |
| 3764 | + event.payload.includes('"stopReason"'), | |
| 3765 | + ), | |
| 3766 | + Stream.runHead, | |
| 3767 | + ); | |
| 3768 | + yield* Effect.yieldNow; | |
| 3769 | + yield* Effect.yieldNow; | |
| 3770 | + | |
| 3771 | + // Stream assistant text onto the carryover subagent while the deferred | |
| 3772 | + // turn is still active so assistantText races ahead of task.result. | |
| 3773 | + assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); | |
| 3774 | + yield* sessionUpdateHandler!({ | |
| 3775 | + sessionId: childSessionId, | |
| 3776 | + update: { | |
| 3777 | + sessionUpdate: "agent_message_chunk", | |
| 3778 | + content: { type: "text", text: streamedSubagentText }, | |
| 3779 | + }, | |
| 3780 | + }); | |
| 3781 | + let subagentTurnItemId: string | null = null; | |
| 3782 | + let streamedTextSeen = false; | |
| 3783 | + for (let attempt = 0; attempt < 64; attempt += 1) { | |
| 3784 | + const maybeEvent = yield* Queue.take(events).pipe(Effect.timeoutOption("50 millis")); | |
| 3785 | + if (Option.isNone(maybeEvent)) break; | |
| 3786 | + const event = maybeEvent.value; | |
| 3787 | + if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { | |
| 3788 | + subagentTurnItemId = event.turnItem.id; | |
| 3789 | + } | |
| 3790 | + if ( | |
| 3791 | + event.type === "message.updated" && | |
| 3792 | + event.message.text.includes(streamedSubagentText) | |
| 3793 | + ) { | |
| 3794 | + streamedTextSeen = true; | |
| 3795 | + break; | |
| 3796 | + } | |
| 3797 | + } | |
| 3798 | + assert.isTrue( | |
| 3799 | + streamedTextSeen, | |
| 3800 | + "pre-steer child session chunk must project as subagent assistant text", | |
| 3801 | + ); | |
| 3802 | + | |
| 3803 | + const firstProviderTurnId = idAllocator.derive.providerTurn({ | |
| 3804 | + driver: ACP_TEST_DRIVER, | |
| 3805 | + nativeTurnId: "mock-session-1:turn:1", | |
| 3806 | + }); | |
| 3807 | + // Soft steer first: clears the turn, leaves the process alive. | |
| 3808 | + yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); | |
| 3809 | + assert.isFalse( | |
| 3810 | + cancelCalled, | |
| 3811 | + "settled soft steer must not send session/cancel before the later Stop", | |
| 3812 | + ); | |
| 3813 | + | |
| 3814 | + let firstTerminalStatus: string | null = null; | |
| 3815 | + while (firstTerminalStatus === null) { | |
| 3816 | + const event = yield* Queue.take(events); | |
| 3817 | + if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { | |
| 3818 | + subagentTurnItemId = event.turnItem.id; | |
| 3819 | + } | |
| 3820 | + if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { | |
| 3821 | + firstTerminalStatus = event.status; | |
| 3822 | + } | |
| 3823 | + } | |
| 3824 | + assert.equal(firstTerminalStatus, "interrupted"); | |
| 3825 | + assert.notEqual(subagentTurnItemId, null); | |
| 3826 | + assert.equal( | |
| 3827 | + runtimeOrdinalSeen, | |
| 3828 | + 1, | |
| 3829 | + "soft steer must leave the original ACP runtime process alive", | |
| 3830 | + ); | |
| 3831 | + | |
| 3832 | + // User Stop against the already-cleared turn: contain the orphan runtime. | |
| 3833 | + const stopExit = yield* runtime | |
| 3834 | + .interruptTurn({ | |
| 3835 | + providerThread, | |
| 3836 | + providerTurnId: firstProviderTurnId, | |
| 3837 | + requestRuntimeRestart: true, | |
| 3838 | + }) | |
| 3839 | + .pipe(Effect.exit); | |
| 3840 | + if (Exit.isFailure(stopExit)) { | |
| 3841 | + assert.fail( | |
| 3842 | + `cleared-turn Stop must contain the orphan runtime, not fail: ${Cause.pretty(stopExit.cause)}`, | |
| 3843 | + ); | |
| 3844 | + } | |
| 3845 | + | |
| 3846 | + // Orphan Stop must terminalize carried-over subagents (soft steer left them | |
| 3847 | + // running on purpose; quarantine alone would leave them stuck "running"). | |
| 3848 | + let subagentStopStatus: string | null = null; | |
| 3849 | + let subagentStopResult: string | null | undefined; | |
| 3850 | + let subagentStopProviderThreadId: string | null | undefined; | |
| 3851 | + let subagentUpdatedResult: string | null | undefined; | |
| 3852 | + for (let attempt = 0; attempt < 64; attempt += 1) { | |
| 3853 | + const maybeEvent = yield* Queue.take(events).pipe(Effect.timeoutOption("50 millis")); | |
| 3854 | + if (Option.isNone(maybeEvent)) break; | |
| 3855 | + const event = maybeEvent.value; | |
| 3856 | + if ( | |
| 3857 | + event.type === "turn_item.updated" && | |
| 3858 | + event.turnItem.type === "subagent" && | |
| 3859 | + event.turnItem.id === subagentTurnItemId | |
| 3860 | + ) { | |
| 3861 | + subagentStopStatus = event.turnItem.status; | |
| 3862 | + subagentStopResult = event.turnItem.result; | |
| 3863 | + subagentStopProviderThreadId = event.turnItem.providerThreadId; | |
| 3864 | + if (subagentStopStatus === "interrupted") break; | |
| 3865 | + } | |
| 3866 | + if ( | |
| 3867 | + event.type === "subagent.updated" && | |
| 3868 | + event.subagent.status === "interrupted" && | |
| 3869 | + event.subagent.result === streamedSubagentText | |
| 3870 | + ) { | |
| 3871 | + subagentUpdatedResult = event.subagent.result; | |
| 3872 | + } | |
| 3873 | + } | |
| 3874 | + assert.equal( | |
| 3875 | + subagentStopStatus, | |
| 3876 | + "interrupted", | |
| 3877 | + "orphan Stop must emit interrupted terminal for turn-1 carryover subagent", | |
| 3878 | + ); | |
| 3879 | + assert.equal( | |
| 3880 | + subagentStopResult, | |
| 3881 | + streamedSubagentText, | |
| 3882 | + "orphan Stop must merge streamed assistantText into the interrupted result", | |
| 3883 | + ); | |
| 3884 | + assert.equal( | |
| 3885 | + subagentStopProviderThreadId, | |
| 3886 | + providerThread.id, | |
| 3887 | + "orphan Stop parent-level events must use the spawn-time parent provider thread id", | |
| 3888 | + ); | |
| 3889 | + assert.equal( | |
| 3890 | + subagentUpdatedResult, | |
| 3891 | + streamedSubagentText, | |
| 3892 | + "orphan Stop subagent.updated must also carry the streamed result", | |
| 3893 | + ); | |
| 3894 | + | |
| 3895 | + yield* Queue.clear(protocolEvents); | |
| 3896 | + const secondNow = yield* DateTime.now; | |
| 3897 | + yield* runtime.startTurn( | |
| 3898 | + makeTurnInput({ | |
| 3899 | + threadId, | |
| 3900 | + providerThread, | |
| 3901 | + instanceId, | |
| 3902 | + runtimePolicy, | |
| 3903 | + now: secondNow, | |
| 3904 | + ordinal: 2, | |
| 3905 | + }), | |
| 3906 | + ); | |
| 3907 | + const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe( | |
| 3908 | + Stream.filter( | |
| 3909 | + (event) => | |
| 3910 | + event.direction === "outgoing" && | |
| 3911 | + (rawProtocolMethod(event) === "session/load" || | |
| 3912 | + rawProtocolMethod(event) === "session/new"), | |
| 3913 | + ), | |
| 3914 | + Stream.runHead, | |
| 3915 | + ); | |
| 3916 | + assert.isTrue( | |
| 3917 | + Option.isSome(loadAfterRestart), | |
| 3918 | + "orphan containment must force a runtime respawn before the next turn", | |
| 3919 | + ); | |
| 3920 | + assert.equal( | |
| 3921 | + runtimeOrdinalSeen, | |
| 3922 | + 2, | |
| 3923 | + "Stop after soft steer must replace the orphan ACP runtime process", | |
| 3924 | + ); | |
| 3925 | + | |
| 3926 | + // nativeTurnId is `${sessionId}:turn:${ordinal}`. The mock always uses | |
| 3927 | + // mock-session-1; ordinal 2 yields turn:2 on the replacement process. | |
| 3928 | + // Carryover was quarantined by Stop, so turn-1's subagent must not re-attach. | |
| 3929 | + subagentPhase = "complete"; | |
| 3930 | + const secondProviderTurnId = idAllocator.derive.providerTurn({ | |
| 3931 | + driver: ACP_TEST_DRIVER, | |
| 3932 | + nativeTurnId: "mock-session-1:turn:2", | |
| 3933 | + }); | |
| 3934 | + let carriedItemStatus: string | null = null; | |
| 3935 | + let secondTerminalStatus: string | null = null; | |
| 3936 | + while (secondTerminalStatus === null) { | |
| 3937 | + const event = yield* Queue.take(events); | |
| 3938 | + if ( | |
| 3939 | + event.type === "turn_item.updated" && | |
| 3940 | + event.turnItem.type === "subagent" && | |
| 3941 | + event.turnItem.id === subagentTurnItemId | |
| 3942 | + ) { | |
| 3943 | + carriedItemStatus = event.turnItem.status; | |
| 3944 | + } | |
| 3945 | + if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { | |
| 3946 | + secondTerminalStatus = event.status; | |
| 3947 | + } | |
| 3948 | + } | |
| 3949 | + assert.isNull( | |
| 3950 | + carriedItemStatus, | |
| 3951 | + "Stop quarantine must drop turn-1 subagent carryover on the respawned runtime", | |
| 3952 | + ); | |
| 3953 | + assert.equal(secondTerminalStatus, "completed"); | |
| 3954 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 3955 | + ); | |
| 3956 | + | |
| 3957 | + it.effect( | |
| 3958 | + "settled soft interrupt skips cancel when prompt wire is settled before completion callback", | |
| 3959 | + () => | |
| 3960 | + Effect.gen(function* () { | |
| 3961 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 3962 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 3963 | + const idAllocator = yield* IdAllocatorV2; | |
| 3964 | + const path = yield* Path.Path; | |
| 3965 | + const serverConfig = yield* ServerConfig; | |
| 3966 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 3967 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 3968 | + ); | |
| 3969 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 3970 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 3971 | + let subagentPhase: "spawn" | "complete" = "spawn"; | |
| 3972 | + let cancelCalled = false; | |
| 3973 | + const promptPhases: Array<string> = []; | |
| 3974 | + // Gate adapter-visible prompt return so we can open the race window after | |
| 3975 | + // the native stopReason is on the wire. Release lets Effect.tap mark | |
| 3976 | + // promptWireSettled before the completion callback requests the permit; | |
| 3977 | + // interrupt then ORs wire-done with promptSettled under the permit. | |
| 3978 | + const promptWireReturned = yield* Deferred.make<void>(); | |
| 3979 | + const releasePromptCompletion = yield* Deferred.make<void>(); | |
| 3980 | + const adapter = makeAcpAdapterV2({ | |
| 3981 | + instanceId, | |
| 3982 | + flavor: { | |
| 3983 | + driver: ACP_TEST_DRIVER, | |
| 3984 | + capabilities: AcpProviderCapabilitiesV2, | |
| 3985 | + deferFinalizeForBackgroundWork: true, | |
| 3986 | + // Production Grok flags: without settled-soft, a steer soft-cancels. | |
| 3987 | + restartRuntimeAfterInterrupt: true, | |
| 3988 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 3989 | + preserveRuntimeOnSettledInterrupt: true, | |
| 3990 | + extractSubagentUpdate: (toolCall) => | |
| 3991 | + toolCall.toolCallId !== "tool-call-generic-1" | |
| 3992 | + ? undefined | |
| 3993 | + : subagentPhase === "spawn" | |
| 3994 | + ? { | |
| 3995 | + nativeTaskId: "task-generic-1", | |
| 3996 | + prompt: "background subagent", | |
| 3997 | + title: "background subagent", | |
| 3998 | + model: null, | |
| 3999 | + status: "running", | |
| 4000 | + childSessionId: null, | |
| 4001 | + result: null, | |
| 4002 | + } | |
| 4003 | + : { | |
| 4004 | + nativeTaskId: "task-generic-1", | |
| 4005 | + prompt: "", | |
| 4006 | + title: null, | |
| 4007 | + model: null, | |
| 4008 | + status: "completed", | |
| 4009 | + childSessionId: null, | |
| 4010 | + result: "SUB_DONE", | |
| 4011 | + }, | |
| 4012 | + makeRuntime: makeMockRuntime({ | |
| 4013 | + childProcessSpawner, | |
| 4014 | + mockAgentPath, | |
| 4015 | + environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, | |
| 4016 | + protocolEvents, | |
| 4017 | + wrapCancel: (cancel) => | |
| 4018 | + Effect.sync(() => { | |
| 4019 | + cancelCalled = true; | |
| 4020 | + }).pipe(Effect.andThen(cancel)), | |
| 4021 | + wrapRuntime: (runtime) => ({ | |
| 4022 | + ...runtime, | |
| 4023 | + prompt: (payload) => | |
| 4024 | + Effect.gen(function* () { | |
| 4025 | + promptPhases.push("prompt-start"); | |
| 4026 | + const result = yield* runtime.prompt(payload); | |
| 4027 | + promptPhases.push("wire-returned"); | |
| 4028 | + yield* Deferred.succeed(promptWireReturned, undefined); | |
| 4029 | + yield* Deferred.await(releasePromptCompletion); | |
| 4030 | + promptPhases.push("completion-released"); | |
| 4031 | + return result; | |
| 4032 | + }), | |
| 4033 | + }), | |
| 4034 | + }), | |
| 4035 | + }, | |
| 4036 | + fileSystem, | |
| 4037 | + idAllocator, | |
| 4038 | + serverConfig, | |
| 4039 | + }); | |
| 4040 | + const threadId = ThreadId.make("thread-acp-settled-soft-admission-race"); | |
| 4041 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 4042 | + runtimeMode: "full-access", | |
| 4043 | + interactionMode: "default", | |
| 4044 | + cwd: process.cwd(), | |
| 4045 | + }); | |
| 4046 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 4047 | + const runtime = yield* adapter.openSession({ | |
| 4048 | + threadId, | |
| 4049 | + providerSessionId: ProviderSessionId.make( | |
| 4050 | + "provider-session-acp-settled-soft-admission-race", | |
| 4051 | + ), | |
| 4052 | + modelSelection, | |
| 4053 | + runtimePolicy, | |
| 4054 | + }); | |
| 4055 | + const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 4056 | + yield* runtime.events.pipe( | |
| 4057 | + Stream.runForEach((event) => Queue.offer(events, event)), | |
| 4058 | + Effect.forkScoped, | |
| 4059 | + ); | |
| 4060 | + const providerThread = yield* runtime.ensureThread({ | |
| 4061 | + threadId, | |
| 4062 | + modelSelection, | |
| 4063 | + runtimePolicy, | |
| 4064 | + }); | |
| 4065 | + const now = yield* DateTime.now; | |
| 4066 | + yield* runtime.startTurn( | |
| 4067 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | |
| 4068 | + ); | |
| 4069 | + yield* Deferred.await(promptWireReturned); | |
| 4070 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 4071 | + Stream.filter( | |
| 4072 | + (event) => | |
| 4073 | + event.direction === "incoming" && | |
| 4074 | + event.stage === "raw" && | |
| 4075 | + typeof event.payload === "string" && | |
| 4076 | + event.payload.includes('"stopReason"'), | |
| 4077 | + ), | |
| 4078 | + Stream.runHead, | |
| 4079 | + ); | |
| 4080 | + assert.deepEqual(promptPhases, ["prompt-start", "wire-returned"]); | |
| 4081 | + | |
| 4082 | + const firstProviderTurnId = idAllocator.derive.providerTurn({ | |
| 4083 | + driver: ACP_TEST_DRIVER, | |
| 4084 | + nativeTurnId: "mock-session-1:turn:1", | |
| 4085 | + }); | |
| 4086 | + // Fork interrupt while the adapter-side return is still gated. Release so | |
| 4087 | + // promptWireSettled completes (Effect.tap) before/while the completion | |
| 4088 | + // callback contends for runtimeCallbackPermit. Holding the gate forever | |
| 4089 | + // would also block the wire signal (same Effect resolution), so release | |
| 4090 | + // is required; the assertion is cancel skipped after that race window. | |
| 4091 | + const interruptFiber = yield* runtime | |
| 4092 | + .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) | |
| 4093 | + .pipe(Effect.forkScoped); | |
| 4094 | + yield* Deferred.succeed(releasePromptCompletion, undefined); | |
| 4095 | + for (let attempt = 0; attempt < 20; attempt += 1) { | |
| 4096 | + if (promptPhases.includes("completion-released")) break; | |
| 4097 | + yield* Effect.yieldNow; | |
| 4098 | + } | |
| 4099 | + yield* TestClock.adjust("10 seconds"); | |
| 4100 | + yield* Fiber.join(interruptFiber); | |
| 4101 | + | |
| 4102 | + assert.includeMembers(promptPhases, [ | |
| 4103 | + "prompt-start", | |
| 4104 | + "wire-returned", | |
| 4105 | + "completion-released", | |
| 4106 | + ]); | |
| 4107 | + assert.isFalse( | |
| 4108 | + cancelCalled, | |
| 4109 | + "settled soft steer must skip session/cancel once the prompt wire has settled", | |
| 4110 | + ); | |
| 4111 | + | |
| 4112 | + let firstTerminalStatus: string | null = null; | |
| 4113 | + while (firstTerminalStatus === null) { | |
| 4114 | + const event = yield* Queue.take(events); | |
| 4115 | + if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { | |
| 4116 | + firstTerminalStatus = event.status; | |
| 4117 | + } | |
| 4118 | + } | |
| 4119 | + assert.equal(firstTerminalStatus, "interrupted"); | |
| 4120 | + assert.equal(subagentPhase, "spawn"); | |
| 4121 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 4122 | + ); | |
| 4123 | + | |
| 4124 | + it.effect( | |
| 4125 | + "does not pin hasPendingBackgroundWork when a late TaskOutput re-reports an in-turn-handled task", | |
| 4126 | + () => | |
| 4127 | + Effect.gen(function* () { | |
| 4128 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 4129 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 4130 | + const idAllocator = yield* IdAllocatorV2; | |
| 4131 | + const path = yield* Path.Path; | |
| 4132 | + const serverConfig = yield* ServerConfig; | |
| 4133 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 4134 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 4135 | + ); | |
| 4136 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 4137 | + const continuationRequests: Array<ProviderContinuationRequest> = []; | |
| 4138 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 4139 | + const adapter = makeAcpAdapterV2({ | |
| 4140 | + instanceId, | |
| 4141 | + flavor: { | |
| 4142 | + driver: ACP_TEST_DRIVER, | |
| 4143 | + capabilities: AcpProviderCapabilitiesV2, | |
| 4144 | + enablePostSettleContinuation: true, | |
| 4145 | + extractBackgroundTaskId: (toolCall) => | |
| 4146 | + toolCall.toolCallId === "tool-call-monitor-1" ? "task-monitor-1" : undefined, | |
| 4147 | + extractBackgroundTaskCompletion: (toolCall) => | |
| 4148 | + toolCall.toolCallId === "tool-call-fetch-1" | |
| 4149 | + ? [ | |
| 4150 | + { | |
| 4151 | + taskId: "task-monitor-1", | |
| 4152 | + status: toolCall.status === "completed" ? "completed" : "running", | |
| 4153 | + appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "", | |
| 4154 | + }, | |
| 4155 | + ] | |
| 4156 | + : [], | |
| 4157 | + makeRuntime: makeMockRuntime({ | |
| 4158 | + childProcessSpawner, | |
| 4159 | + mockAgentPath, | |
| 4160 | + environment: { | |
| 4161 | + T3_ACP_EMIT_IN_TURN_TASKOUTPUT_THEN_LATE_DUPLICATE: "1", | |
| 4162 | + }, | |
| 4163 | + protocolEvents, | |
| 4164 | + }), | |
| 4165 | + }, | |
| 4166 | + fileSystem, | |
| 4167 | + idAllocator, | |
| 4168 | + serverConfig, | |
| 4169 | + continuationRequests: { | |
| 4170 | + offer: (request) => | |
| 4171 | + Effect.sync(() => { | |
| 4172 | + continuationRequests.push(request); | |
| 4173 | + }), | |
| 4174 | + }, | |
| 4175 | + }); | |
| 4176 | + const threadId = ThreadId.make("thread-acp-already-handled-wake-pin"); | |
| 4177 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 4178 | + runtimeMode: "full-access", | |
| 4179 | + interactionMode: "default", | |
| 4180 | + cwd: process.cwd(), | |
| 4181 | + }); | |
| 4182 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 4183 | + const runtime = yield* adapter.openSession({ | |
| 4184 | + threadId, | |
| 4185 | + providerSessionId: ProviderSessionId.make( | |
| 4186 | + "provider-session-acp-already-handled-wake-pin", | |
| 4187 | + ), | |
| 4188 | + modelSelection, | |
| 4189 | + runtimePolicy, | |
| 4190 | + }); | |
| 4191 | + if (runtime.hasPendingBackgroundWork === undefined) { | |
| 4192 | + return yield* Effect.die( | |
| 4193 | + "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", | |
| 4194 | + ); | |
| 4195 | + } | |
| 4196 | + const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; | |
| 4197 | + const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 4198 | + yield* runtime.events.pipe( | |
| 4199 | + Stream.runForEach((event) => Queue.offer(events, event)), | |
| 4200 | + Effect.forkScoped, | |
| 4201 | + ); | |
| 4202 | + const providerThread = yield* runtime.ensureThread({ | |
| 4203 | + threadId, | |
| 4204 | + modelSelection, | |
| 4205 | + runtimePolicy, | |
| 4206 | + }); | |
| 4207 | + const now = yield* DateTime.now; | |
| 4208 | + yield* runtime.startTurn( | |
| 4209 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | |
| 4210 | + ); | |
| 4211 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 4212 | + driver: ACP_TEST_DRIVER, | |
| 4213 | + nativeTurnId: "mock-session-1:turn:1", | |
| 4214 | + }); | |
| 4215 | + | |
| 4216 | + let terminalStatus: string | null = null; | |
| 4217 | + while (terminalStatus === null) { | |
| 4218 | + const event = yield* Queue.take(events); | |
| 4219 | + if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { | |
| 4220 | + terminalStatus = event.status; | |
| 4221 | + } | |
| 4222 | + } | |
| 4223 | + assert.equal(terminalStatus, "completed"); | |
| 4224 | + | |
| 4225 | + // Wait for the late post-finalize duplicate TaskOutput frame. | |
| 4226 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 4227 | + Stream.filter( | |
| 4228 | + (event) => | |
| 4229 | + event.direction === "incoming" && | |
| 4230 | + event.stage === "raw" && | |
| 4231 | + typeof event.payload === "string" && | |
| 4232 | + event.payload.includes("MONITOR_LISTING_TOKEN_LATE"), | |
| 4233 | + ), | |
| 4234 | + Stream.runHead, | |
| 4235 | + ); | |
| 4236 | + yield* Effect.yieldNow; | |
| 4237 | + yield* Effect.yieldNow; | |
| 4238 | + yield* Effect.yieldNow; | |
| 4239 | + | |
| 4240 | + assert.lengthOf( | |
| 4241 | + continuationRequests, | |
| 4242 | + 0, | |
| 4243 | + "already-handled late TaskOutput must not open a continuation run", | |
| 4244 | + ); | |
| 4245 | + assert.isFalse( | |
| 4246 | + yield* hasPendingBackgroundWork, | |
| 4247 | + "wake buffer must not stay non-empty and pin idle release after an already-handled re-report", | |
| 4248 | + ); | |
| 4249 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 4250 | + ); | |
| 4251 | + | |
| 4252 | + it.effect( | |
| 4253 | + "holds a settled turn until the injected monitor report streams instead of finalizing into it", | |
| 4254 | + () => | |
| 4255 | + Effect.gen(function* () { | |
| 4256 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 4257 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 4258 | + const idAllocator = yield* IdAllocatorV2; | |
| 4259 | + const path = yield* Path.Path; | |
| 4260 | + const serverConfig = yield* ServerConfig; | |
| 4261 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 4262 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 4263 | + ); | |
| 4264 | + const triggerDir = yield* fileSystem.makeTempDirectoryScoped(); | |
| 4265 | + const triggerPath = path.join(triggerDir, "report-trigger"); | |
| 4266 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 4267 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 4268 | + const adapter = makeAcpAdapterV2({ | |
| 4269 | + instanceId, | |
| 4270 | + flavor: { | |
| 4271 | + driver: ACP_TEST_DRIVER, | |
| 4272 | + capabilities: AcpProviderCapabilitiesV2, | |
| 4273 | + deferFinalizeForBackgroundWork: true, | |
| 4274 | + extractBackgroundTaskId: (toolCall) => | |
| 4275 | + toolCall.toolCallId === "tool-call-monitor-1" ? "task-monitor-1" : undefined, | |
| 4276 | + extractBackgroundToolMutation: (text) => | |
| 4277 | + text.includes('Monitor "task-monitor-1" ended') | |
| 4278 | + ? [{ taskId: "task-monitor-1", status: "completed", appendOutput: "" }] | |
| 4279 | + : [], | |
| 4280 | + extractBackgroundTaskCompletion: (toolCall) => | |
| 4281 | + toolCall.toolCallId === "tool-call-fetch-1" | |
| 4282 | + ? [ | |
| 4283 | + { | |
| 4284 | + taskId: "task-monitor-1", | |
| 4285 | + status: toolCall.status === "completed" ? "completed" : "running", | |
| 4286 | + appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "", | |
| 4287 | + }, | |
| 4288 | + ] | |
| 4289 | + : [], | |
| 4290 | + makeRuntime: makeMockRuntime({ | |
| 4291 | + childProcessSpawner, | |
| 4292 | + mockAgentPath, | |
| 4293 | + environment: { | |
| 4294 | + T3_ACP_EMIT_POST_SETTLE_MONITOR_FLOW: "1", | |
| 4295 | + T3_ACP_INJECTED_REPORT_TRIGGER_PATH: triggerPath, | |
| 4296 | + }, | |
| 4297 | + protocolEvents, | |
| 4298 | + }), | |
| 4299 | + }, | |
| 4300 | + fileSystem, | |
| 4301 | + idAllocator, | |
| 4302 | + serverConfig, | |
| 4303 | + }); | |
| 4304 | + const threadId = ThreadId.make("thread-acp-injected-report-hold"); | |
| 4305 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 4306 | + runtimeMode: "full-access", | |
| 4307 | + interactionMode: "default", | |
| 4308 | + cwd: process.cwd(), | |
| 4309 | + }); | |
| 4310 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 4311 | + const runtime = yield* adapter.openSession({ | |
| 4312 | + threadId, | |
| 4313 | + providerSessionId: ProviderSessionId.make("provider-session-acp-injected-report"), | |
| 4314 | + modelSelection, | |
| 4315 | + runtimePolicy, | |
| 4316 | + }); | |
| 4317 | + const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 4318 | + yield* runtime.events.pipe( | |
| 4319 | + Stream.runForEach((event) => Queue.offer(events, event)), | |
| 4320 | + Effect.forkScoped, | |
| 4321 | + ); | |
| 4322 | + const providerThread = yield* runtime.ensureThread({ | |
| 4323 | + threadId, | |
| 4324 | + modelSelection, | |
| 4325 | + runtimePolicy, | |
| 4326 | + }); | |
| 4327 | + const now = yield* DateTime.now; | |
| 4328 | + yield* runtime.startTurn( | |
| 4329 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | |
| 4330 | + ); | |
| 4331 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 4332 | + driver: ACP_TEST_DRIVER, | |
| 4333 | + nativeTurnId: "mock-session-1:turn:1", | |
| 4334 | + }); | |
| 4335 | + | |
| 4336 | + // Wait (real time) until the post-settle end notice and TaskOutput | |
| 4337 | + // hydration are ingested: the hydrated monitor card carries the | |
| 4338 | + // fetched listing. | |
| 4339 | + let reportSeen = false; | |
| 4340 | + const trackReport = (event: ProviderAdapterV2Event): void => { | |
| 4341 | + if ( | |
| 4342 | + event.type === "turn_item.updated" && | |
| 4343 | + event.turnItem.type === "assistant_message" && | |
| 4344 | + event.turnItem.text.includes("MONITOR_REPORT_TOKEN") | |
| 4345 | + ) { | |
| 4346 | + reportSeen = true; | |
| 4347 | + } | |
| 4348 | + }; | |
| 4349 | + let hydrated = false; | |
| 4350 | + while (!hydrated) { | |
| 4351 | + const event = yield* Queue.take(events); | |
| 4352 | + if ( | |
| 4353 | + event.type === "turn_item.updated" && | |
| 4354 | + event.turnItem.type === "command_execution" && | |
| 4355 | + event.turnItem.status === "completed" && | |
| 4356 | + (event.turnItem.output ?? "").includes("MONITOR_LISTING_TOKEN") | |
| 4357 | + ) { | |
| 4358 | + hydrated = true; | |
| 4359 | + } | |
| 4360 | + } | |
| 4361 | + yield* Effect.yieldNow; | |
| 4362 | + yield* Effect.yieldNow; | |
| 4363 | + yield* Effect.yieldNow; | |
| 4364 | + | |
| 4365 | + // Pre-fix the 2s deferred-finalize debounce fires here and the report | |
| 4366 | + // streamed by the injected turn afterwards is dropped on the floor. | |
| 4367 | + yield* TestClock.adjust("3 seconds"); | |
| 4368 | + yield* Effect.yieldNow; | |
| 4369 | + yield* Effect.yieldNow; | |
| 4370 | + yield* Effect.yieldNow; | |
| 4371 | + let drained = yield* Queue.poll(events); | |
| 4372 | + while (Option.isSome(drained)) { | |
| 4373 | + trackReport(drained.value); | |
| 4374 | + assert.notEqual( | |
| 4375 | + drained.value.type, | |
| 4376 | + "turn.terminal", | |
| 4377 | + "deferred finalize must hold while the injected-turn report is owed", | |
| 4378 | + ); | |
| 4379 | + drained = yield* Queue.poll(events); | |
| 4380 | + } | |
| 4381 | + | |
| 4382 | + // Release the report, then the normal debounce finalizes the turn. | |
| 4383 | + yield* fileSystem.writeFileString(triggerPath, "go"); | |
| 4384 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 4385 | + Stream.filter( | |
| 4386 | + (event) => | |
| 4387 | + event.direction === "incoming" && | |
| 4388 | + event.stage === "raw" && | |
| 4389 | + typeof event.payload === "string" && | |
| 4390 | + event.payload.includes("MONITOR_REPORT_TOKEN"), | |
| 4391 | + ), | |
| 4392 | + Stream.runHead, | |
| 4393 | + ); | |
| 4394 | + yield* Effect.yieldNow; | |
| 4395 | + yield* Effect.yieldNow; | |
| 4396 | + yield* Effect.yieldNow; | |
| 4397 | + yield* TestClock.adjust("3 seconds"); | |
| 4398 | + | |
| 4399 | + let terminalStatus: string | null = null; | |
| 4400 | + while (terminalStatus === null) { | |
| 4401 | + const event = yield* Queue.take(events); | |
| 4402 | + trackReport(event); | |
| 4403 | + if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { | |
| 4404 | + terminalStatus = event.status; | |
| 4405 | + } | |
| 4406 | + } | |
| 4407 | + assert.equal(terminalStatus, "completed"); | |
| 4408 | + assert.isTrue( | |
| 4409 | + reportSeen, | |
| 4410 | + "the injected-turn report must project before the turn finalizes", | |
| 4411 | + ); | |
| 4412 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 4413 | + ); | |
| 4414 | + | |
| 4415 | + it.effect("restarts the ACP child process before the next prompt after interrupt", () => | |
| 4416 | + Effect.gen(function* () { | |
| 4417 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 4418 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 4419 | + const idAllocator = yield* IdAllocatorV2; | |
| 4420 | + const path = yield* Path.Path; | |
| 4421 | + const serverConfig = yield* ServerConfig; | |
| 4422 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 4423 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 4424 | + ); | |
| 4425 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 4426 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 4427 | + const adapter = makeAcpAdapterV2({ | |
| 4428 | + instanceId, | |
| 4429 | + flavor: { | |
| 4430 | + driver: ACP_TEST_DRIVER, | |
| 4431 | + capabilities: AcpProviderCapabilitiesV2, | |
| 4432 | + restartRuntimeAfterInterrupt: true, | |
| 4433 | + makeRuntime: makeMockRuntime({ | |
| 4434 | + childProcessSpawner, | |
| 4435 | + mockAgentPath, | |
| 4436 | + protocolEvents, | |
| 4437 | + }), | |
| 4438 | + }, | |
| 4439 | + fileSystem, | |
| 4440 | + idAllocator, | |
| 4441 | + serverConfig, | |
| 4442 | + }); | |
| 4443 | + const threadId = ThreadId.make("thread-acp-restart-after-interrupt"); | |
| 4444 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 4445 | + runtimeMode: "full-access", | |
| 4446 | + interactionMode: "default", | |
| 4447 | + cwd: process.cwd(), | |
| 4448 | + }); | |
| 4449 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 4450 | + const runtime = yield* adapter.openSession({ | |
| 4451 | + threadId, | |
| 4452 | + providerSessionId: ProviderSessionId.make("provider-session-acp-restart-after-interrupt"), | |
| 4453 | + modelSelection, | |
| 4454 | + runtimePolicy, | |
| 4455 | + }); | |
| 4456 | + const providerThread = yield* runtime.ensureThread({ | |
| 4457 | + threadId, | |
| 4458 | + modelSelection, | |
| 4459 | + runtimePolicy, | |
| 4460 | + }); | |
| 4461 | + const now = yield* DateTime.now; | |
| 4462 | + yield* runtime.startTurn( | |
| 4463 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | |
| 4464 | + ); | |
| 4465 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 4466 | + Stream.filter( | |
| 4467 | + (event) => | |
| 4468 | + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | |
| 4469 | + ), | |
| 4470 | + Stream.runHead, | |
| 4471 | + ); | |
| 4472 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 4473 | + driver: ACP_TEST_DRIVER, | |
| 4474 | + nativeTurnId: `${providerThread.nativeThreadRef?.nativeId}:turn:1`, | |
| 4475 | + }); | |
| 4476 | + yield* runtime.interruptTurn({ | |
| 4477 | + providerThread, | |
| 4478 | + providerTurnId, | |
| 4479 | + requestRuntimeRestart: true, | |
| 4480 | + }); | |
| 4481 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 4482 | + Stream.filter( | |
| 4483 | + (event) => | |
| 4484 | + event.direction === "outgoing" && rawProtocolMethod(event) === "session/cancel", | |
| 4485 | + ), | |
| 4486 | + Stream.runHead, | |
| 4487 | + ); | |
| 4488 | + | |
| 4489 | + yield* runtime.startTurn( | |
| 4490 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), | |
| 4491 | + ); | |
| 4492 | + const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe( | |
| 4493 | + Stream.filter( | |
| 4494 | + (event) => event.direction === "outgoing" && rawProtocolMethod(event) === "session/load", | |
| 4495 | + ), | |
| 4496 | + Stream.runHead, | |
| 4497 | + ); | |
| 4498 | + assert.isTrue( | |
| 4499 | + Option.isSome(loadAfterRestart), | |
| 4500 | + "post-interrupt startTurn should respawn the runtime and replay session/load", | |
| 4501 | + ); | |
| 4502 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 4503 | + ); | |
| 4504 | + | |
| 4505 | + it.effect("Windows teardown is one-shot explicitly with independent finalizer cleanup", () => | |
| 4506 | + Effect.gen(function* () { | |
| 4507 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 4508 | + const path = yield* Path.Path; | |
| 4509 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 4510 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 4511 | + ); | |
| 4512 | + const runtimeScope = yield* Scope.make(); | |
| 4513 | + const taskkillCommands: Array<{ | |
| 4514 | + readonly command: string; | |
| 4515 | + readonly args: ReadonlyArray<string>; | |
| 4516 | + }> = []; | |
| 4517 | + const taskkillSpawner = makeTaskkillSpawner({ exitCode: 0, commands: taskkillCommands }); | |
| 4518 | + const spawner = ChildProcessSpawner.make((command) => { | |
| 4519 | + const value = command as unknown as { readonly command: string }; | |
| 4520 | + return value.command === "taskkill" | |
| 4521 | + ? taskkillSpawner.spawn(command) | |
| 4522 | + : childProcessSpawner.spawn(command); | |
| 4523 | + }); | |
| 4524 | + const context = yield* Layer.build( | |
| 4525 | + AcpSessionRuntime.layer({ | |
| 4526 | + spawn: { | |
| 4527 | + command: process.execPath, | |
| 4528 | + args: [mockAgentPath], | |
| 4529 | + cwd: process.cwd(), | |
| 4530 | + env: { T3_ACP_SESSION_LIFECYCLE: "1" }, | |
| 4531 | + }, | |
| 4532 | + cwd: process.cwd(), | |
| 4533 | + clientInfo: { name: "t3-acp-test", version: "0.0.0" }, | |
| 4534 | + ownDetachedProcessGroup: true, | |
| 4535 | + processGroupPlatform: "win32", | |
| 4536 | + }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))), | |
| 4537 | + ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); | |
| 4538 | + const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | |
| 4539 | + Effect.provide(context), | |
| 4540 | + ); | |
| 4541 | + assert.isDefined(runtime.terminateProcessGroup); | |
| 4542 | + yield* Effect.all([runtime.terminateProcessGroup!, runtime.terminateProcessGroup!], { | |
| 4543 | + concurrency: "unbounded", | |
| 4544 | + }); | |
| 4545 | + assert.equal(taskkillCommands.length, 1); | |
| 4546 | + yield* Scope.close(runtimeScope, Exit.void); | |
| 4547 | + assert.equal(taskkillCommands.length, 1); | |
| 4548 | + }).pipe(Effect.provide(testLayer)), | |
| 4549 | + ); | |
| 4550 | + | |
| 4551 | + it("accepts only taskkill exit code zero as successful tree termination", () => { | |
| 4552 | + assert.isTrue(AcpSessionRuntime.windowsTaskkillResultIsSuccess(0, "")); | |
| 4553 | + assert.isFalse( | |
| 4554 | + AcpSessionRuntime.windowsTaskkillResultIsSuccess( | |
| 4555 | + 128, | |
| 4556 | + "FEHLER: Der Prozess wurde nicht gefunden.", | |
| 4557 | + ), | |
| 4558 | + ); | |
| 4559 | + assert.isFalse(AcpSessionRuntime.windowsTaskkillResultIsSuccess(128, "")); | |
| 4560 | + assert.isFalse(AcpSessionRuntime.windowsTaskkillResultIsSuccess(1, "localized failure")); | |
| 4561 | + assert.isFalse(AcpSessionRuntime.windowsTaskkillResultIsSuccess(255, "")); | |
| 4562 | + }); | |
| 4563 | + | |
| 4564 | + it.effect("runs the default taskkill path and preserves every failure mode", () => | |
| 4565 | + Effect.gen(function* () { | |
| 4566 | + const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = | |
| 4567 | + []; | |
| 4568 | + yield* AcpSessionRuntime.terminateWindowsProcessTreeWithTaskkill( | |
| 4569 | + makeTaskkillSpawner({ exitCode: 0, commands }), | |
| 4570 | + 4321, | |
| 4571 | + ); | |
| 4572 | + assert.deepEqual(commands, [{ command: "taskkill", args: ["/PID", "4321", "/T", "/F"] }]); | |
| 4573 | + | |
| 4574 | + for (const fixture of [ | |
| 4575 | + { exitCode: 128, output: 'ERROR: The process "4321" not found.' }, | |
| 4576 | + { exitCode: 128, output: "FEHLER: Prozess nicht gefunden." }, | |
| 4577 | + { exitCode: 128, output: "" }, | |
| 4578 | + { exitCode: 128, output: "ERROR: Access is denied." }, | |
| 4579 | + { exitCode: 1, output: "generic failure" }, | |
| 4580 | + ]) { | |
| 4581 | + const failed = yield* AcpSessionRuntime.terminateWindowsProcessTreeWithTaskkill( | |
| 4582 | + makeTaskkillSpawner(fixture), | |
| 4583 | + 4321, | |
| 4584 | + ).pipe(Effect.exit); | |
| 4585 | + if (Exit.isSuccess(failed)) assert.fail(`taskkill ${fixture.exitCode} must fail`); | |
| 4586 | + const error = Cause.squash(failed.cause); | |
| 4587 | + assert.instanceOf(error, AcpSessionRuntime.AcpProcessGroupTerminationError); | |
| 4588 | + const termination = error as AcpSessionRuntime.AcpProcessGroupTerminationError; | |
| 4589 | + assert.equal( | |
| 4590 | + termination.detail, | |
| 4591 | + `taskkill exited ${fixture.exitCode} for ACP process tree 4321`, | |
| 4592 | + ); | |
| 4593 | + assert.equal(termination.pid, 4321); | |
| 4594 | + assert.equal(termination.exitCode, fixture.exitCode); | |
| 4595 | + if (fixture.output.length > 0) { | |
| 4596 | + assert.equal(termination.cause, fixture.output); | |
| 4597 | + } | |
| 4598 | + } | |
| 4599 | + | |
| 4600 | + for (const fixture of [ | |
| 4601 | + { spawnFailure: true }, | |
| 4602 | + { outputFailure: true }, | |
| 4603 | + { exitFailure: true }, | |
| 4604 | + ]) { | |
| 4605 | + const failed = yield* AcpSessionRuntime.terminateWindowsProcessTreeWithTaskkill( | |
| 4606 | + makeTaskkillSpawner(fixture), | |
| 4607 | + 4321, | |
| 4608 | + ).pipe(Effect.exit); | |
| 4609 | + if (Exit.isSuccess(failed)) assert.fail("taskkill infrastructure failure must fail"); | |
| 4610 | + const error = Cause.squash(failed.cause); | |
| 4611 | + assert.instanceOf(error, AcpSessionRuntime.AcpProcessGroupTerminationError); | |
| 4612 | + assert.equal( | |
| 4613 | + (error as AcpSessionRuntime.AcpProcessGroupTerminationError).detail, | |
| 4614 | + "Failed to run taskkill for ACP process tree 4321", | |
| 4615 | + ); | |
| 4616 | + } | |
| 4617 | + }), | |
| 4618 | + ); | |
| 4619 | + | |
| 4620 | + it.effect("Windows process-tree teardown surfaces taskkill failure", () => | |
| 4621 | + Effect.gen(function* () { | |
| 4622 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 4623 | + const path = yield* Path.Path; | |
| 4624 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 4625 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 4626 | + ); | |
| 4627 | + const context = yield* Layer.build( | |
| 4628 | + AcpSessionRuntime.layer({ | |
| 4629 | + spawn: { command: process.execPath, args: [mockAgentPath], cwd: process.cwd() }, | |
| 4630 | + cwd: process.cwd(), | |
| 4631 | + clientInfo: { name: "t3-acp-test", version: "0.0.0" }, | |
| 4632 | + ownDetachedProcessGroup: true, | |
| 4633 | + processGroupPlatform: "win32", | |
| 4634 | + windowsProcessTreeTerminator: () => | |
| 4635 | + Effect.fail( | |
| 4636 | + new AcpSessionRuntime.AcpProcessGroupTerminationError({ | |
| 4637 | + detail: "mock taskkill failure", | |
| 4638 | + }), | |
| 4639 | + ), | |
| 4640 | + }).pipe( | |
| 4641 | + Layer.provide( | |
| 4642 | + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), | |
| 4643 | + ), | |
| 4644 | + ), | |
| 4645 | + ); | |
| 4646 | + const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | |
| 4647 | + Effect.provide(context), | |
| 4648 | + ); | |
| 4649 | + const error = yield* Effect.flip(runtime.terminateProcessGroup!); | |
| 4650 | + assert.equal(error.detail, "mock taskkill failure"); | |
| 4651 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 4652 | + ); | |
| 4653 | + | |
| 4654 | + it.live("poisons the session when hard teardown defects and blocks replacement work", () => | |
| 4655 | + Effect.gen(function* () { | |
| 4656 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 4657 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 4658 | + const idAllocator = yield* IdAllocatorV2; | |
| 4659 | + const path = yield* Path.Path; | |
| 4660 | + const serverConfig = yield* ServerConfig; | |
| 4661 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 4662 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 4663 | + ); | |
| 4664 | + const commandPidPath = yield* fileSystem.makeTempFileScoped({ | |
| 4665 | + prefix: "t3-acp-failed-teardown-command-", | |
| 4666 | + }); | |
| 4667 | + const residualCallbackDir = yield* fileSystem.makeTempDirectoryScoped(); | |
| 4668 | + const residualCallbackResponseLogPath = path.join(residualCallbackDir, "responses.log"); | |
| 4669 | + const residualCallbackTriggerPath = path.join(residualCallbackDir, "trigger"); | |
| 4670 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 4671 | + const continuationRequests: Array<ProviderContinuationRequest> = []; | |
| 4672 | + const teardownStarted = yield* Deferred.make<void>(); | |
| 4673 | + const releaseTeardown = yield* Deferred.make<void>(); | |
| 4674 | + let terminatorCallCount = 0; | |
| 4675 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 4676 | + const adapter = makeAcpAdapterV2({ | |
| 4677 | + instanceId, | |
| 4678 | + flavor: { | |
| 4679 | + driver: ACP_TEST_DRIVER, | |
| 4680 | + capabilities: AcpProviderCapabilitiesV2, | |
| 4681 | + enablePostSettleContinuation: true, | |
| 4682 | + restartRuntimeAfterInterrupt: true, | |
| 4683 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 4684 | + makeRuntime: makeMockRuntime({ | |
| 4685 | + childProcessSpawner, | |
| 4686 | + mockAgentPath, | |
| 4687 | + ownDetachedProcessGroup: true, | |
| 4688 | + processGroupPlatform: "win32", | |
| 4689 | + windowsProcessTreeTerminator: () => | |
| 4690 | + Effect.gen(function* () { | |
| 4691 | + terminatorCallCount += 1; | |
| 4692 | + yield* Deferred.succeed(teardownStarted, undefined); | |
| 4693 | + yield* Deferred.await(releaseTeardown); | |
| 4694 | + return yield* Effect.die("mock taskkill defect"); | |
| 4695 | + }), | |
| 4696 | + environment: { | |
| 4697 | + T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG: "1", | |
| 4698 | + T3_ACP_RESIDUAL_CALLBACK_RESPONSE_LOG_PATH: residualCallbackResponseLogPath, | |
| 4699 | + T3_ACP_RESIDUAL_CALLBACK_TRIGGER_PATH: residualCallbackTriggerPath, | |
| 4700 | + T3_ACP_RUNNING_COMMAND_PID_PATH: commandPidPath, | |
| 4701 | + }, | |
| 4702 | + protocolEvents, | |
| 4703 | + }), | |
| 4704 | + }, | |
| 4705 | + fileSystem, | |
| 4706 | + idAllocator, | |
| 4707 | + serverConfig, | |
| 4708 | + continuationRequests: { | |
| 4709 | + offer: (request) => | |
| 4710 | + Effect.sync(() => { | |
| 4711 | + continuationRequests.push(request); | |
| 4712 | + }), | |
| 4713 | + }, | |
| 4714 | + }); | |
| 4715 | + const threadId = ThreadId.make("thread-acp-failed-hard-teardown"); | |
| 4716 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 4717 | + runtimeMode: "full-access", | |
| 4718 | + interactionMode: "default", | |
| 4719 | + cwd: process.cwd(), | |
| 4720 | + }); | |
| 4721 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 4722 | + const sessionScope = yield* Scope.make(); | |
| 4723 | + const runtime = yield* adapter | |
| 4724 | + .openSession({ | |
| 4725 | + threadId, | |
| 4726 | + providerSessionId: ProviderSessionId.make("provider-session-acp-failed-hard-teardown"), | |
| 4727 | + modelSelection, | |
| 4728 | + runtimePolicy, | |
| 4729 | + }) | |
| 4730 | + .pipe(Effect.provideService(Scope.Scope, sessionScope)); | |
| 4731 | + const adapterEvents = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 4732 | + yield* runtime.events.pipe( | |
| 4733 | + Stream.runForEach((event) => Queue.offer(adapterEvents, event)), | |
| 4734 | + Effect.forkIn(sessionScope), | |
| 4735 | + ); | |
| 4736 | + const providerThread = yield* runtime.ensureThread({ | |
| 4737 | + threadId, | |
| 4738 | + modelSelection, | |
| 4739 | + runtimePolicy, | |
| 4740 | + }); | |
| 4741 | + const now = yield* DateTime.now; | |
| 4742 | + yield* runtime | |
| 4743 | + .startTurn( | |
| 4744 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | |
| 4745 | + ) | |
| 4746 | + .pipe(Effect.forkScoped); | |
| 4747 | + while ((yield* fileSystem.readFileString(commandPidPath)).trim().length === 0) { | |
| 4748 | + yield* Effect.yieldNow; | |
| 4749 | + } | |
| 4750 | + const [commandRootPid, commandSleepPid] = (yield* fileSystem.readFileString(commandPidPath)) | |
| 4751 | + .trim() | |
| 4752 | + .split(/\s+/) | |
| 4753 | + .map(Number); | |
| 4754 | + assert.isTrue( | |
| 4755 | + Option.isSome(yield* waitForProcesses([commandRootPid!, commandSleepPid!])), | |
| 4756 | + "declared failed-teardown Bash and sleep PIDs must both become live", | |
| 4757 | + ); | |
| 4758 | + yield* pollProtocolMethods(protocolEvents); | |
| 4759 | + | |
| 4760 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 4761 | + driver: ACP_TEST_DRIVER, | |
| 4762 | + nativeTurnId: "mock-session-1:turn:1", | |
| 4763 | + }); | |
| 4764 | + const interruptFiber = yield* runtime | |
| 4765 | + .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }) | |
| 4766 | + .pipe(Effect.exit, Effect.forkScoped); | |
| 4767 | + yield* Deferred.await(teardownStarted); | |
| 4768 | + const startFiber = yield* runtime | |
| 4769 | + .startTurn( | |
| 4770 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), | |
| 4771 | + ) | |
| 4772 | + .pipe(Effect.exit, Effect.forkScoped); | |
| 4773 | + yield* Effect.yieldNow; | |
| 4774 | + yield* Effect.yieldNow; | |
| 4775 | + assert.isUndefined(startFiber.pollUnsafe()); | |
| 4776 | + const methodsDuringTeardown = yield* pollProtocolMethods(protocolEvents); | |
| 4777 | + yield* Deferred.succeed(releaseTeardown, undefined); | |
| 4778 | + assert.notInclude(methodsDuringTeardown, "session/load"); | |
| 4779 | + assert.notInclude(methodsDuringTeardown, "session/prompt"); | |
| 4780 | + | |
| 4781 | + const interruptExit = yield* Fiber.join(interruptFiber); | |
| 4782 | + if (Exit.isSuccess(interruptExit)) assert.fail("hard teardown failure must fail interrupt"); | |
| 4783 | + assert.include(Cause.pretty(interruptExit.cause), "session is poisoned"); | |
| 4784 | + assert.isTrue( | |
| 4785 | + Option.isSome(yield* waitForProcesses([commandRootPid!, commandSleepPid!])), | |
| 4786 | + "failed teardown must leave both declared Bash and sleep PIDs live", | |
| 4787 | + ); | |
| 4788 | + | |
| 4789 | + while (Option.isSome(yield* Queue.poll(adapterEvents))) { | |
| 4790 | + // Discard the interrupted turn's expected projection before residual traffic. | |
| 4791 | + } | |
| 4792 | + yield* fileSystem.writeFileString(residualCallbackTriggerPath, "go"); | |
| 4793 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 4794 | + Stream.filter( | |
| 4795 | + (event) => | |
| 4796 | + event.direction === "incoming" && | |
| 4797 | + event.stage === "raw" && | |
| 4798 | + typeof event.payload === "string" && | |
| 4799 | + event.payload.includes('"method":"session/elicitation"'), | |
| 4800 | + ), | |
| 4801 | + Stream.runHead, | |
| 4802 | + ); | |
| 4803 | + yield* Effect.sleep("100 millis"); | |
| 4804 | + assert.isTrue( | |
| 4805 | + Option.isNone(yield* Queue.poll(adapterEvents)), | |
| 4806 | + "residual callbacks must not mutate or emit adapter projection after poison", | |
| 4807 | + ); | |
| 4808 | + assert.isFalse( | |
| 4809 | + yield* fileSystem.exists(residualCallbackResponseLogPath), | |
| 4810 | + "permission and elicitation callbacks must remain unresolved after poison", | |
| 4811 | + ); | |
| 4812 | + assert.lengthOf( | |
| 4813 | + continuationRequests, | |
| 4814 | + 0, | |
| 4815 | + "residual callbacks must not offer a continuation after poison", | |
| 4816 | + ); | |
| 4817 | + | |
| 4818 | + const startExit = yield* Fiber.join(startFiber); | |
| 4819 | + if (Exit.isSuccess(startExit)) assert.fail("poisoned session must reject startTurn"); | |
| 4820 | + assert.include(Cause.pretty(startExit.cause), "session is poisoned"); | |
| 4821 | + const resumeExit = yield* runtime | |
| 4822 | + .resumeThread({ providerThread, modelSelection, runtimePolicy }) | |
| 4823 | + .pipe(Effect.exit); | |
| 4824 | + if (Exit.isSuccess(resumeExit)) assert.fail("poisoned session must reject resumeThread"); | |
| 4825 | + assert.include(Cause.pretty(resumeExit.cause), "session is poisoned"); | |
| 4826 | + const snapshotExit = yield* runtime.readThreadSnapshot({ providerThread }).pipe(Effect.exit); | |
| 4827 | + if (Exit.isSuccess(snapshotExit)) { | |
| 4828 | + assert.fail("poisoned session must reject readThreadSnapshot"); | |
| 4829 | + } | |
| 4830 | + assert.include(Cause.pretty(snapshotExit.cause), "session is poisoned"); | |
| 4831 | + const forkExit = yield* runtime | |
| 4832 | + .forkThread({ | |
| 4833 | + sourceProviderThread: providerThread, | |
| 4834 | + targetThreadId: ThreadId.make("thread-acp-poisoned-fork"), | |
| 4835 | + }) | |
| 4836 | + .pipe(Effect.exit); | |
| 4837 | + if (Exit.isSuccess(forkExit)) assert.fail("poisoned session must reject forkThread"); | |
| 4838 | + assert.include(Cause.pretty(forkExit.cause), "session is poisoned"); | |
| 4839 | + yield* pollProtocolMethods(protocolEvents); | |
| 4840 | + const retryInterruptExit = yield* runtime | |
| 4841 | + .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }) | |
| 4842 | + .pipe(Effect.exit); | |
| 4843 | + if (Exit.isSuccess(retryInterruptExit)) { | |
| 4844 | + assert.fail("poisoned session must reject a repeated hard interrupt"); | |
| 4845 | + } | |
| 4846 | + const firstInterruptError = Cause.squash(interruptExit.cause) as Error; | |
| 4847 | + const retryInterruptError = Cause.squash(retryInterruptExit.cause) as Error; | |
| 4848 | + assert.strictEqual(retryInterruptError.cause, firstInterruptError.cause); | |
| 4849 | + assert.equal(terminatorCallCount, 1); | |
| 4850 | + const methodsAfterPoison = yield* pollProtocolMethods(protocolEvents); | |
| 4851 | + assert.notInclude(methodsAfterPoison, "initialize"); | |
| 4852 | + assert.notInclude(methodsAfterPoison, "session/cancel"); | |
| 4853 | + assert.notInclude(methodsAfterPoison, "session/fork"); | |
| 4854 | + assert.notInclude(methodsAfterPoison, "session/load"); | |
| 4855 | + assert.notInclude(methodsAfterPoison, "session/prompt"); | |
| 4856 | + assert.isTrue(processExists(commandRootPid!)); | |
| 4857 | + assert.isTrue(processExists(commandSleepPid!)); | |
| 4858 | + yield* Scope.close(sessionScope, Exit.void); | |
| 4859 | + assert.isTrue( | |
| 4860 | + Option.isSome(yield* waitForProcessesToExit([commandRootPid!, commandSleepPid!])), | |
| 4861 | + "finalizer cleanup must reap both declared Bash and sleep PIDs", | |
| 4862 | + ); | |
| 4863 | + assert.isFalse(processExists(commandRootPid!)); | |
| 4864 | + assert.isFalse(processExists(commandSleepPid!)); | |
| 4865 | + const finalizerMethods = yield* pollProtocolMethods(protocolEvents); | |
| 4866 | + assert.notInclude(finalizerMethods, "session/close"); | |
| 4867 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 4868 | + ); | |
| 4869 | + | |
| 4870 | + it.live("durably poisons start and resume when required hard teardown is unavailable", () => | |
| 4871 | + Effect.gen(function* () { | |
| 4872 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 4873 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 4874 | + const idAllocator = yield* IdAllocatorV2; | |
| 4875 | + const path = yield* Path.Path; | |
| 4876 | + const serverConfig = yield* ServerConfig; | |
| 4877 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 4878 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 4879 | + ); | |
| 4880 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 4881 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 4882 | + const adapter = makeAcpAdapterV2({ | |
| 4883 | + instanceId, | |
| 4884 | + flavor: { | |
| 4885 | + driver: ACP_TEST_DRIVER, | |
| 4886 | + capabilities: AcpProviderCapabilitiesV2, | |
| 4887 | + restartRuntimeAfterInterrupt: true, | |
| 4888 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 4889 | + makeRuntime: makeMockRuntime({ | |
| 4890 | + childProcessSpawner, | |
| 4891 | + mockAgentPath, | |
| 4892 | + environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, | |
| 4893 | + protocolEvents, | |
| 4894 | + }), | |
| 4895 | + }, | |
| 4896 | + fileSystem, | |
| 4897 | + idAllocator, | |
| 4898 | + serverConfig, | |
| 4899 | + }); | |
| 4900 | + const threadId = ThreadId.make("thread-acp-missing-hard-teardown"); | |
| 4901 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 4902 | + runtimeMode: "full-access", | |
| 4903 | + interactionMode: "default", | |
| 4904 | + cwd: process.cwd(), | |
| 4905 | + }); | |
| 4906 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 4907 | + const runtime = yield* adapter.openSession({ | |
| 4908 | + threadId, | |
| 4909 | + providerSessionId: ProviderSessionId.make("provider-session-acp-missing-hard-teardown"), | |
| 4910 | + modelSelection, | |
| 4911 | + runtimePolicy, | |
| 4912 | + }); | |
| 4913 | + const providerThread = yield* runtime.ensureThread({ | |
| 4914 | + threadId, | |
| 4915 | + modelSelection, | |
| 4916 | + runtimePolicy, | |
| 4917 | + }); | |
| 4918 | + const now = yield* DateTime.now; | |
| 4919 | + yield* runtime | |
| 4920 | + .startTurn( | |
| 4921 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | |
| 4922 | + ) | |
| 4923 | + .pipe(Effect.forkScoped); | |
| 4924 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 4925 | + Stream.filter( | |
| 4926 | + (event) => | |
| 4927 | + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | |
| 4928 | + ), | |
| 4929 | + Stream.runHead, | |
| 4930 | + ); | |
| 4931 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 4932 | + driver: ACP_TEST_DRIVER, | |
| 4933 | + nativeTurnId: "mock-session-1:turn:1", | |
| 4934 | + }); | |
| 4935 | + const interruptExit = yield* runtime | |
| 4936 | + .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }) | |
| 4937 | + .pipe(Effect.exit); | |
| 4938 | + if (Exit.isSuccess(interruptExit)) assert.fail("missing hard teardown must fail interrupt"); | |
| 4939 | + assert.include(Cause.pretty(interruptExit.cause), "session is poisoned"); | |
| 4940 | + | |
| 4941 | + const startExit = yield* runtime | |
| 4942 | + .startTurn( | |
| 4943 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), | |
| 4944 | + ) | |
| 4945 | + .pipe(Effect.exit); | |
| 4946 | + if (Exit.isSuccess(startExit)) assert.fail("poisoned session must reject startTurn"); | |
| 4947 | + assert.include(Cause.pretty(startExit.cause), "session is poisoned"); | |
| 4948 | + const resumeExit = yield* runtime | |
| 4949 | + .resumeThread({ providerThread, modelSelection, runtimePolicy }) | |
| 4950 | + .pipe(Effect.exit); | |
| 4951 | + if (Exit.isSuccess(resumeExit)) assert.fail("poisoned session must reject resumeThread"); | |
| 4952 | + assert.include(Cause.pretty(resumeExit.cause), "session is poisoned"); | |
| 4953 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 4954 | + ); | |
| 4955 | + | |
| 4956 | + it.live("holds concurrent startTurn behind successful hard teardown and reloads once", () => | |
| 4957 | + Effect.gen(function* () { | |
| 4958 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 4959 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 4960 | + const idAllocator = yield* IdAllocatorV2; | |
| 4961 | + const path = yield* Path.Path; | |
| 4962 | + const serverConfig = yield* ServerConfig; | |
| 4963 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 4964 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 4965 | + ); | |
| 4966 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 4967 | + const teardownStarted = yield* Deferred.make<void>(); | |
| 4968 | + const releaseTeardown = yield* Deferred.make<void>(); | |
| 4969 | + let runtimeOrdinalSeen = 0; | |
| 4970 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 4971 | + const adapter = makeAcpAdapterV2({ | |
| 4972 | + instanceId, | |
| 4973 | + flavor: { | |
| 4974 | + driver: ACP_TEST_DRIVER, | |
| 4975 | + capabilities: AcpProviderCapabilitiesV2, | |
| 4976 | + interruptPromptOnCancel: true, | |
| 4977 | + restartRuntimeAfterInterrupt: true, | |
| 4978 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 4979 | + makeRuntime: makeMockRuntime({ | |
| 4980 | + childProcessSpawner, | |
| 4981 | + mockAgentPath, | |
| 4982 | + ownDetachedProcessGroup: true, | |
| 4983 | + processGroupPlatform: "win32", | |
| 4984 | + windowsProcessTreeTerminator: () => | |
| 4985 | + Deferred.succeed(teardownStarted, undefined).pipe( | |
| 4986 | + Effect.andThen(Deferred.await(releaseTeardown)), | |
| 4987 | + ), | |
| 4988 | + environment: (runtimeOrdinal) => { | |
| 4989 | + runtimeOrdinalSeen = runtimeOrdinal; | |
| 4990 | + return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {}; | |
| 4991 | + }, | |
| 4992 | + protocolEvents, | |
| 4993 | + }), | |
| 4994 | + }, | |
| 4995 | + fileSystem, | |
| 4996 | + idAllocator, | |
| 4997 | + serverConfig, | |
| 4998 | + }); | |
| 4999 | + const threadId = ThreadId.make("thread-acp-concurrent-hard-teardown"); | |
| 5000 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 5001 | + runtimeMode: "full-access", | |
| 5002 | + interactionMode: "default", | |
| 5003 | + cwd: process.cwd(), | |
| 5004 | + }); | |
| 5005 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 5006 | + const runtime = yield* adapter.openSession({ | |
| 5007 | + threadId, | |
| 5008 | + providerSessionId: ProviderSessionId.make("provider-session-acp-concurrent-hard-teardown"), | |
| 5009 | + modelSelection, | |
| 5010 | + runtimePolicy, | |
| 5011 | + }); | |
| 5012 | + const providerThread = yield* runtime.ensureThread({ | |
| 5013 | + threadId, | |
| 5014 | + modelSelection, | |
| 5015 | + runtimePolicy, | |
| 5016 | + }); | |
| 5017 | + const now = yield* DateTime.now; | |
| 5018 | + yield* runtime | |
| 5019 | + .startTurn( | |
| 5020 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | |
| 5021 | + ) | |
| 5022 | + .pipe(Effect.forkScoped); | |
| 5023 | + yield* Effect.yieldNow; | |
| 5024 | + yield* Effect.yieldNow; | |
| 5025 | + yield* Effect.yieldNow; | |
| 5026 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 5027 | + driver: ACP_TEST_DRIVER, | |
| 5028 | + nativeTurnId: "mock-session-1:turn:1", | |
| 5029 | + }); | |
| 5030 | + yield* pollProtocolMethods(protocolEvents); | |
| 5031 | + const interruptFiber = yield* runtime | |
| 5032 | + .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }) | |
| 5033 | + .pipe(Effect.forkScoped); | |
| 5034 | + yield* Deferred.await(teardownStarted); | |
| 5035 | + const startFiber = yield* runtime | |
| 5036 | + .startTurn( | |
| 5037 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), | |
| 5038 | + ) | |
| 5039 | + .pipe(Effect.forkScoped); | |
| 5040 | + yield* Effect.yieldNow; | |
| 5041 | + yield* Effect.yieldNow; | |
| 5042 | + assert.isUndefined(startFiber.pollUnsafe()); | |
| 5043 | + assert.equal(runtimeOrdinalSeen, 1); | |
| 5044 | + const methodsDuringTeardown = yield* pollProtocolMethods(protocolEvents); | |
| 5045 | + assert.notInclude(methodsDuringTeardown, "session/load"); | |
| 5046 | + assert.notInclude(methodsDuringTeardown, "session/prompt"); | |
| 5047 | + | |
| 5048 | + yield* Deferred.succeed(releaseTeardown, undefined); | |
| 5049 | + yield* Fiber.join(interruptFiber); | |
| 5050 | + yield* Fiber.join(startFiber); | |
| 5051 | + assert.equal(runtimeOrdinalSeen, 2); | |
| 5052 | + const replacementMethods = yield* pollProtocolMethods(protocolEvents); | |
| 5053 | + assert.equal(replacementMethods.filter((method) => method === "session/load").length, 1); | |
| 5054 | + if (!replacementMethods.includes("session/prompt")) { | |
| 5055 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 5056 | + Stream.filter( | |
| 5057 | + (event) => | |
| 5058 | + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | |
| 5059 | + ), | |
| 5060 | + Stream.runHead, | |
| 5061 | + ); | |
| 5062 | + } | |
| 5063 | + }).pipe(Effect.provide(testLayer), Effect.scoped), | |
| 5064 | + ); | |
| 5065 | + | |
| 5066 | + it.live("quarantines old-runtime callbacks after successful hard teardown", () => | |
| 5067 | + Effect.gen(function* () { | |
| 5068 | + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 5069 | + const fileSystem = yield* FileSystem.FileSystem; | |
| 5070 | + const idAllocator = yield* IdAllocatorV2; | |
| 5071 | + const path = yield* Path.Path; | |
| 5072 | + const serverConfig = yield* ServerConfig; | |
| 5073 | + const mockAgentPath = yield* path.fromFileUrl( | |
| 5074 | + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | |
| 5075 | + ); | |
| 5076 | + const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | |
| 5077 | + const continuationRequests: Array<ProviderContinuationRequest> = []; | |
| 5078 | + const responseLifecycle: Array<string> = []; | |
| 5079 | + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; | |
| 5080 | + type HandlerRecord = { | |
| 5081 | + sessionUpdate?: Parameters<RuntimeService["handleSessionUpdate"]>[0]; | |
| 5082 | + permission?: Parameters<RuntimeService["handleRequestPermission"]>[0]; | |
| 5083 | + elicitation?: Parameters<RuntimeService["handleElicitation"]>[0]; | |
| 5084 | + requestUserInput?: AcpAdapterV2ExtensionContext["requestUserInput"]; | |
| 5085 | + }; | |
| 5086 | + const handlerRecords: HandlerRecord[] = []; | |
| 5087 | + const runtimeInputs: AcpAdapterV2RuntimeInput[] = []; | |
| 5088 | + const oldPromptCompletion = yield* Deferred.make<EffectAcpSchema.PromptResponse>(); | |
| 5089 | + const transportDrained = yield* Deferred.make<void>(); | |
| 5090 | + const releaseTransportDrain = yield* Deferred.make<void>(); | |
| 5091 | + let extensionOrdinal = 0; | |
| 5092 | + let runtimeOrdinalSeen = 0; | |
| 5093 | + const instanceId = ProviderInstanceId.make("acp-test"); | |
| 5094 | + const makeRuntime = makeMockRuntime({ | |
| 5095 | + childProcessSpawner, | |
| 5096 | + mockAgentPath, | |
| 5097 | + ownDetachedProcessGroup: true, | |
| 5098 | + processGroupPlatform: "win32", | |
| 5099 | + windowsProcessTreeTerminator: () => Effect.void, | |
| 5100 | + environment: (runtimeOrdinal) => { | |
| 5101 | + runtimeOrdinalSeen = runtimeOrdinal; | |
| 5102 | + return { T3_ACP_HANG_PROMPT_FOREVER: "1" }; | |
| 5103 | + }, | |
| 5104 | + protocolEvents, | |
| 5105 | + wrapRuntime: (runtime, runtimeOrdinal) => { | |
| 5106 | + const record: HandlerRecord = {}; | |
| 5107 | + handlerRecords[runtimeOrdinal - 1] = record; | |
| 5108 | + return { | |
| 5109 | + ...runtime, | |
| 5110 | + handleSessionUpdate: (handler) => | |
| 5111 | + Effect.sync(() => { | |
| 5112 | + record.sessionUpdate = handler; | |
| 5113 | + }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), | |
| 5114 | + handleRequestPermission: (handler) => | |
| 5115 | + Effect.sync(() => { | |
| 5116 | + record.permission = handler; | |
| 5117 | + }).pipe(Effect.andThen(runtime.handleRequestPermission(handler))), | |
| 5118 | + handleElicitation: (handler) => | |
| 5119 | + Effect.sync(() => { | |
| 5120 | + record.elicitation = handler; | |
| 5121 | + }).pipe(Effect.andThen(runtime.handleElicitation(handler))), | |
| 5122 | + ...(runtimeOrdinal === 1 | |
| 5123 | + ? { | |
| 5124 | + prompt: (payload) => | |
| 5125 | + Effect.gen(function* () { | |
| 5126 | + yield* runtime.prompt(payload).pipe(Effect.ignore, Effect.forkDetach); | |
| 5127 | + return yield* Deferred.await(oldPromptCompletion); | |
| 5128 | + }), | |
| 5129 | + } | |
| 5130 | + : {}), | |
| 5131 | + }; | |
| 5132 | + }, | |
| 5133 | + }); | |
| 5134 | + const adapter = makeAcpAdapterV2({ | |
| 5135 | + instanceId, | |
| 5136 | + flavor: { | |
| 5137 | + driver: ACP_TEST_DRIVER, | |
| 5138 | + capabilities: AcpProviderCapabilitiesV2, | |
| 5139 | + enablePostSettleContinuation: true, | |
| 5140 | + registerExtensions: ({ requestUserInput }) => | |
| 5141 | + Effect.sync(() => { | |
| 5142 | + handlerRecords[extensionOrdinal++]!.requestUserInput = requestUserInput; | |
| 5143 | + }), | |
| 5144 | + restartRuntimeAfterInterrupt: true, | |
| 5145 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 5146 | + makeRuntime: (runtimeInput) => | |
| 5147 | + Effect.sync(() => { | |
| 5148 | + runtimeInputs.push(runtimeInput); | |
| 5149 | + }).pipe(Effect.andThen(makeRuntime(runtimeInput))), | |
| 5150 | + }, | |
| 5151 | + fileSystem, | |
| 5152 | + idAllocator, | |
| 5153 | + serverConfig, | |
| 5154 | + testHooks: { | |
| 5155 | + afterHardTeardownTransportDrained: () => | |
| 5156 | + Deferred.succeed(transportDrained, undefined).pipe( | |
| 5157 | + Effect.andThen(Deferred.await(releaseTransportDrain)), | |
| 5158 | + ), | |
| 5159 | + onNativeResponseLifecycle: (event) => | |
| 5160 | + Effect.sync(() => { | |
| 5161 | + responseLifecycle.push(event.type); | |
| 5162 | + }), | |
| 5163 | + }, | |
| 5164 | + continuationRequests: { | |
| 5165 | + offer: (request) => | |
| 5166 | + Effect.sync(() => { | |
| 5167 | + continuationRequests.push(request); | |
| 5168 | + }), | |
| 5169 | + }, | |
| 5170 | + }); | |
| 5171 | + const threadId = ThreadId.make("thread-acp-successful-teardown-callback-quarantine"); | |
| 5172 | + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 5173 | + runtimeMode: "full-access", | |
| 5174 | + interactionMode: "default", | |
| 5175 | + cwd: process.cwd(), | |
| 5176 | + }); | |
| 5177 | + const modelSelection = { instanceId, model: "default" } as const; | |
| 5178 | + const runtime = yield* adapter.openSession({ | |
| 5179 | + threadId, | |
| 5180 | + providerSessionId: ProviderSessionId.make( | |
| 5181 | + "provider-session-acp-successful-teardown-callback-quarantine", | |
| 5182 | + ), | |
| 5183 | + modelSelection, | |
| 5184 | + runtimePolicy, | |
| 5185 | + }); | |
| 5186 | + const adapterEvents = yield* Queue.unbounded<ProviderAdapterV2Event>(); | |
| 5187 | + yield* runtime.events.pipe( | |
| 5188 | + Stream.runForEach((event) => Queue.offer(adapterEvents, event)), | |
| 5189 | + Effect.forkScoped, | |
| 5190 | + ); | |
| 5191 | + const providerThread = yield* runtime.ensureThread({ | |
| 5192 | + threadId, | |
| 5193 | + modelSelection, | |
| 5194 | + runtimePolicy, | |
| 5195 | + }); | |
| 5196 | + const now = yield* DateTime.now; | |
| 5197 | + yield* runtime | |
| 5198 | + .startTurn( | |
| 5199 | + makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | |
| 5200 | + ) | |
| 5201 | + .pipe(Effect.forkScoped); | |
| 5202 | + yield* Stream.fromQueue(protocolEvents).pipe( | |
| 5203 | + Stream.filter( | |
| 5204 | + (event) => | |
| 5205 | + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | |
| 5206 | + ), | |
| 5207 | + Stream.runHead, | |
| 5208 | + ); | |
| 5209 | + const providerTurnId = idAllocator.derive.providerTurn({ | |
| 5210 | + driver: ACP_TEST_DRIVER, | |
| 5211 | + nativeTurnId: "mock-session-1:turn:1", | |
| 5212 | + }); | |
| 5213 | + const permissionRequest = { | |
| 5214 | + sessionId: "mock-session-1", | |
| 5215 | + toolCall: { | |
| 5216 | + toolCallId: "stale-generation-1-permission", | |
| 5217 | + title: "Stale generation 1 permission", | |
| 5218 | + }, | |
| 5219 | + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" as const }], | |
| 5220 | + }; | |
| 5221 | + const interruptFiber = yield* runtime | |
| 5222 | + .interruptTurn({ | |
| 5223 | + providerThread, | |
1396 more lines aren’t shown here.
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | type ProviderInstanceId, |
| 19 | 19 | type ProviderDriverKind, |
| 20 | 20 | type ProviderRequestKind, |
| 21 | + type ProviderThreadId, | |
| 21 | 22 | type ProviderUserInputAnswers, |
| 22 | 23 | type RuntimeRequestId, |
| 23 | 24 | type ThreadId, |
| @@ -27,11 +28,14 @@ import * as Cause from "effect/Cause"; · 3 unmodified lines | ||
| 27 | 28 | import * as DateTime from "effect/DateTime"; |
| 28 | 29 | import * as Deferred from "effect/Deferred"; |
| 29 | 30 | import * as Effect from "effect/Effect"; |
| 31 | +import * as Exit from "effect/Exit"; | |
| 30 | 32 | import * as FileSystem from "effect/FileSystem"; |
| 33 | +import * as Fiber from "effect/Fiber"; | |
| 31 | 34 | import * as Option from "effect/Option"; |
| 32 | 35 | import * as Queue from "effect/Queue"; |
| 33 | 36 | import * as Ref from "effect/Ref"; |
| 34 | −import type * as Scope from "effect/Scope"; | |
| 37 | +import * as Scope from "effect/Scope"; | |
| 38 | +import * as Semaphore from "effect/Semaphore"; | |
| 35 | 39 | import * as Stream from "effect/Stream"; |
| 36 | 40 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 37 | 41 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| @@ -52,6 +56,7 @@ import type { · 14 unmodified lines | ||
| 52 | 56 | } from "../../provider/acp/AcpSessionRuntime.ts"; |
| 53 | 57 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; |
| 54 | 58 | import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts"; |
| 59 | +import { type ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; | |
| 55 | 60 | import { makeProviderFailure } from "../ProviderFailure.ts"; |
| 56 | 61 | import { acpSelectionTransition } from "../ProviderSelectionTransition.ts"; |
| 57 | 62 | import { |
| @@ -87,10 +92,15 @@ export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; · 29 unmodified lines | ||
| 87 | 92 | export interface AcpAdapterV2RuntimeInput { |
| 88 | 93 | readonly cwd: string; |
| 89 | 94 | readonly mcpServers: ReadonlyArray<EffectAcpSchema.McpServer>; |
| 95 | + readonly interruptPromptOnCancel?: boolean; | |
| 90 | 96 | readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"]; |
| 91 | 97 | readonly clientInfo: AcpSessionRuntimeOptions["clientInfo"]; |
| 92 | 98 | readonly requestLogger?: NonNullable<AcpSessionRuntimeOptions["requestLogger"]>; |
| 93 | 99 | readonly protocolLogging: NonNullable<AcpSessionRuntimeOptions["protocolLogging"]>; |
| 100 | + readonly onIncomingRequest?: AcpSessionRuntimeOptions["onIncomingRequest"]; | |
| 101 | + readonly onTermination: NonNullable<AcpSessionRuntimeOptions["onTermination"]>; | |
| 102 | + readonly onOutgoingResponseFailure?: AcpSessionRuntimeOptions["onOutgoingResponseFailure"]; | |
| 103 | + readonly onOutgoingResponse?: AcpSessionRuntimeOptions["onOutgoingResponse"]; | |
| 94 | 104 | } |
| 95 | 105 | |
| 96 | 106 | export type AcpAdapterV2NativeLogging = Pick< |
| @@ -100,15 +110,79 @@ export type AcpAdapterV2NativeLogging = Pick< · 3 unmodified lines | ||
| 100 | 110 | |
| 101 | 111 | export interface AcpAdapterV2UserInputRequest { |
| 102 | 112 | readonly nativeItemId: string; |
| 113 | + readonly nativeMethod?: string; | |
| 103 | 114 | readonly nativeRequestId: string; |
| 115 | + readonly nativeSessionId?: string; | |
| 104 | 116 | readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>; |
| 105 | 117 | } |
| 106 | 118 | |
| 107 | 119 | export interface AcpAdapterV2ExtensionContext { |
| 108 | 120 | readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; |
| 109 | − readonly requestUserInput: ( | |
| 110 | − input: AcpAdapterV2UserInputRequest, | |
| 111 | − ) => Effect.Effect<ProviderUserInputAnswers | null, EffectAcpErrors.AcpError>; | |
| 121 | + /** | |
| 122 | + * Session-scoped background-task lifecycle reported via extension | |
| 123 | + * notifications (e.g. Grok `_x.ai/task_backgrounded` after a soft cancel | |
| 124 | + * detaches foreground work). Mutations for non-root sessions are ignored. | |
| 125 | + */ | |
| 126 | + readonly applyBackgroundTaskMutation: (mutation: { | |
| 127 | + readonly sessionId: string; | |
| 128 | + readonly taskId: string; | |
| 129 | + readonly status: "running" | "completed" | "failed"; | |
| 130 | + }) => Effect.Effect<void>; | |
| 131 | + readonly requestUserInput: (input: AcpAdapterV2UserInputRequest) => Effect.Effect< | |
| 132 | + { | |
| 133 | + readonly acknowledgeNativeResponse: Effect.Effect<void, EffectAcpErrors.AcpError>; | |
| 134 | + readonly answers: ProviderUserInputAnswers | null; | |
| 135 | + }, | |
| 136 | + EffectAcpErrors.AcpError | |
| 137 | + >; | |
| 138 | +} | |
| 139 | + | |
| 140 | +export interface AcpRootTurnIdleSnapshot { | |
| 141 | + readonly finalized: boolean; | |
| 142 | + readonly interrupted: boolean; | |
| 143 | + readonly assistantStreamOpen: boolean; | |
| 144 | + readonly reasoningStreamOpen: boolean; | |
| 145 | + readonly hasRunningTool: boolean; | |
| 146 | + readonly hasPendingRuntimeRequest: boolean; | |
| 147 | + readonly hasToolHistory: boolean; | |
| 148 | + readonly hasRunningSubagent: boolean; | |
| 149 | + readonly hasOutput: boolean; | |
| 150 | +} | |
| 151 | + | |
| 152 | +/** | |
| 153 | + * Debounce used if a flavor re-enables speculative idle settlement. | |
| 154 | + * Kept for tests and future root-matched recovery; Grok no longer idle-settles. | |
| 155 | + */ | |
| 156 | +export const acpRootTurnSettleDebounceMs = 2_000; | |
| 157 | + | |
| 158 | +/** Let trailing root session chunks land before terminalizing a settled turn. */ | |
| 159 | +export const acpRootTurnCompletionDrainMs = 100; | |
| 160 | + | |
| 161 | +/** | |
| 162 | + * True when root-session streaming is quiescent enough for speculative settle. | |
| 163 | + * | |
| 164 | + * Always false today: settling on "assistant text then quiet" over-settles Grok | |
| 165 | + * preamble-before-tools turns, and settling after tools drops later tool waves | |
| 166 | + * while `session/prompt` is still open. Terminalize from the prompt RPC (or a | |
| 167 | + * future root-matched completion signal), not from local silence. | |
| 168 | + */ | |
| 169 | +export function acpRootTurnIsIdle(snapshot: AcpRootTurnIdleSnapshot): boolean { | |
| 170 | + if (snapshot.finalized || snapshot.interrupted) return false; | |
| 171 | + if (snapshot.assistantStreamOpen || snapshot.reasoningStreamOpen) return false; | |
| 172 | + if (snapshot.hasRunningTool || snapshot.hasPendingRuntimeRequest) return false; | |
| 173 | + if (snapshot.hasRunningSubagent) return false; | |
| 174 | + if (!snapshot.hasOutput) return false; | |
| 175 | + // Structural gates above stay for unit tests / future re-enable. Speculative | |
| 176 | + // idle completion is intentionally disabled. | |
| 177 | + return false; | |
| 178 | +} | |
| 179 | + | |
| 180 | +/** True when idle settle should be (re-)scheduled after pending runtime work clears. */ | |
| 181 | +export function acpRootTurnShouldRearmRecoveryTimers(context: { | |
| 182 | + readonly finalized: boolean; | |
| 183 | + readonly interrupted: boolean; | |
| 184 | +}): boolean { | |
| 185 | + return !context.finalized && !context.interrupted; | |
| 112 | 186 | } |
| 113 | 187 | |
| 114 | 188 | export interface AcpAdapterV2Flavor { |
| @@ -128,7 +202,117 @@ export interface AcpAdapterV2Flavor { · 13 unmodified lines | ||
| 128 | 202 | readonly extractSubagentUpdate?: ( |
| 129 | 203 | toolCall: AcpToolCallState, |
| 130 | 204 | ) => AcpAdapterV2SubagentUpdate | undefined; |
| 205 | + /** | |
| 206 | + * Optional Grok-style rewrite before tool projection (e.g. keep monitor start | |
| 207 | + * ACKs in the running state until stream end). | |
| 208 | + */ | |
| 209 | + readonly normalizeToolCall?: (toolCall: AcpToolCallState) => AcpToolCallState; | |
| 210 | + /** | |
| 211 | + * Optional mapping from a long-lived background tool start ACK to a task id | |
| 212 | + * (e.g. monitor task uuid) so later synthetic text events can update it. | |
| 213 | + */ | |
| 214 | + readonly extractBackgroundTaskId?: (toolCall: AcpToolCallState) => string | undefined; | |
| 215 | + /** | |
| 216 | + * Optional parse of root-session synthetic text (monitor-event lines, monitor | |
| 217 | + * ended reminders). Returns every task mutation in the chunk so coalesced | |
| 218 | + * progress / end notices are not dropped. | |
| 219 | + */ | |
| 220 | + readonly extractBackgroundToolMutation?: (text: string) => ReadonlyArray<{ | |
| 221 | + readonly taskId: string; | |
| 222 | + readonly status: "running" | "completed" | "failed"; | |
| 223 | + readonly appendOutput: string; | |
| 224 | + }>; | |
| 225 | + /** | |
| 226 | + * Optional parse of root-session synthetic text announcing a background | |
| 227 | + * subagent's end ("Background subagent "<uuid>" ... completed successfully"). | |
| 228 | + * The agent may never hydrate via get_command_or_subagent_output, so this | |
| 229 | + * notice can be the only terminal signal for the subagent row. | |
| 230 | + */ | |
| 231 | + readonly extractSubagentEndNotice?: (text: string) => | |
| 232 | + | { | |
| 233 | + readonly childSessionId: string; | |
| 234 | + readonly status: "completed" | "failed"; | |
| 235 | + } | |
| 236 | + | undefined; | |
| 237 | + /** | |
| 238 | + * Optional hydration when a later tool (e.g. get_command TaskOutput) completes | |
| 239 | + * previously registered background task id(s). | |
| 240 | + */ | |
| 241 | + readonly extractBackgroundTaskCompletion?: (toolCall: AcpToolCallState) => ReadonlyArray<{ | |
| 242 | + readonly taskId: string; | |
| 243 | + readonly status: "running" | "completed" | "failed"; | |
| 244 | + readonly appendOutput: string; | |
| 245 | + }>; | |
| 246 | + /** | |
| 247 | + * Persistent monitors (e.g. Grok `persistent: true`) should not hold root-turn | |
| 248 | + * deferred finalize open forever. Still tracked for post-settle wake. | |
| 249 | + */ | |
| 250 | + readonly isPersistentBackgroundTool?: (toolCall: AcpToolCallState) => boolean; | |
| 251 | + /** | |
| 252 | + * When true, keep the active turn open after session/prompt returns while | |
| 253 | + * background tools/subagents are still running so later monitor/wake traffic | |
| 254 | + * can project (Grok monitors finish after the root prompt settles). | |
| 255 | + */ | |
| 256 | + readonly deferFinalizeForBackgroundWork?: boolean; | |
| 131 | 257 | readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>; |
| 258 | + /** | |
| 259 | + * When true, schedule speculative local settlement after root session | |
| 260 | + * quiet. Disabled for Grok: short idle windows over-settle preamble-before- | |
| 261 | + * tools turns and `session/cancel` from that path freezes projection while | |
| 262 | + * the agent keeps working. Prefer `session/prompt` return (or a future | |
| 263 | + * root-matched terminal signal). | |
| 264 | + */ | |
| 265 | + readonly settleRootTurnWhenIdle?: boolean; | |
| 266 | + /** Interrupt the local prompt fiber before `session/cancel` (Grok wedged prompts). */ | |
| 267 | + readonly interruptPromptOnCancel?: boolean; | |
| 268 | + /** | |
| 269 | + * Kill and respawn the ACP child process before the next `session/prompt` after a | |
| 270 | + * user interrupt. Grok can keep `task_already_running` state until the process exits. | |
| 271 | + */ | |
| 272 | + readonly restartRuntimeAfterInterrupt?: boolean; | |
| 273 | + /** | |
| 274 | + * When true, every interrupt restarts the runtime, not just those carrying | |
| 275 | + * `requestRuntimeRestart` (user Stop). Leave unset to keep non-Stop | |
| 276 | + * interrupts (steering, restart_active) soft: `session/cancel` plus session | |
| 277 | + * reuse in the same process. | |
| 278 | + */ | |
| 279 | + readonly restartRuntimeOnEveryInterrupt?: boolean; | |
| 280 | + readonly terminateRuntimeProcessGroupOnInterrupt?: boolean; | |
| 281 | + /** | |
| 282 | + * When true, an interrupt without `requestRuntimeRestart` (steering restart) | |
| 283 | + * on a turn whose native prompt already settled skips the hard process-group | |
| 284 | + * kill, the ACP cancel, and the runtime respawn entirely: the turn | |
| 285 | + * terminalizes locally while background subagents keep running in the same | |
| 286 | + * process and carry over into the replacement turn. Verified against the | |
| 287 | + * real Grok CLI (tmp/grok-acp-experiments E1): a new session/prompt is | |
| 288 | + * accepted concurrently while a fire-and-forget subagent is still running, | |
| 289 | + * with no task_already_running. User Stop (`requestRuntimeRestart: true`) | |
| 290 | + * keeps the hard teardown; mid-prompt non-Stop interrupts go soft | |
| 291 | + * (`session/cancel` plus same-session re-prompt) unless | |
| 292 | + * `restartRuntimeOnEveryInterrupt` is set. | |
| 293 | + */ | |
| 294 | + readonly preserveRuntimeOnSettledInterrupt?: boolean; | |
| 295 | + /** | |
| 296 | + * When true (with continuationRequests), post-settle root session/update traffic | |
| 297 | + * buffers and requests a provider continuation run instead of being dropped or | |
| 298 | + * only appended to loaded history. | |
| 299 | + */ | |
| 300 | + readonly enablePostSettleContinuation?: boolean; | |
| 301 | + /** | |
| 302 | + * When true, send image attachment content blocks even if the ACP agent | |
| 303 | + * advertises `promptCapabilities.image: false`. Grok CLI currently accepts | |
| 304 | + * and vision-processes image blocks while still reporting the capability as | |
| 305 | + * false; without this override, screenshot turns fail before `session/prompt`. | |
| 306 | + */ | |
| 307 | + readonly supportsImagePrompts?: boolean; | |
| 308 | +} | |
| 309 | + | |
| 310 | +/** Whether image attachment blocks may be included in session/prompt. */ | |
| 311 | +export function acpSupportsImagePrompts(input: { | |
| 312 | + readonly flavorSupportsImagePrompts?: boolean | undefined; | |
| 313 | + readonly negotiatedImage?: boolean | undefined; | |
| 314 | +}): boolean { | |
| 315 | + return input.flavorSupportsImagePrompts === true || input.negotiatedImage === true; | |
| 132 | 316 | } |
| 133 | 317 | |
| 134 | 318 | export interface AcpAdapterV2SubagentUpdate { |
| @@ -136,9 +320,14 @@ export interface AcpAdapterV2SubagentUpdate { · 1 unmodified line | ||
| 136 | 320 | readonly prompt: string; |
| 137 | 321 | readonly title: string | null; |
| 138 | 322 | readonly model: string | null; |
| 139 | − readonly status: "running" | "completed" | "failed"; | |
| 323 | + readonly status: "running" | "completed" | "failed" | "interrupted" | "cancelled"; | |
| 140 | 324 | readonly childSessionId: string | null; |
| 141 | 325 | readonly result: string | null; |
| 326 | + /** | |
| 327 | + * When false, still project a normal tool turn item after the subagent update | |
| 328 | + * (hydration tools like get_command_or_subagent_output). Defaults to true. | |
| 329 | + */ | |
| 330 | + readonly suppressNormalTool?: boolean; | |
| 142 | 331 | } |
| 143 | 332 | |
| 144 | 333 | export interface AcpAdapterV2Options { |
| @@ -148,6 +337,36 @@ export interface AcpAdapterV2Options { · 3 unmodified lines | ||
| 148 | 337 | readonly idAllocator: IdAllocatorV2Shape; |
| 149 | 338 | readonly serverConfig: ServerConfig["Service"]; |
| 150 | 339 | readonly nativeLogging?: (threadId: ThreadId) => AcpAdapterV2NativeLogging; |
| 340 | + /** | |
| 341 | + * Shared with ProviderContinuationService so post-settle wake traffic can start | |
| 342 | + * a continuation run. Optional: adapters that omit it keep pre-continuation drop | |
| 343 | + * / history-only behavior for null-activeTurn updates. | |
| 344 | + */ | |
| 345 | + readonly continuationRequests?: { | |
| 346 | + readonly offer: (request: ProviderContinuationRequest) => Effect.Effect<void>; | |
| 347 | + }; | |
| 348 | + readonly testHooks?: { | |
| 349 | + readonly afterNativeResponseTransportClosed?: () => Effect.Effect<void>; | |
| 350 | + readonly afterHardTeardownTransportDrained?: () => Effect.Effect<void>; | |
| 351 | + readonly beforeNativeResponseAdmissionCheck?: ( | |
| 352 | + generation: number, | |
| 353 | + requestId: string, | |
| 354 | + ) => Effect.Effect<void>; | |
| 355 | + readonly onNativeResponseLifecycle?: (event: { | |
| 356 | + readonly generation?: number; | |
| 357 | + readonly requestId?: string; | |
| 358 | + readonly type: | |
| 359 | + | "admission_rejected" | |
| 360 | + | "failed" | |
| 361 | + | "late_noop" | |
| 362 | + | "registered" | |
| 363 | + | "removed" | |
| 364 | + | "timer_exited" | |
| 365 | + | "timer_started" | |
| 366 | + | "watcher_exited" | |
| 367 | + | "watcher_started"; | |
| 368 | + }) => Effect.Effect<void>; | |
| 369 | + }; | |
| 151 | 370 | } |
| 152 | 371 | |
| 153 | 372 | export const AcpProviderCapabilitiesV2 = { |
| @@ -278,7 +497,8 @@ function negotiatedCapabilities( · 124 unmodified lines | ||
| 278 | 497 | }; |
| 279 | 498 | } |
| 280 | 499 | |
| 281 | −function acpMcpServers(threadId: ThreadId): ReadonlyArray<EffectAcpSchema.McpServer> { | |
| 500 | +function acpMcpServers(threadId: ThreadId | null): ReadonlyArray<EffectAcpSchema.McpServer> { | |
| 501 | + if (threadId === null) return []; | |
| 282 | 502 | const session = McpProviderSession.readMcpProviderSession(threadId); |
| 283 | 503 | if (session === undefined) { |
| 284 | 504 | return []; |
| @@ -352,14 +572,114 @@ function unknownRecord(value: unknown): Record<string, unknown> | undefined { · 67 unmodified lines | ||
| 352 | 572 | : undefined; |
| 353 | 573 | } |
| 354 | 574 | |
| 575 | +export function acpCanonicalJson(value: unknown): string { | |
| 576 | + if (Array.isArray(value)) { | |
| 577 | + return `[${value.map(acpCanonicalJson).join(",")}]`; | |
| 578 | + } | |
| 579 | + const record = unknownRecord(value); | |
| 580 | + if (record !== undefined) { | |
| 581 | + return `{${Object.keys(record) | |
| 582 | + .toSorted() | |
| 583 | + .map((key) => `${JSON.stringify(key)}:${acpCanonicalJson(record[key])}`) | |
| 584 | + .join(",")}}`; | |
| 585 | + } | |
| 586 | + return JSON.stringify(value) ?? "undefined"; | |
| 587 | +} | |
| 588 | + | |
| 589 | +export function acpNativeUserInputRequestMatches( | |
| 590 | + request: Pick< | |
| 591 | + AcpAdapterV2UserInputRequest, | |
| 592 | + "nativeMethod" | "nativeRequestId" | "nativeSessionId" | |
| 593 | + >, | |
| 594 | + transport: { readonly method: string; readonly payload: unknown }, | |
| 595 | +): boolean { | |
| 596 | + if ( | |
| 597 | + request.nativeMethod === undefined || | |
| 598 | + request.nativeMethod.trim().length === 0 || | |
| 599 | + request.nativeRequestId.trim().length === 0 || | |
| 600 | + request.nativeSessionId === undefined || | |
| 601 | + request.nativeSessionId.trim().length === 0 | |
| 602 | + ) { | |
| 603 | + return false; | |
| 604 | + } | |
| 605 | + if ( | |
| 606 | + transport.method !== "x.ai/ask_user_question" && | |
| 607 | + transport.method !== "_x.ai/ask_user_question" | |
| 608 | + ) { | |
| 609 | + return false; | |
| 610 | + } | |
| 611 | + if (transport.method !== request.nativeMethod) { | |
| 612 | + return false; | |
| 613 | + } | |
| 614 | + const payloadRecord = unknownRecord(transport.payload); | |
| 615 | + const paramsRecord = unknownRecord(payloadRecord?.params) ?? payloadRecord; | |
| 616 | + return ( | |
| 617 | + paramsRecord?.toolCallId !== undefined && | |
| 618 | + String(paramsRecord.toolCallId).trim().length > 0 && | |
| 619 | + String(paramsRecord.toolCallId) === request.nativeRequestId && | |
| 620 | + paramsRecord.sessionId !== undefined && | |
| 621 | + String(paramsRecord.sessionId).trim().length > 0 && | |
| 622 | + String(paramsRecord.sessionId) === request.nativeSessionId | |
| 623 | + ); | |
| 624 | +} | |
| 625 | + | |
| 626 | +export function acpClaimNativeTransportRequest< | |
| 627 | + T extends { | |
| 628 | + readonly generation: number; | |
| 629 | + readonly requestId: string; | |
| 630 | + readonly sequence: number; | |
| 631 | + }, | |
| 632 | +>( | |
| 633 | + requests: ReadonlyArray<T>, | |
| 634 | + generation: number, | |
| 635 | + predicate: (request: T) => boolean, | |
| 636 | +): readonly [string | undefined, Array<T>] { | |
| 637 | + let claimedIndex = -1; | |
| 638 | + let claimedSequence = Number.POSITIVE_INFINITY; | |
| 639 | + for (let index = 0; index < requests.length; index += 1) { | |
| 640 | + const request = requests[index]!; | |
| 641 | + if ( | |
| 642 | + request.generation === generation && | |
| 643 | + request.sequence < claimedSequence && | |
| 644 | + predicate(request) | |
| 645 | + ) { | |
| 646 | + claimedIndex = index; | |
| 647 | + claimedSequence = request.sequence; | |
| 648 | + } | |
| 649 | + } | |
| 650 | + if (claimedIndex < 0) return [undefined, [...requests]]; | |
| 651 | + return [ | |
| 652 | + requests[claimedIndex]!.requestId, | |
| 653 | + [...requests.slice(0, claimedIndex), ...requests.slice(claimedIndex + 1)], | |
| 654 | + ]; | |
| 655 | +} | |
| 656 | + | |
| 355 | 657 | function nonEmptyText(value: unknown, fallback: string): string { |
| 356 | 658 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback; |
| 357 | 659 | } |
| 358 | 660 | |
| 661 | +function decodeByteText(value: unknown): string | undefined { | |
| 662 | + if (!Array.isArray(value) || value.length === 0) return undefined; | |
| 663 | + if (!value.every((entry) => typeof entry === "number" && Number.isInteger(entry))) { | |
| 664 | + return undefined; | |
| 665 | + } | |
| 666 | + try { | |
| 667 | + // Preserve leading/trailing whitespace like the string path in textFromUnknown. | |
| 668 | + const text = new TextDecoder().decode(Uint8Array.from(value as number[])); | |
| 669 | + return text.length > 0 ? text : undefined; | |
| 670 | + } catch { | |
| 671 | + return undefined; | |
| 672 | + } | |
| 673 | +} | |
| 674 | + | |
| 359 | 675 | function textFromUnknown(value: unknown): string | undefined { |
| 360 | 676 | if (typeof value === "string") { |
| 361 | 677 | return value; |
| 362 | 678 | } |
| 679 | + const fromBytes = decodeByteText(value); | |
| 680 | + if (fromBytes !== undefined) { | |
| 681 | + return fromBytes; | |
| 682 | + } | |
| 363 | 683 | if (Array.isArray(value)) { |
| 364 | 684 | const parts = value.flatMap((entry) => { |
| 365 | 685 | const text = textFromUnknown(entry); |
| @@ -371,12 +691,33 @@ function textFromUnknown(value: unknown): string | undefined { · 5 unmodified lines | ||
| 371 | 691 | if (record === undefined) { |
| 372 | 692 | return undefined; |
| 373 | 693 | } |
| 374 | − for (const key of ["stdout", "stderr", "output", "content", "text", "message"]) { | |
| 375 | − const text = textFromUnknown(record[key]); | |
| 694 | + // Prefer prompt-facing Grok fields before nested envelopes. | |
| 695 | + for (const key of [ | |
| 696 | + "output_for_prompt", | |
| 697 | + "stdout", | |
| 698 | + "stderr", | |
| 699 | + "output", | |
| 700 | + "content", | |
| 701 | + "text", | |
| 702 | + "message", | |
| 703 | + ]) { | |
| 704 | + const direct = record[key]; | |
| 705 | + if (typeof direct === "string" && direct.length > 0) { | |
| 706 | + return direct; | |
| 707 | + } | |
| 708 | + const decoded = decodeByteText(direct); | |
| 709 | + if (decoded !== undefined) { | |
| 710 | + return decoded; | |
| 711 | + } | |
| 712 | + const text = textFromUnknown(direct); | |
| 376 | 713 | if (text !== undefined && text.length > 0) { |
| 377 | 714 | return text; |
| 378 | 715 | } |
| 379 | 716 | } |
| 717 | + const result = unknownRecord(record.Result) ?? unknownRecord(record.result); | |
| 718 | + if (result !== undefined) { | |
| 719 | + return textFromUnknown(result); | |
| 720 | + } | |
| 380 | 721 | return undefined; |
| 381 | 722 | } |
| 382 | 723 | |
| @@ -391,6 +732,21 @@ function commandExitCode(value: unknown): number | undefined { · 8 unmodified lines | ||
| 391 | 732 | return undefined; |
| 392 | 733 | } |
| 393 | 734 | |
| 735 | +/** | |
| 736 | + * Project an exit code only when the tool has a terminal native status. | |
| 737 | + * Mid-stream Grok Bash re-reports carry exit_code 0 while still in progress; | |
| 738 | + * interrupted tools must not retain that stale success code. | |
| 739 | + */ | |
| 740 | +export function acpProjectedCommandExitCode( | |
| 741 | + status: "pending" | "running" | "completed" | "failed" | "interrupted", | |
| 742 | + rawOutput: unknown, | |
| 743 | +): number | undefined { | |
| 744 | + if (status !== "completed" && status !== "failed") { | |
| 745 | + return undefined; | |
| 746 | + } | |
| 747 | + return commandExitCode(rawOutput); | |
| 748 | +} | |
| 749 | + | |
| 394 | 750 | function pathFromToolCall(toolCall: AcpToolCallState): string | undefined { |
| 395 | 751 | const locations = toolCall.data.locations; |
| 396 | 752 | if (Array.isArray(locations)) { |
| @@ -443,15 +799,14 @@ function toolStatus( · 46 unmodified lines | ||
| 443 | 799 | } |
| 444 | 800 | } |
| 445 | 801 | |
| 446 | −function nodeStatus(status: ReturnType<typeof toolStatus>): OrchestrationV2ExecutionNode["status"] { | |
| 802 | +type ProjectedToolStatus = ReturnType<typeof toolStatus> | "interrupted"; | |
| 803 | + | |
| 804 | +function nodeStatus(status: ProjectedToolStatus): OrchestrationV2ExecutionNode["status"] { | |
| 447 | 805 | return status === "pending" ? "running" : status; |
| 448 | 806 | } |
| 449 | 807 | |
| 450 | −function completedAtForStatus( | |
| 451 | − status: ReturnType<typeof toolStatus>, | |
| 452 | − now: DateTime.Utc, | |
| 453 | −): DateTime.Utc | null { | |
| 454 | − return status === "completed" || status === "failed" ? now : null; | |
| 808 | +function completedAtForStatus(status: ProjectedToolStatus, now: DateTime.Utc): DateTime.Utc | null { | |
| 809 | + return status === "completed" || status === "failed" || status === "interrupted" ? now : null; | |
| 455 | 810 | } |
| 456 | 811 | |
| 457 | 812 | function selectPermissionOptionId( |
| @@ -558,12 +913,175 @@ interface ActiveAcpTurn { · 100 unmodified lines | ||
| 558 | 913 | readonly subagents: Map<string, ActiveAcpSubagent>; |
| 559 | 914 | readonly subagentsBySessionId: Map<string, ActiveAcpSubagent>; |
| 560 | 915 | readonly pendingSubagentNotifications: Map<string, Array<EffectAcpSchema.SessionNotification>>; |
| 916 | + /** Background monitor/task id → toolCallId for synthetic root text updates. */ | |
| 917 | + readonly toolCallIdsByBackgroundTaskId: Map<string, string>; | |
| 918 | + /** | |
| 919 | + * Persistent monitors registered this turn. Excluded from deferred-finalize | |
| 920 | + * holds so the root turn can settle while they keep streaming post-settle. | |
| 921 | + */ | |
| 922 | + readonly persistentBackgroundTaskIds: Set<string>; | |
| 923 | + /** | |
| 924 | + * Monitor end events often only say "use get_command…"; keep the turn open | |
| 925 | + * until TaskOutput hydration arrives (or the safety timeout elapses). | |
| 926 | + */ | |
| 927 | + readonly awaitingBackgroundHydration: Set<string>; | |
| 928 | + /** | |
| 929 | + * A monitor end notice landed after the prompt settled: the CLI runs an | |
| 930 | + * injected turn whose report never gets a turn_completed marker, so the | |
| 931 | + * report chunk races the deferred-finalize debounce (thread a8e8b0a9 run 5 | |
| 932 | + * dropped the listing this way). Hold finalize until the report streams or | |
| 933 | + * the safety timeout elapses. | |
| 934 | + */ | |
| 935 | + readonly pendingInjectedReport: Set<string>; | |
| 561 | 936 | plan: { |
| 562 | 937 | readonly id: OrchestrationV2PlanArtifact["id"]; |
| 563 | 938 | readonly startedAt: DateTime.Utc; |
| 564 | 939 | } | null; |
| 565 | 940 | interrupted: boolean; |
| 566 | 941 | finalized: boolean; |
| 942 | + settleScheduleGeneration: number; | |
| 943 | + /** session/prompt already returned; finalize deferred for background work. */ | |
| 944 | + promptSettled: boolean; | |
| 945 | + promptSettledStatus: "completed" | "interrupted" | "failed" | "cancelled" | null; | |
| 946 | + /** | |
| 947 | + * Completed the moment `runtime.prompt` resolves on the wire, before the | |
| 948 | + * completion callback requests `runtimeCallbackPermit`. Failure does not | |
| 949 | + * complete this; settled-soft classification ORs it with `promptSettled`. | |
| 950 | + */ | |
| 951 | + readonly promptWireSettled: Deferred.Deferred<void, never>; | |
| 952 | + backgroundFinalizeGeneration: number; | |
| 953 | +} | |
| 954 | + | |
| 955 | +type AcpRuntimeTeardownState = | |
| 956 | + | { readonly _tag: "Idle" } | |
| 957 | + | { | |
| 958 | + readonly _tag: "InProgress"; | |
| 959 | + readonly completed: Deferred.Deferred<void, ProviderAdapterProtocolError>; | |
| 960 | + } | |
| 961 | + | { readonly _tag: "Failed"; readonly error: ProviderAdapterProtocolError }; | |
| 962 | + | |
| 963 | +export function acpRootTurnHasIngestedOutput(context: { | |
| 964 | + readonly assistant: ActiveTextStream; | |
| 965 | + readonly reasoning: ActiveTextStream; | |
| 966 | + readonly tools: ReadonlyMap<string, AcpToolCallState>; | |
| 967 | + readonly plan: unknown; | |
| 968 | +}): boolean { | |
| 969 | + return ( | |
| 970 | + context.assistant.nextSegment > 0 || | |
| 971 | + context.reasoning.nextSegment > 0 || | |
| 972 | + context.tools.size > 0 || | |
| 973 | + context.plan !== null | |
| 974 | + ); | |
| 975 | +} | |
| 976 | + | |
| 977 | +/** True when a root session/update carries ingestible turn output, not keepalive noise. */ | |
| 978 | +export function acpRootSessionUpdateIngestsOutput( | |
| 979 | + notification: EffectAcpSchema.SessionNotification, | |
| 980 | +): boolean { | |
| 981 | + const update = notification.update; | |
| 982 | + switch (update.sessionUpdate) { | |
| 983 | + case "agent_message_chunk": | |
| 984 | + case "agent_thought_chunk": | |
| 985 | + return update.content.type === "text" && update.content.text.length > 0; | |
| 986 | + case "tool_call": | |
| 987 | + case "tool_call_update": | |
| 988 | + case "plan": | |
| 989 | + return parseSessionUpdateEvent(notification).events.some( | |
| 990 | + (event) => event._tag === "ToolCallUpdated" || event._tag === "PlanUpdated", | |
| 991 | + ); | |
| 992 | + default: | |
| 993 | + return false; | |
| 994 | + } | |
| 995 | +} | |
| 996 | + | |
| 997 | +/** | |
| 998 | + * Post-settle traffic that should be *buffered* for a continuation attach. | |
| 999 | + * Excludes monitor end/event chatter that must not become ghost history. | |
| 1000 | + * Broader than {@link acpPostSettleContinuationOfferEvidence}: incremental | |
| 1001 | + * tool progress may still need replay once a real completion offers a run. | |
| 1002 | + */ | |
| 1003 | +export function acpPostSettleWakeEvidence( | |
| 1004 | + notification: EffectAcpSchema.SessionNotification, | |
| 1005 | + flavor: Pick<AcpAdapterV2Flavor, "extractBackgroundToolMutation"> = {}, | |
| 1006 | +): boolean { | |
| 1007 | + if (!acpRootSessionUpdateIngestsOutput(notification)) return false; | |
| 1008 | + const update = notification.update; | |
| 1009 | + if ( | |
| 1010 | + (update.sessionUpdate === "user_message_chunk" || | |
| 1011 | + update.sessionUpdate === "agent_message_chunk") && | |
| 1012 | + update.content.type === "text" | |
| 1013 | + ) { | |
| 1014 | + const text = update.content.text; | |
| 1015 | + if ((flavor.extractBackgroundToolMutation?.(text) ?? []).length > 0) return false; | |
| 1016 | + if (/<monitor-event\b/i.test(text) || /Monitor\s+["']?[0-9a-f-]{8,}["']?\s+ended/i.test(text)) { | |
| 1017 | + return false; | |
| 1018 | + } | |
| 1019 | + } | |
| 1020 | + return true; | |
| 1021 | +} | |
| 1022 | + | |
| 1023 | +/** | |
| 1024 | + * Whether buffered post-settle traffic should *offer* a continuation run now. | |
| 1025 | + * Still-running tool streams often land farther apart than the deferred-finalize | |
| 1026 | + * quiet window; treating every tool_call_update as offer evidence re-opens a | |
| 1027 | + * synthetic "Background task completed." run on each chunk. Only completion-like | |
| 1028 | + * frames (real agent text, or a terminal tool status) should open a new run. | |
| 1029 | + */ | |
| 1030 | +export function acpPostSettleContinuationOfferEvidence( | |
| 1031 | + notification: EffectAcpSchema.SessionNotification, | |
| 1032 | + flavor: Pick<AcpAdapterV2Flavor, "extractBackgroundToolMutation" | "normalizeToolCall"> = {}, | |
| 1033 | +): boolean { | |
| 1034 | + if (!acpPostSettleWakeEvidence(notification, flavor)) { | |
| 1035 | + return false; | |
| 1036 | + } | |
| 1037 | + const update = notification.update; | |
| 1038 | + // Assistant text only. Thought/reasoning bursts alone must not open synthetic | |
| 1039 | + // "Background task completed." runs (duplicate-run spam after monitors). | |
| 1040 | + if (update.sessionUpdate === "agent_message_chunk") { | |
| 1041 | + return update.content.type === "text" && update.content.text.length > 0; | |
| 1042 | + } | |
| 1043 | + if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { | |
| 1044 | + return parseSessionUpdateEvent(notification).events.some((event) => { | |
| 1045 | + if (event._tag !== "ToolCallUpdated") return false; | |
| 1046 | + // Normalize first: a Grok monitor start ACK arrives with raw status | |
| 1047 | + // "completed" but is a still-running background task, not completion. | |
| 1048 | + const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | |
| 1049 | + return toolCall.status === "completed" || toolCall.status === "failed"; | |
| 1050 | + }); | |
| 1051 | + } | |
| 1052 | + return false; | |
| 1053 | +} | |
| 1054 | + | |
| 1055 | +export function acpPostSettleWakeShouldBuffer( | |
| 1056 | + notification: EffectAcpSchema.SessionNotification, | |
| 1057 | + backgroundWorkRunning: boolean, | |
| 1058 | +): boolean { | |
| 1059 | + if (!backgroundWorkRunning) return true; | |
| 1060 | + const update = notification.update; | |
| 1061 | + return ( | |
| 1062 | + update.sessionUpdate !== "agent_message_chunk" && update.sessionUpdate !== "agent_thought_chunk" | |
| 1063 | + ); | |
| 1064 | +} | |
| 1065 | + | |
| 1066 | +export function acpPostSettleMonitorPromptShouldSuppress( | |
| 1067 | + mutation: | |
| 1068 | + | { | |
| 1069 | + readonly taskId: string; | |
| 1070 | + readonly status: "running" | "completed" | "failed"; | |
| 1071 | + } | |
| 1072 | + | undefined, | |
| 1073 | +): boolean { | |
| 1074 | + return mutation?.status === "running"; | |
| 1075 | +} | |
| 1076 | + | |
| 1077 | +export function acpCompletedTurnShouldTerminalizeTool( | |
| 1078 | + tool: AcpToolCallState, | |
| 1079 | + flavor: Pick<AcpAdapterV2Flavor, "extractBackgroundTaskId" | "extractSubagentUpdate">, | |
| 1080 | +): boolean { | |
| 1081 | + const status = toolStatus(tool.status); | |
| 1082 | + if (status !== "pending" && status !== "running") return false; | |
| 1083 | + if (flavor.extractBackgroundTaskId?.(tool) !== undefined) return false; | |
| 1084 | + return flavor.extractSubagentUpdate?.(tool) === undefined; | |
| 567 | 1085 | } |
| 568 | 1086 | |
| 569 | 1087 | interface ActiveAcpSubagent { |
| @@ -572,12 +1090,29 @@ interface ActiveAcpSubagent { · 2 unmodified lines | ||
| 572 | 1090 | readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; |
| 573 | 1091 | readonly turnItemId: OrchestrationV2TurnItem["id"]; |
| 574 | 1092 | readonly turnItemOrdinal: number; |
| 1093 | + /** Turn that spawned the subagent; carryover updates keep this lineage. */ | |
| 1094 | + readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | |
| 1095 | + readonly parentProviderThreadId: ProviderThreadId; | |
| 575 | 1096 | childSessionId: string | null; |
| 576 | 1097 | assistantText: string; |
| 577 | 1098 | nextChildOrdinal: number; |
| 578 | 1099 | } |
| 579 | 1100 | |
| 1101 | +function acpTurnHasPendingRuntimeRequest( | |
| 1102 | + providerTurnId: OrchestrationV2ProviderTurn["id"], | |
| 1103 | + pending: ReadonlyMap<string, PendingRuntimeRequest>, | |
| 1104 | +): boolean { | |
| 1105 | + return [...pending.values()].some( | |
| 1106 | + (request) => | |
| 1107 | + request.runtimeRequest.providerTurnId === providerTurnId && | |
| 1108 | + request.runtimeRequest.status === "pending", | |
| 1109 | + ); | |
| 1110 | +} | |
| 1111 | + | |
| 580 | 1112 | type PendingRuntimeRequest = { |
| 1113 | + readonly generation: number; | |
| 1114 | + readonly nativeResponseAcknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>; | |
| 1115 | + readonly transportRequestId: string; | |
| 581 | 1116 | readonly requestId: RuntimeRequestId; |
| 582 | 1117 | readonly runtimeRequest: OrchestrationV2RuntimeRequest; |
| 583 | 1118 | readonly node: OrchestrationV2ExecutionNode; |
| @@ -603,6 +1138,9 @@ interface SnapshotMessageState { · 19 unmodified lines | ||
| 603 | 1138 | export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV2Shape { |
| 604 | 1139 | const { flavor, fileSystem, idAllocator, serverConfig } = options; |
| 605 | 1140 | const driver = flavor.driver; |
| 1141 | + const continuationRequests = options.continuationRequests; | |
| 1142 | + const postSettleContinuationEnabled = | |
| 1143 | + flavor.enablePostSettleContinuation === true && continuationRequests !== undefined; | |
| 606 | 1144 | |
| 607 | 1145 | return ProviderAdapterV2.of({ |
| 608 | 1146 | instanceId: options.instanceId, |
| @@ -617,7 +1155,33 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 8 unmodified lines | ||
| 617 | 1155 | const activeSessionId = yield* Ref.make<string | null>(null); |
| 618 | 1156 | const activeSessionSetup = yield* Ref.make<AcpSessionRuntimeStartResult | null>(null); |
| 619 | 1157 | const activeSelection = yield* Ref.make<ModelSelection | null>(null); |
| 1158 | + const runtimeRestartRequired = yield* Ref.make(false); | |
| 1159 | + const runtimeTeardownState = yield* Ref.make<AcpRuntimeTeardownState>({ _tag: "Idle" }); | |
| 1160 | + const runtimeCallbackGeneration = yield* Ref.make(0); | |
| 1161 | + const runtimeCallbackPermit = yield* Semaphore.make(1); | |
| 1162 | + const runtimeTransitionPermit = yield* Semaphore.make(1); | |
| 1163 | + const nativeTransportRequests = yield* Ref.make< | |
| 1164 | + Array<{ | |
| 1165 | + readonly generation: number; | |
| 1166 | + readonly method: string; | |
| 1167 | + readonly payload: unknown; | |
| 1168 | + readonly requestId: string; | |
| 1169 | + readonly sequence: number; | |
| 1170 | + }> | |
| 1171 | + >([]); | |
| 1172 | + const nextNativeTransportSequence = yield* Ref.make(0); | |
| 1173 | + const nativeResponseAcknowledgements = yield* Ref.make( | |
| 1174 | + new Map< | |
| 1175 | + string, | |
| 1176 | + { | |
| 1177 | + readonly acknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>; | |
| 1178 | + readonly generation: number; | |
| 1179 | + } | |
| 1180 | + >(), | |
| 1181 | + ); | |
| 620 | 1182 | const pendingRuntimeRequests = yield* Ref.make(new Map<string, PendingRuntimeRequest>()); |
| 1183 | + const emitNativeResponseLifecycle = | |
| 1184 | + options.testHooks?.onNativeResponseLifecycle ?? (() => Effect.void); | |
| 621 | 1185 | const nextElicitationOrdinal = yield* Ref.make(0); |
| 622 | 1186 | const itemOrdinals = yield* Ref.make(new Map<string, number>()); |
| 623 | 1187 | const nextItemOrdinalsByTurn = yield* Ref.make(new Map<string, number>()); |
| @@ -629,20 +1193,434 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines | ||
| 629 | 1193 | loadingIndex: 0, |
| 630 | 1194 | }); |
| 631 | 1195 | |
| 1196 | + const awaitRuntimeTeardown = Effect.fnUntraced(function* () { | |
| 1197 | + const state = yield* Ref.get(runtimeTeardownState); | |
| 1198 | + if (state._tag === "InProgress") { | |
| 1199 | + yield* Deferred.await(state.completed); | |
| 1200 | + } else if (state._tag === "Failed") { | |
| 1201 | + return yield* state.error; | |
| 1202 | + } | |
| 1203 | + }); | |
| 1204 | + const runRuntimeCallbackAtGeneration = <A, E, R>( | |
| 1205 | + generation: number, | |
| 1206 | + effect: Effect.Effect<A, E, R>, | |
| 1207 | + ) => | |
| 1208 | + runtimeCallbackPermit.withPermit( | |
| 1209 | + Effect.gen(function* () { | |
| 1210 | + if ((yield* Ref.get(runtimeTeardownState))._tag !== "Idle") { | |
| 1211 | + return Option.none<A>(); | |
| 1212 | + } | |
| 1213 | + if ((yield* Ref.get(runtimeCallbackGeneration)) !== generation) { | |
| 1214 | + return Option.none<A>(); | |
| 1215 | + } | |
| 1216 | + return Option.some(yield* effect); | |
| 1217 | + }), | |
| 1218 | + ); | |
| 1219 | + const claimNativeTransportRequest = ( | |
| 1220 | + generation: number, | |
| 1221 | + predicate: (request: { readonly method: string; readonly payload: unknown }) => boolean, | |
| 1222 | + ) => | |
| 1223 | + Ref.modify( | |
| 1224 | + nativeTransportRequests, | |
| 1225 | + ( | |
| 1226 | + requests, | |
| 1227 | + ): readonly [ | |
| 1228 | + string | undefined, | |
| 1229 | + Array<{ | |
| 1230 | + readonly generation: number; | |
| 1231 | + readonly method: string; | |
| 1232 | + readonly payload: unknown; | |
| 1233 | + readonly requestId: string; | |
| 1234 | + readonly sequence: number; | |
| 1235 | + }>, | |
| 1236 | + ] => acpClaimNativeTransportRequest(requests, generation, predicate), | |
| 1237 | + ); | |
| 1238 | + const registerNativeResponseAcknowledgement = ( | |
| 1239 | + generation: number, | |
| 1240 | + transportRequestId: string, | |
| 1241 | + acknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>, | |
| 1242 | + ) => | |
| 1243 | + Effect.gen(function* () { | |
| 1244 | + yield* Ref.update(nativeResponseAcknowledgements, (current) => { | |
| 1245 | + const updated = new Map(current); | |
| 1246 | + updated.set(transportRequestId, { acknowledgement, generation }); | |
| 1247 | + return updated; | |
| 1248 | + }); | |
| 1249 | + yield* emitNativeResponseLifecycle({ | |
| 1250 | + type: "registered", | |
| 1251 | + generation, | |
| 1252 | + requestId: transportRequestId, | |
| 1253 | + }); | |
| 1254 | + if (!(yield* Deferred.isDone(acknowledgement))) return; | |
| 1255 | + yield* Ref.update(nativeResponseAcknowledgements, (current) => { | |
| 1256 | + if (current.get(transportRequestId)?.acknowledgement !== acknowledgement) { | |
| 1257 | + return current; | |
| 1258 | + } | |
| 1259 | + const updated = new Map(current); | |
| 1260 | + updated.delete(transportRequestId); | |
| 1261 | + return updated; | |
| 1262 | + }); | |
| 1263 | + }); | |
| 1264 | + const acknowledgeNativeResponse = (generation: number, transportRequestId: string) => | |
| 1265 | + ( | |
| 1266 | + options.testHooks?.beforeNativeResponseAdmissionCheck?.( | |
| 1267 | + generation, | |
| 1268 | + transportRequestId, | |
| 1269 | + ) ?? Effect.void | |
| 1270 | + ).pipe( | |
| 1271 | + Effect.andThen(runRuntimeCallbackAtGeneration(generation, Effect.void)), | |
| 1272 | + Effect.flatMap((registered) => | |
| 1273 | + Option.isSome(registered) | |
| 1274 | + ? Effect.void | |
| 1275 | + : emitNativeResponseLifecycle({ | |
| 1276 | + type: "admission_rejected", | |
| 1277 | + generation, | |
| 1278 | + requestId: transportRequestId, | |
| 1279 | + }).pipe( | |
| 1280 | + Effect.andThen( | |
| 1281 | + new EffectAcpErrors.AcpTransportError({ | |
| 1282 | + detail: "The ACP runtime closed before its response could be admitted", | |
| 1283 | + cause: "Native response registration rejected during teardown", | |
| 1284 | + }), | |
| 1285 | + ), | |
| 1286 | + ), | |
| 1287 | + ), | |
| 1288 | + ); | |
| 1289 | + const awaitNativeResponseAcknowledgements = Effect.fnUntraced(function* ( | |
| 1290 | + acknowledgements: ReadonlyArray< | |
| 1291 | + readonly [string | undefined, Deferred.Deferred<void, EffectAcpErrors.AcpError>] | |
| 1292 | + >, | |
| 1293 | + ) { | |
| 1294 | + if (acknowledgements.length === 0) return true; | |
| 1295 | + const completed = yield* Deferred.make<"settled" | "timeout">(); | |
| 1296 | + yield* emitNativeResponseLifecycle({ type: "watcher_started" }); | |
| 1297 | + const acknowledgementFiber = yield* Effect.forEach( | |
| 1298 | + acknowledgements, | |
| 1299 | + ([, acknowledgement]) => Deferred.await(acknowledgement).pipe(Effect.exit), | |
| 1300 | + { concurrency: "unbounded", discard: true }, | |
| 1301 | + ).pipe( | |
| 1302 | + Effect.andThen(Deferred.succeed(completed, "settled")), | |
| 1303 | + Effect.interruptible, | |
| 1304 | + Effect.ensuring(emitNativeResponseLifecycle({ type: "watcher_exited" })), | |
| 1305 | + Effect.forkDetach, | |
| 1306 | + ); | |
| 1307 | + yield* emitNativeResponseLifecycle({ type: "timer_started" }); | |
| 1308 | + const timerFiber = yield* Effect.sleep("2 seconds").pipe( | |
| 1309 | + Effect.andThen(Deferred.succeed(completed, "timeout")), | |
| 1310 | + Effect.interruptible, | |
| 1311 | + Effect.ensuring(emitNativeResponseLifecycle({ type: "timer_exited" })), | |
| 1312 | + Effect.forkDetach, | |
| 1313 | + ); | |
| 1314 | + const outcome = yield* Deferred.await(completed); | |
| 1315 | + yield* Fiber.interrupt(acknowledgementFiber); | |
| 1316 | + yield* Fiber.interrupt(timerFiber); | |
| 1317 | + if (outcome === "settled") return true; | |
| 1318 | + | |
| 1319 | + yield* Ref.update(nativeResponseAcknowledgements, (current) => { | |
| 1320 | + const updated = new Map(current); | |
| 1321 | + for (const [requestId, acknowledgement] of acknowledgements) { | |
| 1322 | + if ( | |
| 1323 | + requestId !== undefined && | |
| 1324 | + updated.get(requestId)?.acknowledgement === acknowledgement | |
| 1325 | + ) { | |
| 1326 | + updated.delete(requestId); | |
| 1327 | + } | |
| 1328 | + } | |
| 1329 | + return updated; | |
| 1330 | + }); | |
| 1331 | + yield* Effect.forEach( | |
| 1332 | + acknowledgements, | |
| 1333 | + ([requestId]) => | |
| 1334 | + emitNativeResponseLifecycle({ | |
| 1335 | + type: "removed", | |
| 1336 | + ...(requestId === undefined ? {} : { requestId }), | |
| 1337 | + }), | |
| 1338 | + { concurrency: "unbounded", discard: true }, | |
| 1339 | + ); | |
| 1340 | + const timeoutError = new EffectAcpErrors.AcpTransportError({ | |
| 1341 | + detail: "Timed out waiting for an admitted ACP response to reach the transport queue", | |
| 1342 | + cause: "Native response acknowledgement timed out", | |
| 1343 | + }); | |
| 1344 | + yield* Effect.forEach( | |
| 1345 | + acknowledgements, | |
| 1346 | + ([requestId, acknowledgement]) => | |
| 1347 | + Deferred.fail(acknowledgement, timeoutError).pipe( | |
| 1348 | + Effect.andThen( | |
| 1349 | + emitNativeResponseLifecycle({ | |
| 1350 | + type: "failed", | |
| 1351 | + ...(requestId === undefined ? {} : { requestId }), | |
| 1352 | + }), | |
| 1353 | + ), | |
| 1354 | + ), | |
| 1355 | + { concurrency: "unbounded", discard: true }, | |
| 1356 | + ); | |
| 1357 | + return false; | |
| 1358 | + }); | |
| 1359 | + const awaitAdmittedNativeResponses = Effect.gen(function* () { | |
| 1360 | + yield* awaitNativeResponseAcknowledgements( | |
| 1361 | + [...(yield* Ref.get(nativeResponseAcknowledgements)).entries()].map( | |
| 1362 | + ([requestId, entry]) => [requestId, entry.acknowledgement] as const, | |
| 1363 | + ), | |
| 1364 | + ); | |
| 1365 | + }); | |
| 1366 | + const quarantineNativeTransportAtGeneration = Effect.fnUntraced(function* ( | |
| 1367 | + generation: number, | |
| 1368 | + ) { | |
| 1369 | + yield* Ref.update(nativeTransportRequests, (requests) => | |
| 1370 | + requests.filter((request) => request.generation !== generation), | |
| 1371 | + ); | |
| 1372 | + const quarantined = yield* Ref.modify(nativeResponseAcknowledgements, (current) => { | |
| 1373 | + const updated = new Map(current); | |
| 1374 | + const acknowledgements: Array<Deferred.Deferred<void, EffectAcpErrors.AcpError>> = []; | |
| 1375 | + for (const [requestId, entry] of updated) { | |
| 1376 | + if (entry.generation !== generation) continue; | |
| 1377 | + updated.delete(requestId); | |
| 1378 | + acknowledgements.push(entry.acknowledgement); | |
| 1379 | + } | |
| 1380 | + return [acknowledgements, updated] as const; | |
| 1381 | + }); | |
| 1382 | + const error = new EffectAcpErrors.AcpTransportError({ | |
| 1383 | + detail: "The ACP runtime was replaced before its response reached the transport queue", | |
| 1384 | + cause: "ACP runtime transport was quarantined during teardown", | |
| 1385 | + }); | |
| 1386 | + yield* Effect.forEach( | |
| 1387 | + quarantined, | |
| 1388 | + (acknowledgement) => Deferred.fail(acknowledgement, error), | |
| 1389 | + { concurrency: "unbounded", discard: true }, | |
| 1390 | + ); | |
| 1391 | + }); | |
| 1392 | + const closeNativeTransport = runtimeCallbackPermit.withPermit( | |
| 1393 | + Effect.gen(function* () { | |
| 1394 | + yield* Ref.update(runtimeCallbackGeneration, (generation) => generation + 1); | |
| 1395 | + yield* Ref.set(nativeTransportRequests, []); | |
| 1396 | + const acknowledgements = yield* Ref.getAndSet( | |
| 1397 | + nativeResponseAcknowledgements, | |
| 1398 | + new Map(), | |
| 1399 | + ); | |
| 1400 | + const error = new EffectAcpErrors.AcpTransportError({ | |
| 1401 | + detail: "The ACP session closed before its admitted response reached the transport", | |
| 1402 | + cause: "ACP session transport closed", | |
| 1403 | + }); | |
| 1404 | + yield* Effect.forEach( | |
| 1405 | + acknowledgements, | |
| 1406 | + ([requestId, entry]) => | |
| 1407 | + Deferred.fail(entry.acknowledgement, error).pipe( | |
| 1408 | + Effect.andThen( | |
| 1409 | + emitNativeResponseLifecycle({ | |
| 1410 | + type: "removed", | |
| 1411 | + generation: entry.generation, | |
| 1412 | + requestId, | |
| 1413 | + }), | |
| 1414 | + ), | |
| 1415 | + Effect.andThen( | |
| 1416 | + emitNativeResponseLifecycle({ | |
| 1417 | + type: "failed", | |
| 1418 | + generation: entry.generation, | |
| 1419 | + requestId, | |
| 1420 | + }), | |
| 1421 | + ), | |
| 1422 | + ), | |
| 1423 | + { concurrency: "unbounded", discard: true }, | |
| 1424 | + ); | |
| 1425 | + return acknowledgements.size > 0; | |
| 1426 | + }), | |
| 1427 | + ); | |
| 1428 | + // Post-settle wake support (Grok async subagent/monitor follow-up). After | |
| 1429 | + // the root turn finalizes, later root session/update traffic buffers here | |
| 1430 | + // until a provider continuation run attaches and drains it. | |
| 1431 | + const lastTurnRoute = yield* Ref.make<{ | |
| 1432 | + readonly threadId: ThreadId; | |
| 1433 | + readonly providerThreadId: ProviderThreadId; | |
| 1434 | + } | null>(null); | |
| 1435 | + const wakeBuffer = yield* Ref.make<Array<EffectAcpSchema.SessionNotification>>([]); | |
| 1436 | + const continuationRequested = yield* Ref.make(false); | |
| 1437 | + const continuationGeneration = yield* Ref.make(0); | |
| 1438 | + const continuationPermit = yield* Semaphore.make(1); | |
| 1439 | + const continuationClosed = yield* Ref.make(false); | |
| 1440 | + // Direct Stop (requestRuntimeRestart) quarantines residual events from the | |
| 1441 | + // stopped run so they cannot wake or attach to a later prompt/run. | |
| 1442 | + const stoppedRunQuarantine = yield* Ref.make(false); | |
| 1443 | + // A steering restart (or any interrupt) can finalize a turn while its | |
| 1444 | + // spawned subagents are still running natively. Carry the live | |
| 1445 | + // lineages into the next turn on the same session so their terminal | |
| 1446 | + // signals can still flip the original turn items instead of leaving | |
| 1447 | + // them running forever. | |
| 1448 | + const carryoverSubagents = yield* Ref.make<{ | |
| 1449 | + readonly sessionId: string; | |
| 1450 | + readonly subagents: ReadonlyArray<ActiveAcpSubagent>; | |
| 1451 | + } | null>(null); | |
| 1452 | + const handledBackgroundTaskIdsInActiveTurn = yield* Ref.make<ReadonlySet<string>>( | |
| 1453 | + new Set(), | |
| 1454 | + ); | |
| 1455 | + // A monitor-event can arrive after its task and the user-facing provider | |
| 1456 | + // turn already completed. Grok starts another internal prompt for that | |
| 1457 | + // stale notification; suppress its agent output until a genuine terminal | |
| 1458 | + // mutation or the next app turn so it cannot create a redundant app | |
| 1459 | + // continuation. Tool frames continue through normal hydration. | |
| 1460 | + const suppressPostSettleMonitorPrompt = yield* Ref.make(false); | |
| 1461 | + // Background tasks (Grok monitors) known to still run at session level. | |
| 1462 | + // Turn contexts are too short-lived to carry this: a continuation run | |
| 1463 | + // finalizes between monitor events, and the next commentary burst must | |
| 1464 | + // not reopen a run while the monitor is still streaming. | |
| 1465 | + const runningBackgroundTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set()); | |
| 1466 | + // Task ids with a GENUINE end signal (monitor-ended reminder or | |
| 1467 | + // TaskOutput completion). Normalized tool statuses are not genuine: | |
| 1468 | + // Grok Bash re-reports carry exit_code 0 mid-stream. A straggler | |
| 1469 | + // monitor-event can land after the real end (the CLI keeps streaming | |
| 1470 | + // while the agent already consumed the output via | |
| 1471 | + // get_command_or_subagent_output); without the tombstone it would | |
| 1472 | + // resurrect the running set and pin offers/idle-release forever. | |
| 1473 | + // A tool-level failed get_command tombstones too: failing open to a | |
| 1474 | + // single continuation beats failing closed to a dead thread. | |
| 1475 | + const endedBackgroundTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set()); | |
| 1476 | + const endedBackgroundTaskIdLimit = 128; | |
| 1477 | + | |
| 1478 | + const setBackgroundTaskRunning = (taskId: string, running: boolean) => | |
| 1479 | + Effect.gen(function* () { | |
| 1480 | + if (running && (yield* Ref.get(endedBackgroundTaskIds)).has(taskId)) { | |
| 1481 | + return; | |
| 1482 | + } | |
| 1483 | + yield* Ref.update(runningBackgroundTaskIds, (current) => { | |
| 1484 | + if (current.has(taskId) === running) return current; | |
| 1485 | + const next = new Set(current); | |
| 1486 | + if (running) { | |
| 1487 | + next.add(taskId); | |
| 1488 | + } else { | |
| 1489 | + next.delete(taskId); | |
| 1490 | + } | |
| 1491 | + return next; | |
| 1492 | + }); | |
| 1493 | + }); | |
| 1494 | + | |
| 1495 | + const markBackgroundTaskEnded = (taskId: string) => | |
| 1496 | + Ref.update(endedBackgroundTaskIds, (current) => { | |
| 1497 | + if (current.has(taskId)) return current; | |
| 1498 | + const next = new Set(current).add(taskId); | |
| 1499 | + for (const oldest of next) { | |
| 1500 | + if (next.size <= endedBackgroundTaskIdLimit) break; | |
| 1501 | + next.delete(oldest); | |
| 1502 | + } | |
| 1503 | + return next; | |
| 1504 | + }).pipe(Effect.andThen(setBackgroundTaskRunning(taskId, false))); | |
| 1505 | + | |
| 1506 | + const applyBackgroundTaskMutationRunning = (mutation: { | |
| 1507 | + readonly taskId: string; | |
| 1508 | + readonly status: "running" | "completed" | "failed"; | |
| 1509 | + }) => | |
| 1510 | + mutation.status === "running" | |
| 1511 | + ? setBackgroundTaskRunning(mutation.taskId, true) | |
| 1512 | + : markBackgroundTaskEnded(mutation.taskId); | |
| 1513 | + | |
| 1514 | + const trackRunningBackgroundTools = ( | |
| 1515 | + notification: EffectAcpSchema.SessionNotification, | |
| 1516 | + ): Effect.Effect<void> => | |
| 1517 | + Effect.gen(function* () { | |
| 1518 | + if (flavor.extractBackgroundTaskId === undefined) return; | |
| 1519 | + for (const event of parseSessionUpdateEvent(notification).events) { | |
| 1520 | + if (event._tag !== "ToolCallUpdated") continue; | |
| 1521 | + const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | |
| 1522 | + const taskId = flavor.extractBackgroundTaskId(toolCall); | |
| 1523 | + if (taskId === undefined) continue; | |
| 1524 | + const status = toolStatus(toolCall.status); | |
| 1525 | + yield* setBackgroundTaskRunning(taskId, status === "pending" || status === "running"); | |
| 1526 | + } | |
| 1527 | + }); | |
| 1528 | + | |
| 632 | 1529 | const emitProviderEvent = (event: ProviderAdapterV2Event) => |
| 633 | 1530 | Queue.offer(events, event).pipe(Effect.asVoid); |
| 1531 | + let scheduleSettleRootTurnWhenIdle = (_context: ActiveAcpTurn) => Effect.void; | |
| 1532 | + let rearmRootTurnRecoveryTimers = (_context: ActiveAcpTurn) => Effect.void; | |
| 1533 | + let scheduleDeferredFinalize: (context: ActiveAcpTurn) => Effect.Effect<void> = () => | |
| 1534 | + Effect.void; | |
| 634 | 1535 | |
| 635 | 1536 | const nativeLogging = options.nativeLogging?.(input.threadId); |
| 636 | − | |
| 637 | − const runtime = yield* flavor.makeRuntime({ | |
| 1537 | + const makeRuntimeInput = (runtimeGeneration: number): AcpAdapterV2RuntimeInput => ({ | |
| 638 | 1538 | cwd: input.runtimePolicy.cwd ?? process.cwd(), |
| 639 | 1539 | mcpServers: acpMcpServers(input.threadId), |
| 1540 | + interruptPromptOnCancel: flavor.interruptPromptOnCancel ?? false, | |
| 640 | 1541 | clientCapabilities: { |
| 641 | 1542 | fs: { readTextFile: false, writeTextFile: false }, |
| 642 | 1543 | terminal: false, |
| 643 | 1544 | elicitation: { form: {} }, |
| 644 | 1545 | }, |
| 645 | 1546 | clientInfo: { name: "t3-code", version: "0.0.0" }, |
| 1547 | + onIncomingRequest: (requestId, method, payload) => | |
| 1548 | + runRuntimeCallbackAtGeneration( | |
| 1549 | + runtimeGeneration, | |
| 1550 | + Effect.gen(function* () { | |
| 1551 | + const sequence = yield* Ref.getAndUpdate( | |
| 1552 | + nextNativeTransportSequence, | |
| 1553 | + (current) => current + 1, | |
| 1554 | + ); | |
| 1555 | + yield* Ref.update(nativeTransportRequests, (current) => [ | |
| 1556 | + ...current, | |
| 1557 | + { generation: runtimeGeneration, method, payload, requestId, sequence }, | |
| 1558 | + ]); | |
| 1559 | + }), | |
| 1560 | + ).pipe(Effect.asVoid), | |
| 1561 | + onTermination: () => | |
| 1562 | + runRuntimeCallbackAtGeneration( | |
| 1563 | + runtimeGeneration, | |
| 1564 | + Ref.set(runtimeRestartRequired, true), | |
| 1565 | + ).pipe(Effect.asVoid), | |
| 1566 | + onOutgoingResponseFailure: (requestId, error) => | |
| 1567 | + Ref.modify(nativeResponseAcknowledgements, (current) => { | |
| 1568 | + const entry = current.get(requestId); | |
| 1569 | + if (entry === undefined || entry.generation !== runtimeGeneration) { | |
| 1570 | + return [ | |
| 1571 | + emitNativeResponseLifecycle({ | |
| 1572 | + type: "late_noop", | |
| 1573 | + generation: runtimeGeneration, | |
| 1574 | + requestId, | |
| 1575 | + }), | |
| 1576 | + current, | |
| 1577 | + ] as const; | |
| 1578 | + } | |
| 1579 | + const updated = new Map(current); | |
| 1580 | + updated.delete(requestId); | |
| 1581 | + return [ | |
| 1582 | + Deferred.fail(entry.acknowledgement, error).pipe( | |
| 1583 | + Effect.andThen( | |
| 1584 | + emitNativeResponseLifecycle({ | |
| 1585 | + type: "removed", | |
| 1586 | + generation: runtimeGeneration, | |
| 1587 | + requestId, | |
| 1588 | + }), | |
| 1589 | + ), | |
| 1590 | + Effect.asVoid, | |
| 1591 | + ), | |
| 1592 | + updated, | |
| 1593 | + ] as const; | |
| 1594 | + }).pipe(Effect.flatten), | |
| 1595 | + onOutgoingResponse: (requestId) => | |
| 1596 | + Ref.modify(nativeResponseAcknowledgements, (current) => { | |
| 1597 | + const entry = current.get(requestId); | |
| 1598 | + if (entry === undefined || entry.generation !== runtimeGeneration) { | |
| 1599 | + return [ | |
| 1600 | + emitNativeResponseLifecycle({ | |
| 1601 | + type: "late_noop", | |
| 1602 | + generation: runtimeGeneration, | |
| 1603 | + requestId, | |
| 1604 | + }), | |
| 1605 | + current, | |
| 1606 | + ] as const; | |
| 1607 | + } | |
| 1608 | + const updated = new Map(current); | |
| 1609 | + updated.delete(requestId); | |
| 1610 | + return [ | |
| 1611 | + Deferred.succeed(entry.acknowledgement, undefined).pipe( | |
| 1612 | + Effect.andThen( | |
| 1613 | + emitNativeResponseLifecycle({ | |
| 1614 | + type: "removed", | |
| 1615 | + generation: runtimeGeneration, | |
| 1616 | + requestId, | |
| 1617 | + }), | |
| 1618 | + ), | |
| 1619 | + Effect.asVoid, | |
| 1620 | + ), | |
| 1621 | + updated, | |
| 1622 | + ] as const; | |
| 1623 | + }).pipe(Effect.flatten), | |
| 646 | 1624 | ...(nativeLogging?.requestLogger === undefined |
| 647 | 1625 | ? {} |
| 648 | 1626 | : { requestLogger: nativeLogging.requestLogger }), |
| @@ -652,6 +1630,13 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines | ||
| 652 | 1630 | logger: () => Effect.void, |
| 653 | 1631 | }, |
| 654 | 1632 | }); |
| 1633 | + let runtimeScope: Scope.Closeable | undefined; | |
| 1634 | + let runtime!: AcpSessionRuntime.AcpSessionRuntime["Service"]; | |
| 1635 | + yield* Effect.addFinalizer(() => | |
| 1636 | + runtimeScope === undefined | |
| 1637 | + ? Effect.void | |
| 1638 | + : Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore), | |
| 1639 | + ); | |
| 655 | 1640 | |
| 656 | 1641 | const resolveItemOrdinal = Effect.fnUntraced(function* ( |
| 657 | 1642 | context: ActiveAcpTurn, |
| @@ -811,6 +1796,9 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 153 unmodified lines | ||
| 811 | 1796 | if (stream.current === null) return; |
| 812 | 1797 | yield* emitTextSegment(context, kind, true); |
| 813 | 1798 | stream.current = null; |
| 1799 | + if (kind === "assistant") { | |
| 1800 | + yield* scheduleSettleRootTurnWhenIdle(context); | |
| 1801 | + } | |
| 814 | 1802 | }); |
| 815 | 1803 | |
| 816 | 1804 | const closeTextStreams = Effect.fnUntraced(function* (context: ActiveAcpTurn) { |
| @@ -883,39 +1871,56 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 66 unmodified lines | ||
| 883 | 1871 | context: ActiveAcpTurn, |
| 884 | 1872 | update: AcpAdapterV2SubagentUpdate, |
| 885 | 1873 | ) { |
| 886 | − const existing = context.subagents.get(update.nativeTaskId); | |
| 1874 | + // Hydration tools (get_command_or_subagent_output) use a new toolCallId | |
| 1875 | + // but reference the child via subagent_id / task id. | |
| 1876 | + const existing = | |
| 1877 | + context.subagents.get(update.nativeTaskId) ?? | |
| 1878 | + (update.childSessionId !== null | |
| 1879 | + ? context.subagentsBySessionId.get(update.childSessionId) | |
| 1880 | + : undefined); | |
| 1881 | + // get_command_or_subagent_output may target monitors/bash tasks. Only | |
| 1882 | + // hydrate when we already have a matching subagent lineage. Spawn ACKs | |
| 1883 | + // (non-empty prompt) may create a new lineage; empty-prompt hydration | |
| 1884 | + // with suppressNormalTool must not invent a phantom subagent. | |
| 1885 | + if (existing === undefined) { | |
| 1886 | + const isHydrationOnly = update.prompt === "" && update.title === null; | |
| 1887 | + if (isHydrationOnly || update.suppressNormalTool === false) { | |
| 1888 | + return; | |
| 1889 | + } | |
| 1890 | + } | |
| 887 | 1891 | const now = yield* DateTime.now; |
| 1892 | + const nativeTaskId = existing?.task.nativeTaskRef?.nativeId ?? update.nativeTaskId; | |
| 888 | 1893 | const nativeItemRef = { |
| 889 | 1894 | driver, |
| 890 | − nativeId: update.nativeTaskId, | |
| 1895 | + nativeId: nativeTaskId, | |
| 891 | 1896 | strength: "strong" as const, |
| 892 | 1897 | }; |
| 893 | 1898 | const nodeId = |
| 894 | 1899 | existing?.task.id ?? |
| 895 | 1900 | idAllocator.derive.nodeFromProviderItem({ |
| 896 | 1901 | driver, |
| 897 | − nativeItemId: update.nativeTaskId, | |
| 1902 | + nativeItemId: nativeTaskId, | |
| 898 | 1903 | }); |
| 899 | 1904 | const childThreadId = |
| 900 | 1905 | existing?.childThreadId ?? |
| 901 | 1906 | idAllocator.derive.threadFromProviderThread({ |
| 902 | 1907 | driver, |
| 903 | − nativeThreadId: `${nativeThreadId(driver, context.input.providerThread)}:task:${update.nativeTaskId}`, | |
| 1908 | + nativeThreadId: `${nativeThreadId(driver, context.input.providerThread)}:task:${nativeTaskId}`, | |
| 904 | 1909 | }); |
| 905 | 1910 | const childRootNodeId = |
| 906 | 1911 | existing?.childRootNodeId ?? |
| 907 | 1912 | idAllocator.derive.nodeFromProviderItem({ |
| 908 | 1913 | driver, |
| 909 | − nativeItemId: `${update.nativeTaskId}:child-root`, | |
| 1914 | + nativeItemId: `${nativeTaskId}:child-root`, | |
| 910 | 1915 | }); |
| 911 | 1916 | const turnItemId = |
| 912 | 1917 | existing?.turnItemId ?? |
| 913 | 1918 | idAllocator.derive.turnItemFromProviderItem({ |
| 914 | 1919 | driver, |
| 915 | − nativeItemId: update.nativeTaskId, | |
| 1920 | + nativeItemId: nativeTaskId, | |
| 916 | 1921 | }); |
| 917 | 1922 | const turnItemOrdinal = |
| 918 | − existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(context, update.nativeTaskId)); | |
| 1923 | + existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(context, nativeTaskId)); | |
| 919 | 1924 | const taskStatus = update.status; |
| 920 | 1925 | const task: OrchestrationV2Subagent = { |
| 921 | 1926 | ...(existing?.task ?? { |
| @@ -947,12 +1952,14 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 25 unmodified lines | ||
| 947 | 1952 | childRootNodeId, |
| 948 | 1953 | turnItemId, |
| 949 | 1954 | turnItemOrdinal, |
| 1955 | + providerTurnId: context.providerTurnId, | |
| 1956 | + parentProviderThreadId: context.input.providerThread.id, | |
| 950 | 1957 | childSessionId: null, |
| 951 | 1958 | assistantText: "", |
| 952 | 1959 | nextChildOrdinal: 101, |
| 953 | 1960 | }; |
| 954 | 1961 | subagent.task = task; |
| 955 | − context.subagents.set(update.nativeTaskId, subagent); | |
| 1962 | + context.subagents.set(nativeTaskId, subagent); | |
| 956 | 1963 | |
| 957 | 1964 | if (existing === undefined) { |
| 958 | 1965 | yield* emitProviderEvent({ |
| @@ -979,7 +1986,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 20 unmodified lines | ||
| 979 | 1986 | creationSource: "provider", |
| 980 | 1987 | }), |
| 981 | 1988 | }); |
| 982 | − const promptNativeItemId = `${update.nativeTaskId}:prompt`; | |
| 1989 | + const promptNativeItemId = `${nativeTaskId}:prompt`; | |
| 983 | 1990 | const promptArtifacts = makeSubagentConversationArtifacts({ |
| 984 | 1991 | messageId: idAllocator.derive.messageFromProviderItem({ |
| 985 | 1992 | driver, |
| @@ -1064,14 +2071,14 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 78 unmodified lines | ||
| 1064 | 2071 | node: { |
| 1065 | 2072 | id: nodeId, |
| 1066 | 2073 | threadId: context.input.threadId, |
| 1067 | − runId: context.input.runId, | |
| 1068 | − parentNodeId: context.input.rootNodeId, | |
| 1069 | − rootNodeId: context.input.rootNodeId, | |
| 2074 | + runId: subagent.task.runId, | |
| 2075 | + parentNodeId: subagent.task.parentNodeId, | |
| 2076 | + rootNodeId: subagent.task.parentNodeId, | |
| 1070 | 2077 | kind: "subagent", |
| 1071 | 2078 | status: taskStatus, |
| 1072 | 2079 | countsForRun: false, |
| 1073 | 2080 | providerThreadId: context.input.providerThread.id, |
| 1074 | − providerTurnId: context.providerTurnId, | |
| 2081 | + providerTurnId: subagent.providerTurnId, | |
| 1075 | 2082 | nativeItemRef, |
| 1076 | 2083 | runtimeRequestId: null, |
| 1077 | 2084 | checkpointScopeId: null, |
| @@ -1107,10 +2114,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 29 unmodified lines | ||
| 1107 | 2114 | turnItem: { |
| 1108 | 2115 | id: turnItemId, |
| 1109 | 2116 | threadId: context.input.threadId, |
| 1110 | − runId: context.input.runId, | |
| 2117 | + runId: subagent.task.runId, | |
| 1111 | 2118 | nodeId, |
| 1112 | 2119 | providerThreadId: context.input.providerThread.id, |
| 1113 | − providerTurnId: context.providerTurnId, | |
| 2120 | + providerTurnId: subagent.providerTurnId, | |
| 1114 | 2121 | nativeItemRef, |
| 1115 | 2122 | parentItemId: null, |
| 1116 | 2123 | ordinal: turnItemOrdinal, |
| @@ -1131,23 +2138,231 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 14 unmodified lines | ||
| 1131 | 2138 | }); |
| 1132 | 2139 | }); |
| 1133 | 2140 | |
| 1134 | − const emitTool = Effect.fnUntraced(function* ( | |
| 2141 | + const toolOutputText = (toolCall: AcpToolCallState): string => { | |
| 2142 | + if (typeof toolCall.data.rawOutput === "string") { | |
| 2143 | + return toolCall.data.rawOutput; | |
| 2144 | + } | |
| 2145 | + if ( | |
| 2146 | + typeof (toolCall.data.rawOutput as { text?: unknown } | undefined)?.text === "string" | |
| 2147 | + ) { | |
| 2148 | + return String((toolCall.data.rawOutput as { text: string }).text); | |
| 2149 | + } | |
| 2150 | + return textFromUnknown(toolCall.data.rawOutput) ?? ""; | |
| 2151 | + }; | |
| 2152 | + | |
| 2153 | + const setToolOutputText = (toolCall: AcpToolCallState, text: string): AcpToolCallState => ({ | |
| 2154 | + ...toolCall, | |
| 2155 | + data: { | |
| 2156 | + ...toolCall.data, | |
| 2157 | + rawOutput: { | |
| 2158 | + type: "Text", | |
| 2159 | + text, | |
| 2160 | + }, | |
| 2161 | + }, | |
| 2162 | + }); | |
| 2163 | + | |
| 2164 | + const appendToolOutputText = ( | |
| 2165 | + toolCall: AcpToolCallState, | |
| 2166 | + appendOutput: string, | |
| 2167 | + ): AcpToolCallState => { | |
| 2168 | + if (appendOutput.length === 0) return toolCall; | |
| 2169 | + return setToolOutputText(toolCall, `${toolOutputText(toolCall)}${appendOutput}`); | |
| 2170 | + }; | |
| 2171 | + | |
| 2172 | + const isMonitorEndNoticeText = (text: string): boolean => | |
| 2173 | + /Monitor\s+["']?[0-9a-f-]{8,}/i.test(text) && /ended/i.test(text); | |
| 2174 | + | |
| 2175 | + const hasDeferredBackgroundWork = (context: ActiveAcpTurn): boolean => { | |
| 2176 | + if (context.awaitingBackgroundHydration.size > 0) return true; | |
| 2177 | + if (context.pendingInjectedReport.size > 0) return true; | |
| 2178 | + for (const [taskId, toolCallId] of context.toolCallIdsByBackgroundTaskId) { | |
| 2179 | + // Persistent monitors stream after root settle; do not pin finalize. | |
| 2180 | + if (context.persistentBackgroundTaskIds.has(taskId)) continue; | |
| 2181 | + const tool = context.tools.get(toolCallId); | |
| 2182 | + if (tool === undefined) continue; | |
| 2183 | + const status = toolStatus(tool.status); | |
| 2184 | + if (status === "pending" || status === "running") return true; | |
| 2185 | + } | |
| 2186 | + for (const subagent of context.subagents.values()) { | |
| 2187 | + if (subagent.task.status === "running" || subagent.task.status === "pending") { | |
| 2188 | + return true; | |
| 2189 | + } | |
| 2190 | + } | |
| 2191 | + return false; | |
| 2192 | + }; | |
| 2193 | + | |
| 2194 | + // scheduleDeferredFinalize is declared above openSession body start and | |
| 2195 | + // assigned after finalizeTurn so forked finalize Effect typing stays clean. | |
| 2196 | + const rearmDeferredFinalize = (context: ActiveAcpTurn) => | |
| 2197 | + Effect.gen(function* () { | |
| 2198 | + if (!flavor.deferFinalizeForBackgroundWork) return; | |
| 2199 | + if (!context.promptSettled || context.finalized) return; | |
| 2200 | + if (hasDeferredBackgroundWork(context)) return; | |
| 2201 | + yield* scheduleDeferredFinalize(context); | |
| 2202 | + }); | |
| 2203 | + | |
| 2204 | + // `let` breaks circular inference from monitor hydration re-entry. | |
| 2205 | + let emitTool: ( | |
| 2206 | + context: ActiveAcpTurn, | |
| 2207 | + incoming: AcpToolCallState, | |
| 2208 | + projectedStatus?: ProjectedToolStatus, | |
| 2209 | + ) => Effect.Effect<void> = () => Effect.void; | |
| 2210 | + | |
| 2211 | + const markAwaitingBackgroundHydration = (context: ActiveAcpTurn, taskId: string) => | |
| 2212 | + Effect.gen(function* () { | |
| 2213 | + if (context.awaitingBackgroundHydration.has(taskId)) return; | |
| 2214 | + context.awaitingBackgroundHydration.add(taskId); | |
| 2215 | + // Safety: do not hold the root turn forever if the agent never hydrates. | |
| 2216 | + yield* Effect.gen(function* () { | |
| 2217 | + yield* Effect.sleep("60000 millis"); | |
| 2218 | + if (context.finalized || !context.awaitingBackgroundHydration.has(taskId)) return; | |
| 2219 | + context.awaitingBackgroundHydration.delete(taskId); | |
| 2220 | + // End notices keep the tool running until hydration; force-complete so | |
| 2221 | + // deferred finalize can proceed when get_command never arrives. | |
| 2222 | + const toolCallId = context.toolCallIdsByBackgroundTaskId.get(taskId); | |
| 2223 | + const tool = toolCallId !== undefined ? context.tools.get(toolCallId) : undefined; | |
| 2224 | + if (tool !== undefined) { | |
| 2225 | + const status = toolStatus(tool.status); | |
| 2226 | + if (status === "pending" || status === "running") { | |
| 2227 | + yield* emitTool(context, { ...tool, status: "completed" }); | |
| 2228 | + } | |
| 2229 | + } | |
| 2230 | + yield* rearmDeferredFinalize(context); | |
| 2231 | + }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); | |
| 2232 | + }); | |
| 2233 | + | |
| 2234 | + const markPendingInjectedReport = (context: ActiveAcpTurn, taskId: string) => | |
| 2235 | + Effect.gen(function* () { | |
| 2236 | + // Only the settled-and-held window has the race; mid-turn reports | |
| 2237 | + // are protected by the prompt RPC still being open. | |
| 2238 | + if (!context.promptSettled) return; | |
| 2239 | + if (context.pendingInjectedReport.has(taskId)) return; | |
| 2240 | + context.pendingInjectedReport.add(taskId); | |
| 2241 | + // Safety: the injected turn may end without a report chunk. | |
| 2242 | + yield* Effect.gen(function* () { | |
| 2243 | + yield* Effect.sleep("25000 millis"); | |
| 2244 | + if (context.finalized || !context.pendingInjectedReport.has(taskId)) return; | |
| 2245 | + context.pendingInjectedReport.delete(taskId); | |
| 2246 | + yield* rearmDeferredFinalize(context); | |
| 2247 | + }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); | |
| 2248 | + }); | |
| 2249 | + | |
| 2250 | + emitTool = Effect.fnUntraced(function* ( | |
| 1135 | 2251 | context: ActiveAcpTurn, |
| 1136 | 2252 | incoming: AcpToolCallState, |
| 2253 | + projectedStatus?: ProjectedToolStatus, | |
| 1137 | 2254 | ) { |
| 1138 | 2255 | yield* closeTextStreams(context); |
| 1139 | 2256 | const previous = context.tools.get(incoming.toolCallId); |
| 1140 | − const toolCall = mergeToolCallState(previous, incoming); | |
| 2257 | + const merged = mergeToolCallState(previous, incoming); | |
| 2258 | + const toolCall = flavor.normalizeToolCall?.(merged) ?? merged; | |
| 1141 | 2259 | context.tools.set(toolCall.toolCallId, toolCall); |
| 1142 | − const subagentUpdate = flavor.extractSubagentUpdate?.(toolCall); | |
| 1143 | − if (subagentUpdate !== undefined) { | |
| 1144 | − yield* emitSubagent(context, subagentUpdate); | |
| 1145 | − return; | |
| 2260 | + const backgroundTaskId = flavor.extractBackgroundTaskId?.(toolCall); | |
| 2261 | + if (backgroundTaskId !== undefined) { | |
| 2262 | + context.toolCallIdsByBackgroundTaskId.set(backgroundTaskId, toolCall.toolCallId); | |
| 2263 | + if (flavor.isPersistentBackgroundTool?.(toolCall) === true) { | |
| 2264 | + context.persistentBackgroundTaskIds.add(backgroundTaskId); | |
| 2265 | + } | |
| 2266 | + const backgroundStatus = projectedStatus ?? toolStatus(toolCall.status); | |
| 2267 | + yield* setBackgroundTaskRunning( | |
| 2268 | + backgroundTaskId, | |
| 2269 | + backgroundStatus === "pending" || backgroundStatus === "running", | |
| 2270 | + ); | |
| 2271 | + // Background tool that reaches a terminal status while the root | |
| 2272 | + // prompt is still open (completed, failed, or interrupted) was | |
| 2273 | + // consumed in-turn. Mark handled so late CLI re-reports and residual | |
| 2274 | + // agent chatter do not open synthetic "Background task completed." | |
| 2275 | + // runs. get_command TaskOutput still marks handled below. | |
| 2276 | + // Only before promptSettled: after STARTED the deferred-finalize | |
| 2277 | + // hold can let a monitor finish while the turn is still active; | |
| 2278 | + // marking then would suppress the legitimate post-settle TaskOutput | |
| 2279 | + // continuation (live: grok-post-settle-continuation-poll). | |
| 2280 | + // Terminal statuses only after normalizeToolCall (start ACKs stay | |
| 2281 | + // inProgress/running). | |
| 2282 | + if ( | |
| 2283 | + !context.promptSettled && | |
| 2284 | + backgroundStatus !== "pending" && | |
| 2285 | + backgroundStatus !== "running" | |
| 2286 | + ) { | |
| 2287 | + yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => | |
| 2288 | + new Set(current).add(backgroundTaskId), | |
| 2289 | + ); | |
| 2290 | + } | |
| 1146 | 2291 | } |
| 1147 | − const status = toolStatus(toolCall.status); | |
| 1148 | − const now = yield* DateTime.now; | |
| 1149 | − const nativeItemId = `${nativeThreadId(driver, context.input.providerThread)}:tool:${toolCall.toolCallId}`; | |
| 1150 | − const ordinal = yield* resolveItemOrdinal(context, nativeItemId); | |
| 2292 | + | |
| 2293 | + // get_command TaskOutput for registered monitor(s): hydrate those tools. | |
| 2294 | + const backgroundCompletions = | |
| 2295 | + projectedStatus === undefined | |
| 2296 | + ? (flavor.extractBackgroundTaskCompletion?.(toolCall) ?? []) | |
| 2297 | + : []; | |
| 2298 | + let hydratedRegisteredMonitor = false; | |
| 2299 | + for (const backgroundCompletion of backgroundCompletions) { | |
| 2300 | + // Genuine end signal when terminal: tombstone so straggler | |
| 2301 | + // monitor-event chatter cannot resurrect the running set after the | |
| 2302 | + // task truly ended. A still-running poll keeps the id running. | |
| 2303 | + yield* applyBackgroundTaskMutationRunning(backgroundCompletion); | |
| 2304 | + if (backgroundCompletion.status !== "running") { | |
| 2305 | + yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => | |
| 2306 | + new Set(current).add(backgroundCompletion.taskId), | |
| 2307 | + ); | |
| 2308 | + } | |
| 2309 | + // A still-running fetch must keep the hydration hold (and its | |
| 2310 | + // safety timer) alive until output actually lands. | |
| 2311 | + if (backgroundCompletion.status !== "running") { | |
| 2312 | + context.awaitingBackgroundHydration.delete(backgroundCompletion.taskId); | |
| 2313 | + } | |
| 2314 | + const targetToolCallId = context.toolCallIdsByBackgroundTaskId.get( | |
| 2315 | + backgroundCompletion.taskId, | |
| 2316 | + ); | |
| 2317 | + // Known background-task id (monitor registration) — never open a | |
| 2318 | + // phantom subagent for the same get_output poll. | |
| 2319 | + if (targetToolCallId !== undefined) { | |
| 2320 | + hydratedRegisteredMonitor = true; | |
| 2321 | + } | |
| 2322 | + const target = | |
| 2323 | + targetToolCallId !== undefined ? context.tools.get(targetToolCallId) : undefined; | |
| 2324 | + if (target !== undefined && target.toolCallId !== toolCall.toolCallId) { | |
| 2325 | + const nextStatus = | |
| 2326 | + backgroundCompletion.status === "running" | |
| 2327 | + ? ("inProgress" as const) | |
| 2328 | + : backgroundCompletion.status === "failed" | |
| 2329 | + ? ("failed" as const) | |
| 2330 | + : ("completed" as const); | |
| 2331 | + const hydrated = | |
| 2332 | + backgroundCompletion.appendOutput.length > 0 | |
| 2333 | + ? // TaskOutput is the real stdout; replace end-notice boilerplate | |
| 2334 | + // so the timeline shows the listing, not only "Monitor ended…". | |
| 2335 | + isMonitorEndNoticeText(toolOutputText(target)) || | |
| 2336 | + toolOutputText(target).trim().length === 0 | |
| 2337 | + ? setToolOutputText( | |
| 2338 | + { ...target, status: nextStatus }, | |
| 2339 | + backgroundCompletion.appendOutput, | |
| 2340 | + ) | |
| 2341 | + : appendToolOutputText( | |
| 2342 | + { ...target, status: nextStatus }, | |
| 2343 | + backgroundCompletion.appendOutput, | |
| 2344 | + ) | |
| 2345 | + : { ...target, status: nextStatus }; | |
| 2346 | + yield* emitTool(context, hydrated); | |
| 2347 | + } | |
| 2348 | + } | |
| 2349 | + | |
| 2350 | + // Monitor TaskOutput shares the get_command tool shape with subagent | |
| 2351 | + // hydration; do not spawn a phantom subagent for a registered monitor. | |
| 2352 | + const subagentUpdate = hydratedRegisteredMonitor | |
| 2353 | + ? undefined | |
| 2354 | + : flavor.extractSubagentUpdate?.(toolCall); | |
| 2355 | + if (subagentUpdate !== undefined) { | |
| 2356 | + yield* emitSubagent(context, subagentUpdate); | |
| 2357 | + if (subagentUpdate.suppressNormalTool !== false) { | |
| 2358 | + yield* rearmDeferredFinalize(context); | |
| 2359 | + return; | |
| 2360 | + } | |
| 2361 | + } | |
| 2362 | + const status = projectedStatus ?? toolStatus(toolCall.status); | |
| 2363 | + const now = yield* DateTime.now; | |
| 2364 | + const nativeItemId = `${nativeThreadId(driver, context.input.providerThread)}:tool:${toolCall.toolCallId}`; | |
| 2365 | + const ordinal = yield* resolveItemOrdinal(context, nativeItemId); | |
| 1151 | 2366 | const nodeId = idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId }); |
| 1152 | 2367 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ |
| 1153 | 2368 | driver, |
| @@ -1203,6 +2418,30 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 49 unmodified lines | ||
| 1203 | 2418 | const rawInput = toolCall.data.rawInput; |
| 1204 | 2419 | const rawOutput = toolCall.data.rawOutput ?? toolCall.data.content; |
| 1205 | 2420 | const path = pathFromToolCall(toolCall); |
| 2421 | + const rawInputRecord = unknownRecord(rawInput); | |
| 2422 | + const inputVariant = | |
| 2423 | + typeof rawInputRecord?.variant === "string" | |
| 2424 | + ? rawInputRecord.variant.trim().toLowerCase() | |
| 2425 | + : ""; | |
| 2426 | + const rawOutputRecord = unknownRecord(rawOutput); | |
| 2427 | + const outputCommand = | |
| 2428 | + typeof rawOutputRecord?.command === "string" && | |
| 2429 | + rawOutputRecord.command.trim().length > 0 | |
| 2430 | + ? rawOutputRecord.command.trim() | |
| 2431 | + : undefined; | |
| 2432 | + const monitorCommand = | |
| 2433 | + (typeof rawInputRecord?.command === "string" && rawInputRecord.command.trim().length > 0 | |
| 2434 | + ? rawInputRecord.command.trim() | |
| 2435 | + : undefined) ?? outputCommand; | |
| 2436 | + const outputIsBashResult = | |
| 2437 | + typeof rawOutputRecord?.type === "string" && | |
| 2438 | + rawOutputRecord.type.trim().toLowerCase() === "bash" && | |
| 2439 | + commandExitCode(rawOutput) !== undefined; | |
| 2440 | + // Grok Monitor tools arrive as generic kind + variant; project like shell | |
| 2441 | + // so stdout is plain text in the timeline (not JSON {type:Text,text:...}). | |
| 2442 | + // Post-settle wake re-reports of a finished monitor carry no rawInput at | |
| 2443 | + // all, only a structured Bash result; project those as commands too. | |
| 2444 | + const projectAsCommandExecution = inputVariant === "monitor" || outputIsBashResult; | |
| 1206 | 2445 | let turnItem: OrchestrationV2TurnItem; |
| 1207 | 2446 | switch (toolCall.kind) { |
| 1208 | 2447 | case "read": |
| @@ -1225,19 +2464,19 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 16 unmodified lines | ||
| 1225 | 2464 | }), |
| 1226 | 2465 | }; |
| 1227 | 2466 | break; |
| 1228 | − case "execute": | |
| 2467 | + case "execute": { | |
| 2468 | + const exitCode = acpProjectedCommandExitCode(status, rawOutput); | |
| 1229 | 2469 | turnItem = { |
| 1230 | 2470 | ...base, |
| 1231 | 2471 | type: "command_execution", |
| 1232 | − input: toolCall.command ?? toolCall.title ?? "Command", | |
| 2472 | + input: toolCall.command ?? monitorCommand ?? toolCall.title ?? "Command", | |
| 1233 | 2473 | ...(textFromUnknown(rawOutput) === undefined |
| 1234 | 2474 | ? {} |
| 1235 | 2475 | : { output: textFromUnknown(rawOutput) }), |
| 1236 | − ...(commandExitCode(rawOutput) === undefined | |
| 1237 | − ? {} | |
| 1238 | − : { exitCode: commandExitCode(rawOutput) }), | |
| 2476 | + ...(exitCode === undefined ? {} : { exitCode }), | |
| 1239 | 2477 | }; |
| 1240 | 2478 | break; |
| 2479 | + } | |
| 1241 | 2480 | case "edit": |
| 1242 | 2481 | case "delete": |
| 1243 | 2482 | case "move": |
| @@ -1270,15 +2509,33 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 26 unmodified lines | ||
| 1270 | 2509 | }; |
| 1271 | 2510 | break; |
| 1272 | 2511 | default: |
| 1273 | − turnItem = { | |
| 1274 | − ...base, | |
| 1275 | − type: "dynamic_tool", | |
| 1276 | − toolName: toolCall.title ?? toolCall.kind ?? null, | |
| 1277 | − input: rawInput ?? {}, | |
| 1278 | − ...(rawOutput === undefined ? {} : { output: rawOutput }), | |
| 1279 | − }; | |
| 2512 | + if (projectAsCommandExecution) { | |
| 2513 | + const exitCode = acpProjectedCommandExitCode(status, rawOutput); | |
| 2514 | + turnItem = { | |
| 2515 | + ...base, | |
| 2516 | + type: "command_execution", | |
| 2517 | + input: | |
| 2518 | + toolCall.command ?? | |
| 2519 | + monitorCommand ?? | |
| 2520 | + toolCall.title ?? | |
| 2521 | + (inputVariant === "monitor" ? "Monitor" : "Command"), | |
| 2522 | + ...(textFromUnknown(rawOutput) === undefined | |
| 2523 | + ? {} | |
| 2524 | + : { output: textFromUnknown(rawOutput) }), | |
| 2525 | + ...(exitCode === undefined ? {} : { exitCode }), | |
| 2526 | + }; | |
| 2527 | + } else { | |
| 2528 | + turnItem = { | |
| 2529 | + ...base, | |
| 2530 | + type: "dynamic_tool", | |
| 2531 | + toolName: toolCall.title ?? toolCall.kind ?? null, | |
| 2532 | + input: rawInput ?? {}, | |
| 2533 | + ...(rawOutput === undefined ? {} : { output: rawOutput }), | |
| 2534 | + }; | |
| 2535 | + } | |
| 1280 | 2536 | } |
| 1281 | 2537 | yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem }); |
| 2538 | + yield* rearmDeferredFinalize(context); | |
| 1282 | 2539 | }); |
| 1283 | 2540 | |
| 1284 | 2541 | const emitPlan = Effect.fnUntraced(function* ( |
| @@ -1417,22 +2674,287 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 132 unmodified lines | ||
| 1417 | 2674 | }); |
| 1418 | 2675 | }); |
| 1419 | 2676 | |
| 2677 | + const offerContinuationRun = Effect.fnUntraced(function* (sessionId: string) { | |
| 2678 | + if (continuationRequests === undefined) { | |
| 2679 | + return; | |
| 2680 | + } | |
| 2681 | + const pending = yield* continuationPermit.withPermit( | |
| 2682 | + Effect.gen(function* () { | |
| 2683 | + if (yield* Ref.get(continuationClosed)) return Option.none(); | |
| 2684 | + if (yield* Ref.get(stoppedRunQuarantine)) return Option.none(); | |
| 2685 | + if (yield* Ref.get(continuationRequested)) return Option.none(); | |
| 2686 | + const route = yield* Ref.get(lastTurnRoute); | |
| 2687 | + if (route === null) return Option.none(); | |
| 2688 | + yield* Ref.set(continuationRequested, true); | |
| 2689 | + const generation = yield* Ref.updateAndGet( | |
| 2690 | + continuationGeneration, | |
| 2691 | + (value) => value + 1, | |
| 2692 | + ); | |
| 2693 | + return Option.some({ route, generation }); | |
| 2694 | + }), | |
| 2695 | + ); | |
| 2696 | + if (Option.isNone(pending)) return; | |
| 2697 | + const { route, generation } = pending.value; | |
| 2698 | + yield* Effect.logInfo("orchestration-v2.acp-wake-turn-detected", { | |
| 2699 | + driver, | |
| 2700 | + providerSessionId: input.providerSessionId, | |
| 2701 | + threadId: route.threadId, | |
| 2702 | + providerThreadId: route.providerThreadId, | |
| 2703 | + }); | |
| 2704 | + yield* continuationRequests.offer({ | |
| 2705 | + threadId: route.threadId, | |
| 2706 | + providerThreadId: route.providerThreadId, | |
| 2707 | + driver, | |
| 2708 | + detail: null, | |
| 2709 | + dispatchIfCurrent: (effect) => | |
| 2710 | + continuationPermit.withPermit( | |
| 2711 | + Effect.gen(function* () { | |
| 2712 | + const clearIfOwner = Effect.gen(function* () { | |
| 2713 | + if ((yield* Ref.get(continuationGeneration)) === generation) { | |
| 2714 | + yield* Ref.set(continuationRequested, false); | |
| 2715 | + } | |
| 2716 | + }); | |
| 2717 | + if (yield* Ref.get(stoppedRunQuarantine)) { | |
| 2718 | + yield* clearIfOwner; | |
| 2719 | + return Option.none(); | |
| 2720 | + } | |
| 2721 | + if ((yield* Ref.get(continuationGeneration)) !== generation) { | |
| 2722 | + // Superseded by a newer offer; do not clear its flag. | |
| 2723 | + return Option.none(); | |
| 2724 | + } | |
| 2725 | + if (!(yield* Ref.get(continuationRequested))) return Option.none(); | |
| 2726 | + // Clear after success or failure so a dropped dispatch does not | |
| 2727 | + // stick the flag and pin hasPendingBackgroundWork forever. | |
| 2728 | + const exit = yield* Effect.exit(effect); | |
| 2729 | + yield* clearIfOwner; | |
| 2730 | + if (Exit.isFailure(exit)) { | |
| 2731 | + return yield* Effect.failCause(exit.cause); | |
| 2732 | + } | |
| 2733 | + return Option.some(exit.value); | |
| 2734 | + }), | |
| 2735 | + ), | |
| 2736 | + }); | |
| 2737 | + }); | |
| 2738 | + | |
| 2739 | + const applyLateBackgroundMutation = Effect.fnUntraced(function* ( | |
| 2740 | + sessionId: string, | |
| 2741 | + mutation: { | |
| 2742 | + readonly taskId: string; | |
| 2743 | + readonly status: "running" | "completed" | "failed"; | |
| 2744 | + }, | |
| 2745 | + ) { | |
| 2746 | + const taskAlreadyEnded = (yield* Ref.get(endedBackgroundTaskIds)).has(mutation.taskId); | |
| 2747 | + yield* applyBackgroundTaskMutationRunning(mutation); | |
| 2748 | + if (taskAlreadyEnded && acpPostSettleMonitorPromptShouldSuppress(mutation)) { | |
| 2749 | + yield* Ref.set(suppressPostSettleMonitorPrompt, true); | |
| 2750 | + } | |
| 2751 | + if (mutation.status !== "running") { | |
| 2752 | + yield* Ref.set(suppressPostSettleMonitorPrompt, false); | |
| 2753 | + yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => { | |
| 2754 | + if (!current.has(mutation.taskId)) return current; | |
| 2755 | + const next = new Set(current); | |
| 2756 | + next.delete(mutation.taskId); | |
| 2757 | + return next; | |
| 2758 | + }); | |
| 2759 | + if ( | |
| 2760 | + postSettleContinuationEnabled && | |
| 2761 | + (yield* Ref.get(activeSessionId)) === sessionId && | |
| 2762 | + (yield* Ref.get(runningBackgroundTaskIds)).size === 0 && | |
| 2763 | + (yield* Ref.get(wakeBuffer)).length > 0 | |
| 2764 | + ) { | |
| 2765 | + yield* offerContinuationRun(sessionId); | |
| 2766 | + } | |
| 2767 | + } | |
| 2768 | + }); | |
| 2769 | + | |
| 2770 | + const bufferPostSettleWake = Effect.fnUntraced(function* ( | |
| 2771 | + notification: EffectAcpSchema.SessionNotification, | |
| 2772 | + ) { | |
| 2773 | + if (!postSettleContinuationEnabled || continuationRequests === undefined) { | |
| 2774 | + return false; | |
| 2775 | + } | |
| 2776 | + // Direct Stop quarantine: drop residual wake evidence instead of | |
| 2777 | + // buffering it for a later continuation or follow-up run. | |
| 2778 | + if (yield* Ref.get(stoppedRunQuarantine)) { | |
| 2779 | + return true; | |
| 2780 | + } | |
| 2781 | + const rootSessionId = yield* Ref.get(activeSessionId); | |
| 2782 | + if (rootSessionId === null || notification.sessionId !== rootSessionId) { | |
| 2783 | + return false; | |
| 2784 | + } | |
| 2785 | + if (!acpPostSettleWakeEvidence(notification, flavor)) { | |
| 2786 | + return false; | |
| 2787 | + } | |
| 2788 | + const update = notification.update; | |
| 2789 | + let alreadyHandledToolUpdate = false; | |
| 2790 | + if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { | |
| 2791 | + yield* trackRunningBackgroundTools(notification); | |
| 2792 | + // When the agent consumes the monitor output itself via | |
| 2793 | + // get_command_or_subagent_output (long timeout), the TaskOutput | |
| 2794 | + // completion is the ONLY end signal: no "Monitor ended" reminder | |
| 2795 | + // follows. Without applying it here the running set never clears, | |
| 2796 | + // every offer stays suppressed, and the wake buffer never drains | |
| 2797 | + // (observed live 2026-07-12, thread 8dbe607f). The completion | |
| 2798 | + // frame itself is offer evidence, so clearing the id before the | |
| 2799 | + // gate below lets it open the single continuation run. | |
| 2800 | + for (const event of parseSessionUpdateEvent(notification).events) { | |
| 2801 | + if (event._tag !== "ToolCallUpdated") continue; | |
| 2802 | + const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | |
| 2803 | + const toolTaskId = flavor.extractBackgroundTaskId?.(toolCall); | |
| 2804 | + if ( | |
| 2805 | + toolTaskId !== undefined && | |
| 2806 | + (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(toolTaskId) | |
| 2807 | + ) { | |
| 2808 | + alreadyHandledToolUpdate = true; | |
| 2809 | + } | |
| 2810 | + if (flavor.extractBackgroundTaskCompletion !== undefined) { | |
| 2811 | + for (const completion of flavor.extractBackgroundTaskCompletion(toolCall)) { | |
| 2812 | + alreadyHandledToolUpdate = | |
| 2813 | + alreadyHandledToolUpdate || | |
| 2814 | + (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(completion.taskId); | |
| 2815 | + yield* applyBackgroundTaskMutationRunning(completion); | |
| 2816 | + } | |
| 2817 | + } | |
| 2818 | + } | |
| 2819 | + } | |
| 2820 | + const backgroundWorkRunning = (yield* Ref.get(runningBackgroundTaskIds)).size > 0; | |
| 2821 | + // Grok prompts itself for every monitor event after the root turn | |
| 2822 | + // settles. Its assistant/reasoning replies are progress chatter, not | |
| 2823 | + // separate wake results. Retaining them would replay the entire burst | |
| 2824 | + // into the single continuation once the monitor finishes. Keep tool | |
| 2825 | + // state so the final command card still hydrates, then begin retaining | |
| 2826 | + // agent output again after the genuine end signal clears the running | |
| 2827 | + // set. With multiple tasks, text remains best-effort until every task | |
| 2828 | + // ends; their retained tool cards are the authoritative results. | |
| 2829 | + // | |
| 2830 | + // Skip retaining frames for tasks already hydrated in the root turn | |
| 2831 | + // (`handledBackgroundTaskIdsInActiveTurn`). Those re-reports must not | |
| 2832 | + // pin `hasPendingBackgroundWork` via a wake buffer that never drains | |
| 2833 | + // (the already-handled gate below intentionally skips | |
| 2834 | + // `offerContinuationRun` to avoid synthetic "Background task | |
| 2835 | + // completed." spam). | |
| 2836 | + if ( | |
| 2837 | + !alreadyHandledToolUpdate && | |
| 2838 | + acpPostSettleWakeShouldBuffer(notification, backgroundWorkRunning) | |
| 2839 | + ) { | |
| 2840 | + yield* Ref.update(wakeBuffer, (current) => [...current, notification]); | |
| 2841 | + } | |
| 2842 | + // Buffer progress without offering; only completion-like frames open a run. | |
| 2843 | + if (!acpPostSettleContinuationOfferEvidence(notification, flavor)) { | |
| 2844 | + return true; | |
| 2845 | + } | |
| 2846 | + // While a monitor is still streaming, tool re-reports buffer without | |
| 2847 | + // offering and per-event agent commentary is consumed without being | |
| 2848 | + // retained. Grok re-reports a running monitor as Bash frames that | |
| 2849 | + // already carry exit_code 0 mid-stream, so a "terminal" normalized | |
| 2850 | + // status is not evidence the task ended; each burst would otherwise | |
| 2851 | + // reopen a synthetic "Background task completed." run. Retained tool | |
| 2852 | + // frames drain into the single continuation offered once the monitor | |
| 2853 | + // actually ends (end-notice mutation below, or the first frame after | |
| 2854 | + // it). | |
| 2855 | + if (backgroundWorkRunning) { | |
| 2856 | + return true; | |
| 2857 | + } | |
| 2858 | + if (alreadyHandledToolUpdate) { | |
| 2859 | + // Drop leftover wake noise for in-turn-handled work so idle release | |
| 2860 | + // is not pinned forever. Leave the buffer alone when a continuation | |
| 2861 | + // is already outstanding: its startTurn will drain legitimate frames | |
| 2862 | + // from other tasks. | |
| 2863 | + if (!(yield* Ref.get(continuationRequested))) { | |
| 2864 | + yield* Ref.set(wakeBuffer, []); | |
| 2865 | + } | |
| 2866 | + return true; | |
| 2867 | + } | |
| 2868 | + // Residual Grok agent/thought chatter after in-turn-handled background | |
| 2869 | + // work must not open synthetic continuation runs. Tool-path | |
| 2870 | + // alreadyHandled covers re-reports with a task id; this covers | |
| 2871 | + // agent_message_chunk frames that carry no task id (live: | |
| 2872 | + // grok-in-turn-monitor-no-wake). Running work already returned above. | |
| 2873 | + // Do not clear wakeBuffer here: frames for other still-tracked tasks | |
| 2874 | + // must remain drainable when a real (tool) completion later offers. | |
| 2875 | + const handledInTurnCount = (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size; | |
| 2876 | + if ( | |
| 2877 | + handledInTurnCount > 0 && | |
| 2878 | + (update.sessionUpdate === "agent_message_chunk" || | |
| 2879 | + update.sessionUpdate === "agent_thought_chunk") | |
| 2880 | + ) { | |
| 2881 | + return true; | |
| 2882 | + } | |
| 2883 | + yield* offerContinuationRun(notification.sessionId); | |
| 2884 | + return true; | |
| 2885 | + }); | |
| 2886 | + | |
| 1420 | 2887 | const handleSessionUpdate = Effect.fnUntraced(function* ( |
| 1421 | 2888 | notification: EffectAcpSchema.SessionNotification, |
| 1422 | 2889 | ) { |
| 1423 | 2890 | const context = yield* Ref.get(activeTurn); |
| 1424 | 2891 | const update = notification.update; |
| 2892 | + // Only while a finalized turn is still the active context. When | |
| 2893 | + // activeTurn is null, post-settle agent frames must reach | |
| 2894 | + // bufferPostSettleWake so continuation can attach (context?.finalized | |
| 2895 | + // !== false incorrectly treated null as finalized and dropped them). | |
| 2896 | + if ( | |
| 2897 | + context !== null && | |
| 2898 | + context.finalized && | |
| 2899 | + (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size > 0 && | |
| 2900 | + (update.sessionUpdate === "agent_message_chunk" || | |
| 2901 | + update.sessionUpdate === "agent_thought_chunk") | |
| 2902 | + ) { | |
| 2903 | + yield* Ref.set(suppressPostSettleMonitorPrompt, true); | |
| 2904 | + return; | |
| 2905 | + } | |
| 2906 | + if ( | |
| 2907 | + context !== null && | |
| 2908 | + (yield* Ref.get(suppressPostSettleMonitorPrompt)) && | |
| 2909 | + (update.sessionUpdate === "agent_message_chunk" || | |
| 2910 | + update.sessionUpdate === "agent_thought_chunk") | |
| 2911 | + ) { | |
| 2912 | + return; | |
| 2913 | + } | |
| 2914 | + if ( | |
| 2915 | + context?.finalized === true && | |
| 2916 | + update.sessionUpdate === "user_message_chunk" && | |
| 2917 | + update.content.type === "text" | |
| 2918 | + ) { | |
| 2919 | + const mutations = flavor.extractBackgroundToolMutation?.(update.content.text) ?? []; | |
| 2920 | + for (const mutation of mutations) { | |
| 2921 | + yield* applyLateBackgroundMutation(notification.sessionId, mutation); | |
| 2922 | + } | |
| 2923 | + return; | |
| 2924 | + } | |
| 1425 | 2925 | if (context === null) { |
| 2926 | + // Direct Stop: quarantine residual events from the stopped run so | |
| 2927 | + // they cannot become history, wake buffers, or a later run attach. | |
| 2928 | + if (yield* Ref.get(stoppedRunQuarantine)) { | |
| 2929 | + return; | |
| 2930 | + } | |
| 2931 | + // Prefer continuation buffering over history append so the same | |
| 2932 | + // frames are not double-counted once a continuation run attaches. | |
| 2933 | + if (yield* bufferPostSettleWake(notification)) { | |
| 2934 | + return; | |
| 2935 | + } | |
| 1426 | 2936 | if ( |
| 1427 | 2937 | (update.sessionUpdate === "user_message_chunk" || |
| 1428 | 2938 | update.sessionUpdate === "agent_message_chunk") && |
| 1429 | 2939 | update.content.type === "text" |
| 1430 | 2940 | ) { |
| 1431 | − yield* appendLoadedHistory( | |
| 1432 | − notification, | |
| 1433 | − update.sessionUpdate === "user_message_chunk" ? "user" : "assistant", | |
| 1434 | − update.content.text, | |
| 1435 | − ); | |
| 2941 | + // Late monitor end/event reminders must not become ghost user/assistant | |
| 2942 | + // history (or OS-facing chatter) after the root turn already finalized. | |
| 2943 | + const text = update.content.text; | |
| 2944 | + const lateBackgroundMutations = flavor.extractBackgroundToolMutation?.(text) ?? []; | |
| 2945 | + for (const lateBackgroundMutation of lateBackgroundMutations) { | |
| 2946 | + yield* applyLateBackgroundMutation(notification.sessionId, lateBackgroundMutation); | |
| 2947 | + } | |
| 2948 | + const lateMonitorChatter = | |
| 2949 | + /<monitor-event\b/i.test(text) || | |
| 2950 | + /Monitor\s+["']?[0-9a-f-]{8,}["']?\s+ended/i.test(text); | |
| 2951 | + if (lateBackgroundMutations.length === 0 && !lateMonitorChatter) { | |
| 2952 | + yield* appendLoadedHistory( | |
| 2953 | + notification, | |
| 2954 | + update.sessionUpdate === "user_message_chunk" ? "user" : "assistant", | |
| 2955 | + text, | |
| 2956 | + ); | |
| 2957 | + } | |
| 1436 | 2958 | } else if ( |
| 1437 | 2959 | update.sessionUpdate === "tool_call" || |
| 1438 | 2960 | update.sessionUpdate === "tool_call_update" || |
| @@ -1444,6 +2966,8 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines | ||
| 1444 | 2966 | } |
| 1445 | 2967 | if (context.finalized) return; |
| 1446 | 2968 | if (notification.sessionId !== (yield* Ref.get(activeSessionId))) { |
| 2969 | + // Finalize may have completed during the activeSessionId yield. | |
| 2970 | + if (context.finalized) return; | |
| 1447 | 2971 | if (flavor.extractSubagentUpdate === undefined) return; |
| 1448 | 2972 | if (update.sessionUpdate !== "agent_message_chunk" || update.content.type !== "text") { |
| 1449 | 2973 | return; |
| @@ -1458,17 +2982,90 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 8 unmodified lines | ||
| 1458 | 2982 | context.pendingSubagentNotifications.set(notification.sessionId, buffered); |
| 1459 | 2983 | return; |
| 1460 | 2984 | } |
| 2985 | + // Re-check after the activeSessionId yield: idle/prompt settle can | |
| 2986 | + // finalize the same context object while we waited. | |
| 2987 | + if (context.finalized) return; | |
| 1461 | 2988 | switch (update.sessionUpdate) { |
| 1462 | 2989 | case "agent_message_chunk": |
| 1463 | 2990 | if (update.content.type === "text") { |
| 2991 | + // The injected-turn report is streaming; the normal debounce | |
| 2992 | + // after the last chunk takes over from here. | |
| 2993 | + if (context.pendingInjectedReport.size > 0) { | |
| 2994 | + context.pendingInjectedReport.clear(); | |
| 2995 | + } | |
| 1464 | 2996 | yield* appendText(context, "assistant", update.content.text); |
| 1465 | 2997 | } |
| 1466 | − return; | |
| 2998 | + break; | |
| 1467 | 2999 | case "agent_thought_chunk": |
| 1468 | 3000 | if (update.content.type === "text") { |
| 1469 | 3001 | yield* appendText(context, "reasoning", update.content.text); |
| 1470 | 3002 | } |
| 1471 | − return; | |
| 3003 | + break; | |
| 3004 | + case "user_message_chunk": | |
| 3005 | + if (update.content.type === "text" && flavor.extractBackgroundToolMutation) { | |
| 3006 | + for (const mutation of flavor.extractBackgroundToolMutation(update.content.text)) { | |
| 3007 | + const toolCallId = context.toolCallIdsByBackgroundTaskId.get(mutation.taskId); | |
| 3008 | + const previous = | |
| 3009 | + toolCallId !== undefined ? context.tools.get(toolCallId) : undefined; | |
| 3010 | + if (previous !== undefined) { | |
| 3011 | + let nextStatus = | |
| 3012 | + mutation.status === "running" | |
| 3013 | + ? ("inProgress" as const) | |
| 3014 | + : mutation.status === "failed" | |
| 3015 | + ? ("failed" as const) | |
| 3016 | + : ("completed" as const); | |
| 3017 | + // End notices typically omit full stdout (no get_command mention | |
| 3018 | + // either). Keep the tool running and hold finalize until | |
| 3019 | + // TaskOutput hydrates, or the safety timer force-completes. | |
| 3020 | + if (nextStatus !== "inProgress") { | |
| 3021 | + yield* markAwaitingBackgroundHydration(context, mutation.taskId); | |
| 3022 | + nextStatus = "inProgress"; | |
| 3023 | + } | |
| 3024 | + yield* emitTool( | |
| 3025 | + context, | |
| 3026 | + appendToolOutputText( | |
| 3027 | + { ...previous, status: nextStatus }, | |
| 3028 | + mutation.appendOutput, | |
| 3029 | + ), | |
| 3030 | + ); | |
| 3031 | + } | |
| 3032 | + // After emitTool: the hydration hold keeps the tool row at | |
| 3033 | + // inProgress past an end notice, but the offer gate must see | |
| 3034 | + // the mutation's semantic status. Also tracks monitors that | |
| 3035 | + // never surfaced a tool_call row at all. | |
| 3036 | + yield* applyBackgroundTaskMutationRunning(mutation); | |
| 3037 | + if (mutation.status !== "running") { | |
| 3038 | + yield* markPendingInjectedReport(context, mutation.taskId); | |
| 3039 | + } | |
| 3040 | + } | |
| 3041 | + } | |
| 3042 | + if (update.content.type === "text" && flavor.extractSubagentEndNotice) { | |
| 3043 | + const notice = flavor.extractSubagentEndNotice(update.content.text); | |
| 3044 | + const subagent = | |
| 3045 | + notice !== undefined | |
| 3046 | + ? context.subagentsBySessionId.get(notice.childSessionId) | |
| 3047 | + : undefined; | |
| 3048 | + if ( | |
| 3049 | + notice !== undefined && | |
| 3050 | + subagent !== undefined && | |
| 3051 | + (subagent.task.status === "running" || subagent.task.status === "pending") | |
| 3052 | + ) { | |
| 3053 | + // The agent is free to answer with text only and never call | |
| 3054 | + // get_command_or_subagent_output; without this, the subagent | |
| 3055 | + // row stays running and holds deferred finalize open forever. | |
| 3056 | + yield* emitSubagent(context, { | |
| 3057 | + nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? notice.childSessionId, | |
| 3058 | + prompt: subagent.task.prompt, | |
| 3059 | + title: subagent.task.title, | |
| 3060 | + model: subagent.task.model, | |
| 3061 | + status: notice.status, | |
| 3062 | + childSessionId: notice.childSessionId, | |
| 3063 | + result: null, | |
| 3064 | + suppressNormalTool: true, | |
| 3065 | + }); | |
| 3066 | + } | |
| 3067 | + } | |
| 3068 | + break; | |
| 1472 | 3069 | default: { |
| 1473 | 3070 | const parsed = parseSessionUpdateEvent(notification); |
| 1474 | 3071 | for (const event of parsed.events) { |
| @@ -1480,20 +3077,13 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines | ||
| 1480 | 3077 | } |
| 1481 | 3078 | } |
| 1482 | 3079 | } |
| 3080 | + if (acpRootSessionUpdateIngestsOutput(notification)) { | |
| 3081 | + yield* scheduleSettleRootTurnWhenIdle(context); | |
| 3082 | + } | |
| 3083 | + // Keep deferred finalize quiet-window fresh while wake traffic lands. | |
| 3084 | + yield* rearmDeferredFinalize(context); | |
| 1483 | 3085 | }); |
| 1484 | 3086 | |
| 1485 | − yield* runtime.handleSessionUpdate((notification) => | |
| 1486 | − handleSessionUpdate(notification).pipe( | |
| 1487 | − Effect.mapError( | |
| 1488 | − (cause) => | |
| 1489 | − new EffectAcpErrors.AcpTransportError({ | |
| 1490 | − detail: "Failed to project an ACP session update", | |
| 1491 | − cause, | |
| 1492 | − }), | |
| 1493 | − ), | |
| 1494 | − ), | |
| 1495 | − ); | |
| 1496 | − | |
| 1497 | 3087 | const activeContext = Effect.gen(function* () { |
| 1498 | 3088 | const context = yield* Ref.get(activeTurn); |
| 1499 | 3089 | if (context === null) { |
| @@ -1505,9 +3095,11 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines | ||
| 1505 | 3095 | return context; |
| 1506 | 3096 | }); |
| 1507 | 3097 | |
| 1508 | − const emitApprovalRequest = Effect.fnUntraced(function* ( | |
| 3098 | + const beginApprovalRequest = Effect.fnUntraced(function* ( | |
| 1509 | 3099 | context: ActiveAcpTurn, |
| 1510 | 3100 | params: EffectAcpSchema.RequestPermissionRequest, |
| 3101 | + generation: number, | |
| 3102 | + transportRequestId: string, | |
| 1511 | 3103 | ) { |
| 1512 | 3104 | yield* closeTextStreams(context); |
| 1513 | 3105 | const parsed = parsePermissionRequest(params); |
| @@ -1518,6 +3110,15 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 4 unmodified lines | ||
| 1518 | 3110 | nativeRequestId, |
| 1519 | 3111 | }); |
| 1520 | 3112 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); |
| 3113 | + const nativeResponseAcknowledgement = yield* Deferred.make< | |
| 3114 | + void, | |
| 3115 | + EffectAcpErrors.AcpError | |
| 3116 | + >(); | |
| 3117 | + yield* registerNativeResponseAcknowledgement( | |
| 3118 | + generation, | |
| 3119 | + transportRequestId, | |
| 3120 | + nativeResponseAcknowledgement, | |
| 3121 | + ); | |
| 1521 | 3122 | const now = yield* DateTime.now; |
| 1522 | 3123 | const nodeId = idAllocator.derive.approvalNode({ requestId }); |
| 1523 | 3124 | const requestKind = providerRequestKind(parsed.kind); |
| @@ -1581,7 +3182,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 57 unmodified lines | ||
| 1581 | 3182 | const updated = new Map(current); |
| 1582 | 3183 | updated.set(String(requestId), { |
| 1583 | 3184 | type: "approval", |
| 3185 | + generation, | |
| 3186 | + nativeResponseAcknowledgement, | |
| 1584 | 3187 | requestId, |
| 3188 | + transportRequestId, | |
| 1585 | 3189 | decision, |
| 1586 | 3190 | runtimeRequest, |
| 1587 | 3191 | node, |
| @@ -1605,55 +3209,19 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 17 unmodified lines | ||
| 1605 | 3209 | driver, |
| 1606 | 3210 | turnItem, |
| 1607 | 3211 | }); |
| 1608 | − const resolved = yield* Deferred.await(decision).pipe( | |
| 1609 | − Effect.ensuring( | |
| 1610 | − Ref.update(pendingRuntimeRequests, (current) => { | |
| 1611 | − const updated = new Map(current); | |
| 1612 | − updated.delete(String(requestId)); | |
| 1613 | − return updated; | |
| 1614 | − }), | |
| 1615 | − ), | |
| 1616 | − ); | |
| 1617 | − return resolved; | |
| 3212 | + return { | |
| 3213 | + context, | |
| 3214 | + decision, | |
| 3215 | + nativeResponseAcknowledgement, | |
| 3216 | + requestId, | |
| 3217 | + transportRequestId, | |
| 3218 | + } as const; | |
| 1618 | 3219 | }); |
| 1619 | 3220 | |
| 1620 | − yield* runtime.handleRequestPermission((params) => | |
| 1621 | − Effect.gen(function* () { | |
| 1622 | − const context = yield* activeContext; | |
| 1623 | − const disposition = acpPermissionDisposition(context.input.runtimePolicy, params); | |
| 1624 | − if (disposition === "allow") { | |
| 1625 | − const optionId = selectAutoApprovedPermissionOption(params); | |
| 1626 | − return optionId === undefined | |
| 1627 | − ? ({ outcome: { outcome: "cancelled" } } as const) | |
| 1628 | − : ({ outcome: { outcome: "selected", optionId } } as const); | |
| 1629 | − } | |
| 1630 | − if (disposition === "deny") { | |
| 1631 | − const optionId = selectPermissionOptionId(params, "decline"); | |
| 1632 | − return optionId === undefined | |
| 1633 | − ? ({ outcome: { outcome: "cancelled" } } as const) | |
| 1634 | − : ({ outcome: { outcome: "selected", optionId } } as const); | |
| 1635 | − } | |
| 1636 | − const decision = yield* emitApprovalRequest(context, params); | |
| 1637 | − if (decision === "cancel") { | |
| 1638 | − return { outcome: { outcome: "cancelled" } } as const; | |
| 1639 | − } | |
| 1640 | − const optionId = selectPermissionOptionId(params, decision); | |
| 1641 | − return optionId === undefined | |
| 1642 | − ? ({ outcome: { outcome: "cancelled" } } as const) | |
| 1643 | − : ({ outcome: { outcome: "selected", optionId } } as const); | |
| 1644 | − }).pipe( | |
| 1645 | − Effect.mapError( | |
| 1646 | − (cause) => | |
| 1647 | − new EffectAcpErrors.AcpTransportError({ | |
| 1648 | − detail: "Failed to handle an ACP permission request", | |
| 1649 | − cause, | |
| 1650 | − }), | |
| 1651 | − ), | |
| 1652 | − ), | |
| 1653 | − ); | |
| 1654 | − | |
| 1655 | − const requestUserInputInternal = Effect.fnUntraced(function* ( | |
| 3221 | + const beginUserInputRequest = Effect.fnUntraced(function* ( | |
| 1656 | 3222 | request: AcpAdapterV2UserInputRequest, |
| 3223 | + generation: number, | |
| 3224 | + transportRequestId: string, | |
| 1657 | 3225 | ) { |
| 1658 | 3226 | const context = yield* activeContext; |
| 1659 | 3227 | yield* closeTextStreams(context); |
| @@ -1663,6 +3231,15 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines | ||
| 1663 | 3231 | nativeRequestId: request.nativeRequestId, |
| 1664 | 3232 | }); |
| 1665 | 3233 | const answers = yield* Deferred.make<ProviderUserInputAnswers | null>(); |
| 3234 | + const nativeResponseAcknowledgement = yield* Deferred.make< | |
| 3235 | + void, | |
| 3236 | + EffectAcpErrors.AcpError | |
| 3237 | + >(); | |
| 3238 | + yield* registerNativeResponseAcknowledgement( | |
| 3239 | + generation, | |
| 3240 | + transportRequestId, | |
| 3241 | + nativeResponseAcknowledgement, | |
| 3242 | + ); | |
| 1666 | 3243 | const now = yield* DateTime.now; |
| 1667 | 3244 | const nodeId = idAllocator.derive.nodeFromProviderItem({ |
| 1668 | 3245 | driver, |
| @@ -1736,7 +3313,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 67 unmodified lines | ||
| 1736 | 3313 | const updated = new Map(current); |
| 1737 | 3314 | updated.set(String(requestId), { |
| 1738 | 3315 | type: "user_input", |
| 3316 | + generation, | |
| 3317 | + nativeResponseAcknowledgement, | |
| 1739 | 3318 | requestId, |
| 3319 | + transportRequestId, | |
| 1740 | 3320 | answers, |
| 1741 | 3321 | runtimeRequest, |
| 1742 | 3322 | node, |
| @@ -1760,19 +3340,62 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 17 unmodified lines | ||
| 1760 | 3340 | driver, |
| 1761 | 3341 | turnItem, |
| 1762 | 3342 | }); |
| 1763 | − return yield* Deferred.await(answers).pipe( | |
| 1764 | − Effect.ensuring( | |
| 1765 | − Ref.update(pendingRuntimeRequests, (current) => { | |
| 1766 | − const updated = new Map(current); | |
| 1767 | − updated.delete(String(requestId)); | |
| 1768 | − return updated; | |
| 1769 | − }), | |
| 1770 | − ), | |
| 1771 | − ); | |
| 3343 | + return { | |
| 3344 | + answers, | |
| 3345 | + context, | |
| 3346 | + nativeResponseAcknowledgement, | |
| 3347 | + requestId, | |
| 3348 | + transportRequestId, | |
| 3349 | + } as const; | |
| 1772 | 3350 | }); |
| 1773 | 3351 | |
| 1774 | − const requestUserInput = (request: AcpAdapterV2UserInputRequest) => | |
| 1775 | − requestUserInputInternal(request).pipe( | |
| 3352 | + const requestUserInputWithAdmission = ( | |
| 3353 | + generation: number, | |
| 3354 | + request: Effect.Effect<AcpAdapterV2UserInputRequest>, | |
| 3355 | + transportRequestId: string, | |
| 3356 | + ) => | |
| 3357 | + runRuntimeCallbackAtGeneration( | |
| 3358 | + generation, | |
| 3359 | + request.pipe( | |
| 3360 | + Effect.flatMap((value) => | |
| 3361 | + beginUserInputRequest(value, generation, transportRequestId), | |
| 3362 | + ), | |
| 3363 | + ), | |
| 3364 | + ).pipe( | |
| 3365 | + Effect.flatMap((pending) => { | |
| 3366 | + if (Option.isNone(pending)) return Effect.never; | |
| 3367 | + const { answers, context, requestId, transportRequestId } = pending.value; | |
| 3368 | + return Deferred.await(answers).pipe( | |
| 3369 | + Effect.flatMap((result) => | |
| 3370 | + runRuntimeCallbackAtGeneration(generation, Effect.succeed(result)).pipe( | |
| 3371 | + Effect.flatMap((checked) => | |
| 3372 | + Option.isSome(checked) | |
| 3373 | + ? Effect.succeed({ | |
| 3374 | + acknowledgeNativeResponse: acknowledgeNativeResponse( | |
| 3375 | + generation, | |
| 3376 | + transportRequestId, | |
| 3377 | + ), | |
| 3378 | + answers: checked.value, | |
| 3379 | + }) | |
| 3380 | + : Effect.never, | |
| 3381 | + ), | |
| 3382 | + ), | |
| 3383 | + ), | |
| 3384 | + Effect.ensuring( | |
| 3385 | + runRuntimeCallbackAtGeneration( | |
| 3386 | + generation, | |
| 3387 | + Effect.gen(function* () { | |
| 3388 | + yield* Ref.update(pendingRuntimeRequests, (current) => { | |
| 3389 | + const updated = new Map(current); | |
| 3390 | + updated.delete(String(requestId)); | |
| 3391 | + return updated; | |
| 3392 | + }); | |
| 3393 | + yield* rearmRootTurnRecoveryTimers(context); | |
| 3394 | + }), | |
| 3395 | + ).pipe(Effect.asVoid), | |
| 3396 | + ), | |
| 3397 | + ); | |
| 3398 | + }), | |
| 1776 | 3399 | Effect.mapError( |
| 1777 | 3400 | (cause) => |
| 1778 | 3401 | new EffectAcpErrors.AcpTransportError({ |
| @@ -1783,7 +3406,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 4 unmodified lines | ||
| 1783 | 3406 | ); |
| 1784 | 3407 | |
| 1785 | 3408 | const cancelPendingRuntimeRequests = Effect.fnUntraced(function* () { |
| 1786 | − const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()]; | |
| 3409 | + const requests = yield* Ref.modify(pendingRuntimeRequests, (current) => [ | |
| 3410 | + [...current.values()], | |
| 3411 | + new Map<string, PendingRuntimeRequest>(), | |
| 3412 | + ]); | |
| 1787 | 3413 | if (requests.length === 0) return; |
| 1788 | 3414 | |
| 1789 | 3415 | const now = yield* DateTime.now; |
| @@ -1830,61 +3456,423 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 40 unmodified lines | ||
| 1830 | 3456 | ); |
| 1831 | 3457 | }); |
| 1832 | 3458 | |
| 1833 | − yield* runtime.handleElicitation((params) => | |
| 1834 | − Effect.gen(function* () { | |
| 1835 | − if (params.mode === "url") { | |
| 1836 | − return { action: { action: "decline" } } as const; | |
| 3459 | + /** | |
| 3460 | + * Direct Stop after a soft steer clears carryover without an active turn. | |
| 3461 | + * Emit the same interrupted terminal events terminalizeOpenRunOwnedItems | |
| 3462 | + * would have, context-free (no ActiveAcpTurn). | |
| 3463 | + */ | |
| 3464 | + const terminalizeCarryoverSubagents = Effect.fnUntraced(function* ( | |
| 3465 | + carryover: { | |
| 3466 | + readonly sessionId: string; | |
| 3467 | + readonly subagents: ReadonlyArray<ActiveAcpSubagent>; | |
| 3468 | + } | null, | |
| 3469 | + ) { | |
| 3470 | + if (carryover === null) return; | |
| 3471 | + const now = yield* DateTime.now; | |
| 3472 | + for (const subagent of carryover.subagents) { | |
| 3473 | + if (subagent.task.status !== "running" && subagent.task.status !== "pending") { | |
| 3474 | + continue; | |
| 1837 | 3475 | } |
| 1838 | − const questions = Object.entries(params.requestedSchema.properties ?? {}).map( | |
| 1839 | − ([id, property], index): OrchestrationV2UserInputQuestion => { | |
| 1840 | − const record = unknownRecord(property); | |
| 1841 | − const enumValues = Array.isArray(record?.enum) | |
| 1842 | − ? record.enum.filter((value): value is string => typeof value === "string") | |
| 1843 | − : []; | |
| 1844 | − const options = | |
| 1845 | − enumValues.length > 0 | |
| 1846 | − ? enumValues.map((value) => ({ label: value, description: value })) | |
| 1847 | − : record?.type === "boolean" | |
| 1848 | − ? [ | |
| 1849 | − { label: "true", description: "Yes" }, | |
| 1850 | − { label: "false", description: "No" }, | |
| 1851 | − ] | |
| 1852 | − : []; | |
| 1853 | − return { | |
| 1854 | − id, | |
| 1855 | − header: nonEmptyText(record?.title, `Question ${index + 1}`), | |
| 1856 | − question: nonEmptyText(record?.description, params.message), | |
| 1857 | − options, | |
| 1858 | − }; | |
| 3476 | + const nativeTaskId = subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id; | |
| 3477 | + const nativeItemRef = { | |
| 3478 | + driver, | |
| 3479 | + nativeId: nativeTaskId, | |
| 3480 | + strength: "strong" as const, | |
| 3481 | + }; | |
| 3482 | + const parentProviderThreadId = subagent.parentProviderThreadId; | |
| 3483 | + const result = subagent.assistantText || subagent.task.result; | |
| 3484 | + subagent.task = { | |
| 3485 | + ...subagent.task, | |
| 3486 | + status: "interrupted", | |
| 3487 | + result, | |
| 3488 | + completedAt: now, | |
| 3489 | + updatedAt: now, | |
| 3490 | + }; | |
| 3491 | + yield* emitProviderEvent({ | |
| 3492 | + type: "node.updated", | |
| 3493 | + driver, | |
| 3494 | + node: { | |
| 3495 | + id: subagent.task.id, | |
| 3496 | + threadId: subagent.task.threadId, | |
| 3497 | + runId: subagent.task.runId, | |
| 3498 | + parentNodeId: subagent.task.parentNodeId, | |
| 3499 | + rootNodeId: subagent.task.parentNodeId, | |
| 3500 | + kind: "subagent", | |
| 3501 | + status: "interrupted", | |
| 3502 | + countsForRun: false, | |
| 3503 | + providerThreadId: parentProviderThreadId, | |
| 3504 | + providerTurnId: subagent.providerTurnId, | |
| 3505 | + nativeItemRef, | |
| 3506 | + runtimeRequestId: null, | |
| 3507 | + checkpointScopeId: null, | |
| 3508 | + startedAt: subagent.task.startedAt, | |
| 3509 | + completedAt: now, | |
| 3510 | + }, | |
| 3511 | + }); | |
| 3512 | + yield* emitProviderEvent({ | |
| 3513 | + type: "node.updated", | |
| 3514 | + driver, | |
| 3515 | + node: { | |
| 3516 | + id: subagent.childRootNodeId, | |
| 3517 | + threadId: subagent.childThreadId, | |
| 3518 | + runId: null, | |
| 3519 | + parentNodeId: null, | |
| 3520 | + rootNodeId: subagent.childRootNodeId, | |
| 3521 | + kind: "root_turn", | |
| 3522 | + status: "interrupted", | |
| 3523 | + countsForRun: false, | |
| 3524 | + providerThreadId: subagent.task.providerThreadId, | |
| 3525 | + providerTurnId: null, | |
| 3526 | + nativeItemRef, | |
| 3527 | + runtimeRequestId: null, | |
| 3528 | + checkpointScopeId: null, | |
| 3529 | + startedAt: subagent.task.startedAt, | |
| 3530 | + completedAt: now, | |
| 3531 | + }, | |
| 3532 | + }); | |
| 3533 | + yield* emitProviderEvent({ | |
| 3534 | + type: "subagent.updated", | |
| 3535 | + driver, | |
| 3536 | + subagent: subagent.task, | |
| 3537 | + }); | |
| 3538 | + yield* emitProviderEvent({ | |
| 3539 | + type: "turn_item.updated", | |
| 3540 | + driver, | |
| 3541 | + turnItem: { | |
| 3542 | + id: subagent.turnItemId, | |
| 3543 | + threadId: subagent.task.threadId, | |
| 3544 | + runId: subagent.task.runId, | |
| 3545 | + nodeId: subagent.task.id, | |
| 3546 | + providerThreadId: parentProviderThreadId, | |
| 3547 | + providerTurnId: subagent.providerTurnId, | |
| 3548 | + nativeItemRef, | |
| 3549 | + parentItemId: null, | |
| 3550 | + ordinal: subagent.turnItemOrdinal, | |
| 3551 | + status: "interrupted", | |
| 3552 | + title: subagent.task.title, | |
| 3553 | + startedAt: subagent.task.startedAt, | |
| 3554 | + completedAt: now, | |
| 3555 | + updatedAt: now, | |
| 3556 | + type: "subagent", | |
| 3557 | + subagentId: subagent.task.id, | |
| 3558 | + origin: "provider_native", | |
| 3559 | + driver, | |
| 3560 | + providerInstanceId: subagent.task.providerInstanceId, | |
| 3561 | + childThreadId: subagent.childThreadId, | |
| 3562 | + prompt: subagent.task.prompt, | |
| 3563 | + result, | |
| 1859 | 3564 | }, |
| 1860 | − ); | |
| 1861 | − const ordinal = yield* Ref.getAndUpdate( | |
| 1862 | − nextElicitationOrdinal, | |
| 1863 | − (current) => current + 1, | |
| 1864 | − ); | |
| 1865 | − const nativeRequestId = `${params.sessionId}:elicitation:${ordinal}`; | |
| 1866 | − const answers = yield* requestUserInput({ | |
| 1867 | − nativeItemId: nativeRequestId, | |
| 1868 | − nativeRequestId, | |
| 1869 | − questions, | |
| 1870 | 3565 | }); |
| 1871 | − return answers === null | |
| 1872 | − ? ({ action: { action: "cancel" } } as const) | |
| 1873 | − : ({ | |
| 1874 | − action: { | |
| 1875 | − action: "accept", | |
| 1876 | − content: elicitationContent( | |
| 1877 | − answers, | |
| 1878 | − new Set(Object.keys(params.requestedSchema.properties ?? {})), | |
| 3566 | + } | |
| 3567 | + }); | |
| 3568 | + | |
| 3569 | + const wireAcpRuntimeHandlers = Effect.fnUntraced(function* () { | |
| 3570 | + const handlerGeneration = yield* Ref.get(runtimeCallbackGeneration); | |
| 3571 | + const requestUserInput = (request: AcpAdapterV2UserInputRequest) => | |
| 3572 | + Effect.gen(function* () { | |
| 3573 | + const transportRequestId = yield* claimNativeTransportRequest( | |
| 3574 | + handlerGeneration, | |
| 3575 | + (transport) => acpNativeUserInputRequestMatches(request, transport), | |
| 3576 | + ); | |
| 3577 | + const correlated = yield* runRuntimeCallbackAtGeneration( | |
| 3578 | + handlerGeneration, | |
| 3579 | + transportRequestId === undefined | |
| 3580 | + ? new EffectAcpErrors.AcpTransportError({ | |
| 3581 | + detail: | |
| 3582 | + "Could not correlate the ACP user input request with its transport ID", | |
| 3583 | + cause: "Could not correlate xAI user input transport request", | |
| 3584 | + }) | |
| 3585 | + : Effect.succeed(transportRequestId), | |
| 3586 | + ); | |
| 3587 | + if (Option.isNone(correlated)) return yield* Effect.never; | |
| 3588 | + return yield* requestUserInputWithAdmission( | |
| 3589 | + handlerGeneration, | |
| 3590 | + Effect.succeed(request), | |
| 3591 | + correlated.value, | |
| 3592 | + ); | |
| 3593 | + }); | |
| 3594 | + yield* runtime.handleSessionUpdate((notification) => | |
| 3595 | + runRuntimeCallbackAtGeneration( | |
| 3596 | + handlerGeneration, | |
| 3597 | + handleSessionUpdate(notification), | |
| 3598 | + ).pipe( | |
| 3599 | + Effect.asVoid, | |
| 3600 | + Effect.mapError( | |
| 3601 | + (cause) => | |
| 3602 | + new EffectAcpErrors.AcpTransportError({ | |
| 3603 | + detail: "Failed to project an ACP session update", | |
| 3604 | + cause, | |
| 3605 | + }), | |
| 3606 | + ), | |
| 3607 | + ), | |
| 3608 | + ); | |
| 3609 | + yield* runtime.handleRequestPermission((params) => | |
| 3610 | + Effect.gen(function* () { | |
| 3611 | + const transportRequestId = yield* claimNativeTransportRequest( | |
| 3612 | + handlerGeneration, | |
| 3613 | + ({ method, payload }) => | |
| 3614 | + method === "session/request_permission" && | |
| 3615 | + unknownRecord(payload)?.sessionId === params.sessionId && | |
| 3616 | + unknownRecord(unknownRecord(payload)?.toolCall)?.toolCallId === | |
| 3617 | + params.toolCall.toolCallId, | |
| 3618 | + ); | |
| 3619 | + const correlated = yield* runRuntimeCallbackAtGeneration( | |
| 3620 | + handlerGeneration, | |
| 3621 | + transportRequestId === undefined | |
| 3622 | + ? new EffectAcpErrors.AcpTransportError({ | |
| 3623 | + detail: | |
| 3624 | + "Could not correlate the ACP permission request with its transport ID", | |
| 3625 | + cause: "Could not correlate session/request_permission transport request", | |
| 3626 | + }) | |
| 3627 | + : Effect.succeed(transportRequestId), | |
| 3628 | + ); | |
| 3629 | + if (Option.isNone(correlated)) return yield* Effect.never; | |
| 3630 | + const correlatedTransportRequestId = correlated.value; | |
| 3631 | + const admitted = yield* runRuntimeCallbackAtGeneration( | |
| 3632 | + handlerGeneration, | |
| 3633 | + Effect.gen(function* () { | |
| 3634 | + const context = yield* activeContext; | |
| 3635 | + const disposition = acpPermissionDisposition(context.input.runtimePolicy, params); | |
| 3636 | + if (disposition === "allow") { | |
| 3637 | + const optionId = selectAutoApprovedPermissionOption(params); | |
| 3638 | + return { | |
| 3639 | + _tag: "Immediate" as const, | |
| 3640 | + response: | |
| 3641 | + optionId === undefined | |
| 3642 | + ? ({ outcome: { outcome: "cancelled" } } as const) | |
| 3643 | + : ({ outcome: { outcome: "selected", optionId } } as const), | |
| 3644 | + }; | |
| 3645 | + } | |
| 3646 | + if (disposition === "deny") { | |
| 3647 | + const optionId = selectPermissionOptionId(params, "decline"); | |
| 3648 | + return { | |
| 3649 | + _tag: "Immediate" as const, | |
| 3650 | + response: | |
| 3651 | + optionId === undefined | |
| 3652 | + ? ({ outcome: { outcome: "cancelled" } } as const) | |
| 3653 | + : ({ outcome: { outcome: "selected", optionId } } as const), | |
| 3654 | + }; | |
| 3655 | + } | |
| 3656 | + return { | |
| 3657 | + _tag: "Pending" as const, | |
| 3658 | + pending: yield* beginApprovalRequest( | |
| 3659 | + context, | |
| 3660 | + params, | |
| 3661 | + handlerGeneration, | |
| 3662 | + correlatedTransportRequestId, | |
| 1879 | 3663 | ), |
| 1880 | − }, | |
| 1881 | − } as const); | |
| 1882 | − }), | |
| 1883 | − ); | |
| 3664 | + }; | |
| 3665 | + }), | |
| 3666 | + ); | |
| 3667 | + if (Option.isNone(admitted)) { | |
| 3668 | + return yield* Effect.never; | |
| 3669 | + } | |
| 3670 | + if (admitted.value._tag === "Immediate") { | |
| 3671 | + const response = admitted.value.response; | |
| 3672 | + const checked = yield* runRuntimeCallbackAtGeneration( | |
| 3673 | + handlerGeneration, | |
| 3674 | + Effect.gen(function* () { | |
| 3675 | + const nativeResponseAcknowledgement = yield* Deferred.make< | |
| 3676 | + void, | |
| 3677 | + EffectAcpErrors.AcpError | |
| 3678 | + >(); | |
| 3679 | + yield* registerNativeResponseAcknowledgement( | |
| 3680 | + handlerGeneration, | |
| 3681 | + correlatedTransportRequestId, | |
| 3682 | + nativeResponseAcknowledgement, | |
| 3683 | + ); | |
| 3684 | + return response; | |
| 3685 | + }), | |
| 3686 | + ); | |
| 3687 | + return Option.isSome(checked) ? checked.value : yield* Effect.never; | |
| 3688 | + } | |
| 3689 | + const { | |
| 3690 | + context, | |
| 3691 | + decision: pendingDecision, | |
| 3692 | + requestId, | |
| 3693 | + transportRequestId: pendingTransportRequestId, | |
| 3694 | + } = admitted.value.pending; | |
| 3695 | + const decision = yield* Deferred.await(pendingDecision).pipe( | |
| 3696 | + Effect.ensuring( | |
| 3697 | + runRuntimeCallbackAtGeneration( | |
| 3698 | + handlerGeneration, | |
| 3699 | + Effect.gen(function* () { | |
| 3700 | + yield* Ref.update(pendingRuntimeRequests, (current) => { | |
| 3701 | + const updated = new Map(current); | |
| 3702 | + updated.delete(String(requestId)); | |
| 3703 | + return updated; | |
| 3704 | + }); | |
| 3705 | + yield* rearmRootTurnRecoveryTimers(context); | |
| 3706 | + }), | |
| 3707 | + ).pipe(Effect.asVoid), | |
| 3708 | + ), | |
| 3709 | + ); | |
| 3710 | + const response = (() => { | |
| 3711 | + if (decision === "cancel") { | |
| 3712 | + return { outcome: { outcome: "cancelled" } } as const; | |
| 3713 | + } | |
| 3714 | + const optionId = selectPermissionOptionId(params, decision); | |
| 3715 | + return optionId === undefined | |
| 3716 | + ? ({ outcome: { outcome: "cancelled" } } as const) | |
| 3717 | + : ({ outcome: { outcome: "selected", optionId } } as const); | |
| 3718 | + })(); | |
| 3719 | + const checked = yield* runRuntimeCallbackAtGeneration( | |
| 3720 | + handlerGeneration, | |
| 3721 | + Effect.succeed(response), | |
| 3722 | + ); | |
| 3723 | + if (Option.isNone(checked)) return yield* Effect.never; | |
| 3724 | + yield* acknowledgeNativeResponse(handlerGeneration, pendingTransportRequestId); | |
| 3725 | + return checked.value; | |
| 3726 | + }).pipe( | |
| 3727 | + Effect.mapError( | |
| 3728 | + (cause) => | |
| 3729 | + new EffectAcpErrors.AcpTransportError({ | |
| 3730 | + detail: "Failed to handle an ACP permission request", | |
| 3731 | + cause, | |
| 3732 | + }), | |
| 3733 | + ), | |
| 3734 | + ), | |
| 3735 | + ); | |
| 3736 | + yield* runtime.handleElicitation((params) => | |
| 3737 | + Effect.gen(function* () { | |
| 3738 | + const transportRequestId = yield* claimNativeTransportRequest( | |
| 3739 | + handlerGeneration, | |
| 3740 | + ({ method, payload }) => { | |
| 3741 | + const record = unknownRecord(payload); | |
| 3742 | + return ( | |
| 3743 | + method === "session/elicitation" && | |
| 3744 | + record?.sessionId === params.sessionId && | |
| 3745 | + record.message === params.message && | |
| 3746 | + record.mode === params.mode && | |
| 3747 | + (params.mode === "url" | |
| 3748 | + ? record.elicitationId === params.elicitationId && record.url === params.url | |
| 3749 | + : acpCanonicalJson(record.requestedSchema) === | |
| 3750 | + acpCanonicalJson(params.requestedSchema)) | |
| 3751 | + ); | |
| 3752 | + }, | |
| 3753 | + ); | |
| 3754 | + const correlated = yield* runRuntimeCallbackAtGeneration( | |
| 3755 | + handlerGeneration, | |
| 3756 | + transportRequestId === undefined | |
| 3757 | + ? new EffectAcpErrors.AcpTransportError({ | |
| 3758 | + detail: | |
| 3759 | + "Could not correlate the ACP elicitation request with its transport ID", | |
| 3760 | + cause: "Could not correlate session/elicitation transport request", | |
| 3761 | + }) | |
| 3762 | + : Effect.succeed(transportRequestId), | |
| 3763 | + ); | |
| 3764 | + if (Option.isNone(correlated)) return yield* Effect.never; | |
| 3765 | + const correlatedTransportRequestId = correlated.value; | |
| 3766 | + if (params.mode === "url") { | |
| 3767 | + const admitted = yield* runRuntimeCallbackAtGeneration( | |
| 3768 | + handlerGeneration, | |
| 3769 | + Effect.gen(function* () { | |
| 3770 | + const nativeResponseAcknowledgement = yield* Deferred.make< | |
| 3771 | + void, | |
| 3772 | + EffectAcpErrors.AcpError | |
| 3773 | + >(); | |
| 3774 | + yield* registerNativeResponseAcknowledgement( | |
| 3775 | + handlerGeneration, | |
| 3776 | + correlatedTransportRequestId, | |
| 3777 | + nativeResponseAcknowledgement, | |
| 3778 | + ); | |
| 3779 | + return { action: { action: "decline" } } as const; | |
| 3780 | + }), | |
| 3781 | + ); | |
| 3782 | + if (Option.isNone(admitted)) return yield* Effect.never; | |
| 3783 | + return admitted.value; | |
| 3784 | + } | |
| 3785 | + const questions = Object.entries(params.requestedSchema.properties ?? {}).map( | |
| 3786 | + ([id, property], index): OrchestrationV2UserInputQuestion => { | |
| 3787 | + const record = unknownRecord(property); | |
| 3788 | + const enumValues = Array.isArray(record?.enum) | |
| 3789 | + ? record.enum.filter((value): value is string => typeof value === "string") | |
| 3790 | + : []; | |
| 3791 | + const options = | |
| 3792 | + enumValues.length > 0 | |
| 3793 | + ? enumValues.map((value) => ({ label: value, description: value })) | |
| 3794 | + : record?.type === "boolean" | |
| 3795 | + ? [ | |
| 3796 | + { label: "true", description: "Yes" }, | |
| 3797 | + { label: "false", description: "No" }, | |
| 3798 | + ] | |
| 3799 | + : []; | |
| 3800 | + return { | |
| 3801 | + id, | |
| 3802 | + header: nonEmptyText(record?.title, `Question ${index + 1}`), | |
| 3803 | + question: nonEmptyText(record?.description, params.message), | |
| 3804 | + options, | |
| 3805 | + }; | |
| 3806 | + }, | |
| 3807 | + ); | |
| 3808 | + const userInput = yield* requestUserInputWithAdmission( | |
| 3809 | + handlerGeneration, | |
| 3810 | + Effect.gen(function* () { | |
| 3811 | + const ordinal = yield* Ref.getAndUpdate( | |
| 3812 | + nextElicitationOrdinal, | |
| 3813 | + (current) => current + 1, | |
| 3814 | + ); | |
| 3815 | + const nativeRequestId = `${params.sessionId}:elicitation:${ordinal}`; | |
| 3816 | + return { | |
| 3817 | + nativeItemId: nativeRequestId, | |
| 3818 | + nativeRequestId, | |
| 3819 | + questions, | |
| 3820 | + }; | |
| 3821 | + }), | |
| 3822 | + correlatedTransportRequestId, | |
| 3823 | + ); | |
| 3824 | + const response = | |
| 3825 | + userInput.answers === null | |
| 3826 | + ? ({ action: { action: "cancel" } } as const) | |
| 3827 | + : ({ | |
| 3828 | + action: { | |
| 3829 | + action: "accept", | |
| 3830 | + content: elicitationContent( | |
| 3831 | + userInput.answers, | |
| 3832 | + new Set(Object.keys(params.requestedSchema.properties ?? {})), | |
| 3833 | + ), | |
| 3834 | + }, | |
| 3835 | + } as const); | |
| 3836 | + yield* userInput.acknowledgeNativeResponse; | |
| 3837 | + return response; | |
| 3838 | + }), | |
| 3839 | + ); | |
| 3840 | + if (flavor.registerExtensions !== undefined) { | |
| 3841 | + yield* flavor.registerExtensions({ | |
| 3842 | + runtime, | |
| 3843 | + requestUserInput, | |
| 3844 | + applyBackgroundTaskMutation: (mutation) => | |
| 3845 | + Effect.gen(function* () { | |
| 3846 | + // Direct Stop quarantine: drop residual task lifecycle from | |
| 3847 | + // the stopped run instead of mutating wake machinery. | |
| 3848 | + if (yield* Ref.get(stoppedRunQuarantine)) return; | |
| 3849 | + // Root-session tasks only: a cancelled subagent's re-run in | |
| 3850 | + // its child session must not gate root wake machinery. | |
| 3851 | + if ((yield* Ref.get(activeSessionId)) !== mutation.sessionId) return; | |
| 3852 | + yield* applyLateBackgroundMutation(mutation.sessionId, mutation); | |
| 3853 | + }), | |
| 3854 | + }); | |
| 3855 | + } | |
| 3856 | + }); | |
| 3857 | + | |
| 3858 | + const spawnAcpRuntime = Effect.fnUntraced(function* () { | |
| 3859 | + if (runtimeScope !== undefined) { | |
| 3860 | + yield* Scope.close(runtimeScope, Exit.void); | |
| 3861 | + } | |
| 3862 | + runtimeScope = yield* Scope.make(); | |
| 3863 | + const runtimeGeneration = yield* Ref.get(runtimeCallbackGeneration); | |
| 3864 | + runtime = yield* flavor | |
| 3865 | + .makeRuntime(makeRuntimeInput(runtimeGeneration)) | |
| 3866 | + .pipe(Effect.provideService(Scope.Scope, runtimeScope)); | |
| 3867 | + }); | |
| 3868 | + | |
| 3869 | + const restartAcpRuntime = Effect.fnUntraced(function* () { | |
| 3870 | + yield* spawnAcpRuntime(); | |
| 3871 | + yield* wireAcpRuntimeHandlers(); | |
| 3872 | + }); | |
| 1884 | 3873 | |
| 1885 | − if (flavor.registerExtensions !== undefined) { | |
| 1886 | − yield* flavor.registerExtensions({ runtime, requestUserInput }); | |
| 1887 | − } | |
| 3874 | + yield* spawnAcpRuntime(); | |
| 3875 | + yield* wireAcpRuntimeHandlers(); | |
| 1888 | 3876 | |
| 1889 | 3877 | const started = yield* runtime.start(); |
| 1890 | 3878 | yield* Ref.set(activeSessionId, started.sessionId); |
| @@ -1893,12 +3881,15 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 2 unmodified lines | ||
| 1893 | 3881 | const canLoadSession = started.initializeResult.agentCapabilities?.loadSession === true; |
| 1894 | 3882 | const canResumeSession = |
| 1895 | 3883 | started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null; |
| 1896 | − const supportsImagePrompts = | |
| 1897 | − started.initializeResult.agentCapabilities?.promptCapabilities?.image === true; | |
| 3884 | + const supportsImagePrompts = acpSupportsImagePrompts({ | |
| 3885 | + flavorSupportsImagePrompts: flavor.supportsImagePrompts, | |
| 3886 | + negotiatedImage: | |
| 3887 | + started.initializeResult.agentCapabilities?.promptCapabilities?.image === true, | |
| 3888 | + }); | |
| 1898 | 3889 | |
| 1899 | 3890 | const activateSession = Effect.fnUntraced(function* ( |
| 1900 | 3891 | sessionId: string, |
| 1901 | − threadId: ThreadId, | |
| 3892 | + threadId: ThreadId | null, | |
| 1902 | 3893 | ) { |
| 1903 | 3894 | const activationOptions = { mcpServers: acpMcpServers(threadId) }; |
| 1904 | 3895 | if (canLoadSession) { |
| @@ -1996,16 +3987,90 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 91 unmodified lines | ||
| 1996 | 3987 | completedAt, |
| 1997 | 3988 | }); |
| 1998 | 3989 | |
| 3990 | + const drainTrailingRootTurnChunks = Effect.fnUntraced(function* () { | |
| 3991 | + if (!flavor.settleRootTurnWhenIdle) return; | |
| 3992 | + // Projected via handleSessionUpdate, not getEvents(). Cooperative yield | |
| 3993 | + // only — replay uses TestClock; Effect.sleep here would stall settlement. | |
| 3994 | + yield* Effect.yieldNow; | |
| 3995 | + yield* Effect.yieldNow; | |
| 3996 | + }); | |
| 3997 | + | |
| 3998 | + const terminalizeOpenRunOwnedItems = Effect.fnUntraced(function* ( | |
| 3999 | + context: ActiveAcpTurn, | |
| 4000 | + options: { readonly terminalizeSubagents: boolean }, | |
| 4001 | + ) { | |
| 4002 | + for (const tool of context.tools.values()) { | |
| 4003 | + const status = toolStatus(tool.status); | |
| 4004 | + if (status === "pending" || status === "running") { | |
| 4005 | + yield* emitTool(context, tool, "interrupted"); | |
| 4006 | + } | |
| 4007 | + } | |
| 4008 | + if (!options.terminalizeSubagents) return; | |
| 4009 | + for (const subagent of context.subagents.values()) { | |
| 4010 | + if (subagent.task.status !== "running" && subagent.task.status !== "pending") { | |
| 4011 | + continue; | |
| 4012 | + } | |
| 4013 | + yield* emitSubagent(context, { | |
| 4014 | + nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id, | |
| 4015 | + prompt: subagent.task.prompt, | |
| 4016 | + title: subagent.task.title, | |
| 4017 | + model: subagent.task.model, | |
| 4018 | + status: "interrupted", | |
| 4019 | + childSessionId: subagent.childSessionId, | |
| 4020 | + result: subagent.task.result, | |
| 4021 | + suppressNormalTool: true, | |
| 4022 | + }); | |
| 4023 | + } | |
| 4024 | + }); | |
| 4025 | + | |
| 4026 | + const terminalizeOpenForegroundTools = Effect.fnUntraced(function* ( | |
| 4027 | + context: ActiveAcpTurn, | |
| 4028 | + ) { | |
| 4029 | + for (const tool of context.tools.values()) { | |
| 4030 | + if (!acpCompletedTurnShouldTerminalizeTool(tool, flavor)) continue; | |
| 4031 | + yield* emitTool(context, tool, "completed"); | |
| 4032 | + } | |
| 4033 | + }); | |
| 4034 | + | |
| 4035 | + const quarantineStoppedRun = Effect.fnUntraced(function* () { | |
| 4036 | + yield* continuationPermit.withPermit( | |
| 4037 | + Effect.gen(function* () { | |
| 4038 | + yield* Ref.update(continuationGeneration, (value) => value + 1); | |
| 4039 | + yield* Ref.set(stoppedRunQuarantine, true); | |
| 4040 | + yield* Ref.set(wakeBuffer, []); | |
| 4041 | + yield* Ref.set(continuationRequested, false); | |
| 4042 | + yield* Ref.set(runningBackgroundTaskIds, new Set()); | |
| 4043 | + yield* Ref.set(carryoverSubagents, null); | |
| 4044 | + yield* Ref.set(lastTurnRoute, null); | |
| 4045 | + }), | |
| 4046 | + ); | |
| 4047 | + }); | |
| 4048 | + | |
| 1999 | 4049 | const finalizeTurn = Effect.fnUntraced(function* ( |
| 2000 | 4050 | context: ActiveAcpTurn, |
| 2001 | 4051 | status: "completed" | "interrupted" | "failed" | "cancelled", |
| 2002 | 4052 | failure?: OrchestrationV2ProviderFailure, |
| 4053 | + options?: { readonly drainTrailingChunks?: boolean }, | |
| 2003 | 4054 | ) { |
| 2004 | 4055 | if (context.finalized) return; |
| 4056 | + const settledStatus = context.interrupted ? "interrupted" : status; | |
| 2005 | 4057 | context.finalized = true; |
| 4058 | + if (options?.drainTrailingChunks === true) { | |
| 4059 | + yield* drainTrailingRootTurnChunks(); | |
| 4060 | + } | |
| 4061 | + const directStopQuarantine = yield* Ref.get(stoppedRunQuarantine); | |
| 4062 | + if (settledStatus === "completed") { | |
| 4063 | + yield* terminalizeOpenForegroundTools(context); | |
| 4064 | + } else if (settledStatus === "interrupted") { | |
| 4065 | + // Direct Stop terminalizes every visible run-owned item. restart_active | |
| 4066 | + // keeps live subagent lineages for in-process replacement carryover. | |
| 4067 | + yield* terminalizeOpenRunOwnedItems(context, { | |
| 4068 | + terminalizeSubagents: directStopQuarantine, | |
| 4069 | + }); | |
| 4070 | + } | |
| 2006 | 4071 | yield* closeTextStreams(context); |
| 2007 | 4072 | const now = yield* DateTime.now; |
| 2008 | − const turn = providerTurnPayload(context, status, now); | |
| 4073 | + const turn = providerTurnPayload(context, settledStatus, now); | |
| 2009 | 4074 | yield* Ref.update(providerTurns, (current) => { |
| 2010 | 4075 | const updated = new Map(current); |
| 2011 | 4076 | updated.set(String(turn.id), turn); |
| @@ -2031,7 +4096,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 19 unmodified lines | ||
| 2031 | 4096 | }, |
| 2032 | 4097 | }); |
| 2033 | 4098 | yield* emitProviderEvent( |
| 2034 | − status === "failed" | |
| 4099 | + settledStatus === "failed" | |
| 2035 | 4100 | ? { |
| 2036 | 4101 | type: "turn.terminal", |
| 2037 | 4102 | driver, |
| @@ -2042,7 +4107,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 4 unmodified lines | ||
| 2042 | 4107 | context, |
| 2043 | 4108 | `terminal-failure:${context.providerTurnId}`, |
| 2044 | 4109 | ), |
| 2045 | − status, | |
| 4110 | + status: settledStatus, | |
| 2046 | 4111 | failure: failure ?? makeProviderFailure({ class: "provider_error" }), |
| 2047 | 4112 | threadDisposition: "reusable", |
| 2048 | 4113 | } |
| @@ -2052,15 +4117,108 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines | ||
| 2052 | 4117 | providerThreadId: context.input.providerThread.id, |
| 2053 | 4118 | providerTurnId: context.providerTurnId, |
| 2054 | 4119 | runOrdinal: context.input.runOrdinal, |
| 2055 | − status, | |
| 4120 | + status: settledStatus, | |
| 2056 | 4121 | failure: null, |
| 2057 | 4122 | threadDisposition: "reusable", |
| 2058 | 4123 | }, |
| 2059 | 4124 | ); |
| 4125 | + const liveSubagents = [...context.subagents.values()].filter( | |
| 4126 | + (subagent) => subagent.task.status === "running" || subagent.task.status === "pending", | |
| 4127 | + ); | |
| 4128 | + // Direct Stop must not carry residual subagents into a later run. | |
| 4129 | + if (liveSubagents.length > 0 && !directStopQuarantine) { | |
| 4130 | + const sessionId = yield* Ref.get(activeSessionId); | |
| 4131 | + if (sessionId !== null) { | |
| 4132 | + yield* Ref.set(carryoverSubagents, { sessionId, subagents: liveSubagents }); | |
| 4133 | + } | |
| 4134 | + } | |
| 2060 | 4135 | yield* Ref.set(activeTurn, null); |
| 2061 | 4136 | yield* Deferred.succeed(context.completed, undefined).pipe(Effect.ignore); |
| 2062 | 4137 | }); |
| 2063 | 4138 | |
| 4139 | + const trySettleRootTurnWhenIdle = Effect.fnUntraced(function* (context: ActiveAcpTurn) { | |
| 4140 | + const pending = yield* Ref.get(pendingRuntimeRequests); | |
| 4141 | + const hasPendingRuntimeRequest = acpTurnHasPendingRuntimeRequest( | |
| 4142 | + context.providerTurnId, | |
| 4143 | + pending, | |
| 4144 | + ); | |
| 4145 | + const hasRunningTool = [...context.tools.values()].some((tool) => { | |
| 4146 | + const status = toolStatus(tool.status); | |
| 4147 | + return status === "pending" || status === "running"; | |
| 4148 | + }); | |
| 4149 | + // Debounce already proved root-session quiescence; open segment handles | |
| 4150 | + // without an explicit close should not block settlement. | |
| 4151 | + const hasRunningSubagent = [...context.subagents.values()].some( | |
| 4152 | + (subagent) => subagent.task.status === "running", | |
| 4153 | + ); | |
| 4154 | + if ( | |
| 4155 | + !acpRootTurnIsIdle({ | |
| 4156 | + finalized: context.finalized, | |
| 4157 | + interrupted: context.interrupted, | |
| 4158 | + assistantStreamOpen: false, | |
| 4159 | + reasoningStreamOpen: false, | |
| 4160 | + hasRunningTool, | |
| 4161 | + hasPendingRuntimeRequest, | |
| 4162 | + hasToolHistory: context.tools.size > 0, | |
| 4163 | + hasRunningSubagent, | |
| 4164 | + hasOutput: context.assistant.nextSegment > 0, | |
| 4165 | + }) | |
| 4166 | + ) { | |
| 4167 | + return; | |
| 4168 | + } | |
| 4169 | + // Never session/cancel here. Speculative settle must not kill in-flight | |
| 4170 | + // Grok work; late tools would arrive with activeTurn null and drop. | |
| 4171 | + yield* finalizeTurn(context, "completed", undefined, { drainTrailingChunks: true }); | |
| 4172 | + }); | |
| 4173 | + | |
| 4174 | + scheduleDeferredFinalize = (context) => | |
| 4175 | + Effect.gen(function* () { | |
| 4176 | + if (!flavor.deferFinalizeForBackgroundWork) return; | |
| 4177 | + if (!context.promptSettled || context.finalized || context.interrupted) return; | |
| 4178 | + if (hasDeferredBackgroundWork(context)) return; | |
| 4179 | + context.backgroundFinalizeGeneration += 1; | |
| 4180 | + const generation = context.backgroundFinalizeGeneration; | |
| 4181 | + // Minimal quiet for all models (no per-model carveouts). Defer + | |
| 4182 | + // awaitingBackgroundHydration hold the turn through monitors; this | |
| 4183 | + // is only a short debounce after the last rearm so a slightly late | |
| 4184 | + // first post-hydration assistant chunk is not dropped. Multi-second | |
| 4185 | + // floors (4–20s) only prolonged Working on grok-4.5. | |
| 4186 | + yield* Effect.gen(function* () { | |
| 4187 | + yield* Effect.sleep("2000 millis"); | |
| 4188 | + if ( | |
| 4189 | + context.finalized || | |
| 4190 | + context.interrupted || | |
| 4191 | + context.backgroundFinalizeGeneration !== generation | |
| 4192 | + ) { | |
| 4193 | + return; | |
| 4194 | + } | |
| 4195 | + if (hasDeferredBackgroundWork(context)) return; | |
| 4196 | + const status = context.promptSettledStatus ?? "completed"; | |
| 4197 | + yield* finalizeTurn(context, status, undefined, { drainTrailingChunks: true }); | |
| 4198 | + }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); | |
| 4199 | + }); | |
| 4200 | + | |
| 4201 | + scheduleSettleRootTurnWhenIdle = (context) => | |
| 4202 | + Effect.gen(function* () { | |
| 4203 | + if (!flavor.settleRootTurnWhenIdle) return; | |
| 4204 | + context.settleScheduleGeneration += 1; | |
| 4205 | + const generation = context.settleScheduleGeneration; | |
| 4206 | + yield* Effect.gen(function* () { | |
| 4207 | + yield* Effect.sleep(`${acpRootTurnSettleDebounceMs} millis`); | |
| 4208 | + if (context.finalized || context.interrupted) return; | |
| 4209 | + if (context.settleScheduleGeneration !== generation) return; | |
| 4210 | + const active = yield* Ref.get(activeTurn); | |
| 4211 | + if (active !== context) return; | |
| 4212 | + yield* trySettleRootTurnWhenIdle(context); | |
| 4213 | + }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); | |
| 4214 | + }); | |
| 4215 | + | |
| 4216 | + rearmRootTurnRecoveryTimers = (context) => | |
| 4217 | + Effect.gen(function* () { | |
| 4218 | + if (!acpRootTurnShouldRearmRecoveryTimers(context)) return; | |
| 4219 | + yield* scheduleSettleRootTurnWhenIdle(context); | |
| 4220 | + }); | |
| 4221 | + | |
| 2064 | 4222 | const resolvePromptParts = Effect.fnUntraced(function* ( |
| 2065 | 4223 | turnInput: ProviderAdapterV2TurnInput, |
| 2066 | 4224 | ) { |
| @@ -2110,8 +4268,26 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 43 unmodified lines | ||
| 2110 | 4268 | return prompt; |
| 2111 | 4269 | }); |
| 2112 | 4270 | |
| 2113 | − const startTurn = Effect.fn("AcpAdapterV2.startTurn")( | |
| 4271 | + const restartRuntimeAfterTeardownIfRequired = Effect.fnUntraced(function* () { | |
| 4272 | + const restartRequired = yield* Ref.get(runtimeRestartRequired); | |
| 4273 | + if (!restartRequired) return false; | |
| 4274 | + yield* restartAcpRuntime(); | |
| 4275 | + yield* Ref.set(runtimeRestartRequired, false); | |
| 4276 | + yield* Ref.set(activeSessionId, null); | |
| 4277 | + yield* Ref.set(activeSessionSetup, null); | |
| 4278 | + yield* Ref.set(activeSelection, null); | |
| 4279 | + yield* Ref.set(snapshot, { | |
| 4280 | + order: [], | |
| 4281 | + messages: new Map(), | |
| 4282 | + loadingRole: null, | |
| 4283 | + loadingIndex: 0, | |
| 4284 | + }); | |
| 4285 | + return true; | |
| 4286 | + }); | |
| 4287 | + | |
| 4288 | + const startTurnUnlocked = Effect.fn("AcpAdapterV2.startTurn")( | |
| 2114 | 4289 | function* (turnInput: ProviderAdapterV2TurnInput) { |
| 4290 | + yield* awaitRuntimeTeardown(); | |
| 2115 | 4291 | const existing = yield* Ref.get(activeTurn); |
| 2116 | 4292 | if (existing !== null) { |
| 2117 | 4293 | return yield* new ProviderAdapterProtocolError({ |
| @@ -2120,7 +4296,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 2 unmodified lines | ||
| 2120 | 4296 | }); |
| 2121 | 4297 | } |
| 2122 | 4298 | const requestedSessionId = nativeThreadId(driver, turnInput.providerThread); |
| 2123 | − if ((yield* Ref.get(activeSessionId)) !== requestedSessionId) { | |
| 4299 | + const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired(); | |
| 4300 | + const needsSessionActivation = | |
| 4301 | + (yield* Ref.get(activeSessionId)) !== requestedSessionId || restartAfterInterrupt; | |
| 4302 | + if (needsSessionActivation) { | |
| 2124 | 4303 | const activated = yield* activateSession(requestedSessionId, turnInput.threadId); |
| 2125 | 4304 | yield* Ref.set(activeSessionId, activated.sessionId); |
| 2126 | 4305 | yield* Ref.set(activeSessionSetup, activated); |
| @@ -2147,11 +4326,32 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 20 unmodified lines | ||
| 2147 | 4326 | yield* Ref.set(activeSelection, turnInput.modelSelection); |
| 2148 | 4327 | } |
| 2149 | 4328 | } |
| 2150 | − const prompt = yield* resolvePromptParts(turnInput); | |
| 4329 | + yield* Ref.set(lastTurnRoute, { | |
| 4330 | + threadId: turnInput.threadId, | |
| 4331 | + providerThreadId: turnInput.providerThread.id, | |
| 4332 | + }); | |
| 4333 | + yield* Ref.set(suppressPostSettleMonitorPrompt, false); | |
| 4334 | + yield* Ref.set(handledBackgroundTaskIdsInActiveTurn, new Set()); | |
| 4335 | + // Continuation turns attach to wake traffic the agent already produced | |
| 4336 | + // after the prior root turn settled; do not re-prompt the ACP session. | |
| 4337 | + const isContinuationTurn = | |
| 4338 | + postSettleContinuationEnabled && | |
| 4339 | + turnInput.message.createdBy === "agent" && | |
| 4340 | + turnInput.message.creationSource === "provider"; | |
| 4341 | + // Drop a sticky continuation offer when any new turn starts so idle | |
| 4342 | + // pin and further offers cannot wed on a completed or failed dispatch. | |
| 4343 | + yield* continuationPermit.withPermit( | |
| 4344 | + Effect.gen(function* () { | |
| 4345 | + yield* Ref.update(continuationGeneration, (value) => value + 1); | |
| 4346 | + yield* Ref.set(continuationRequested, false); | |
| 4347 | + }), | |
| 4348 | + ); | |
| 4349 | + const prompt = isContinuationTurn ? null : yield* resolvePromptParts(turnInput); | |
| 2151 | 4350 | const startedAt = yield* DateTime.now; |
| 2152 | 4351 | const nativeTurnId = `${requestedSessionId}:turn:${turnInput.providerTurnOrdinal}`; |
| 2153 | 4352 | const providerTurnId = idAllocator.derive.providerTurn({ driver, nativeTurnId }); |
| 2154 | 4353 | const completed = yield* Deferred.make<void, never>(); |
| 4354 | + const promptWireSettled = yield* Deferred.make<void, never>(); | |
| 2155 | 4355 | const context: ActiveAcpTurn = { |
| 2156 | 4356 | input: turnInput, |
| 2157 | 4357 | providerTurnId, |
| @@ -2165,11 +4365,35 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 7 unmodified lines | ||
| 2165 | 4365 | subagents: new Map(), |
| 2166 | 4366 | subagentsBySessionId: new Map(), |
| 2167 | 4367 | pendingSubagentNotifications: new Map(), |
| 4368 | + toolCallIdsByBackgroundTaskId: new Map(), | |
| 4369 | + persistentBackgroundTaskIds: new Set(), | |
| 4370 | + awaitingBackgroundHydration: new Set(), | |
| 4371 | + pendingInjectedReport: new Set(), | |
| 2168 | 4372 | plan: null, |
| 2169 | 4373 | interrupted: false, |
| 2170 | 4374 | finalized: false, |
| 4375 | + settleScheduleGeneration: 0, | |
| 4376 | + promptSettled: false, | |
| 4377 | + promptSettledStatus: null, | |
| 4378 | + promptWireSettled, | |
| 4379 | + backgroundFinalizeGeneration: 0, | |
| 2171 | 4380 | }; |
| 4381 | + const carryover = yield* Ref.getAndSet(carryoverSubagents, null); | |
| 4382 | + if (carryover !== null && carryover.sessionId === requestedSessionId) { | |
| 4383 | + for (const subagent of carryover.subagents) { | |
| 4384 | + const nativeId = subagent.task.nativeTaskRef?.nativeId ?? null; | |
| 4385 | + if (nativeId !== null) { | |
| 4386 | + context.subagents.set(nativeId, subagent); | |
| 4387 | + } | |
| 4388 | + if (subagent.childSessionId !== null) { | |
| 4389 | + context.subagentsBySessionId.set(subagent.childSessionId, subagent); | |
| 4390 | + } | |
| 4391 | + } | |
| 4392 | + } | |
| 2172 | 4393 | yield* Ref.set(activeTurn, context); |
| 4394 | + // Direct Stop closes and recreates the old runtime before reaching | |
| 4395 | + // this reset. The quarantine remains session-scoped by design. | |
| 4396 | + yield* Ref.set(stoppedRunQuarantine, false); | |
| 2173 | 4397 | const runningTurn = providerTurnPayload(context, "running", null); |
| 2174 | 4398 | yield* Ref.update(providerTurns, (current) => { |
| 2175 | 4399 | const updated = new Map(current); |
| @@ -2206,35 +4430,100 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 30 unmodified lines | ||
| 2206 | 4430 | createdAt: startedAt, |
| 2207 | 4431 | updatedAt: startedAt, |
| 2208 | 4432 | }); |
| 2209 | − yield* runtime.prompt({ prompt }).pipe( | |
| 2210 | − Effect.flatMap((result) => { | |
| 2211 | − const status = | |
| 2212 | − result.stopReason === "cancelled" | |
| 2213 | − ? context.interrupted | |
| 2214 | − ? "interrupted" | |
| 2215 | − : "cancelled" | |
| 2216 | − : "completed"; | |
| 2217 | − return finalizeTurn(context, status); | |
| 2218 | − }), | |
| 4433 | + if (isContinuationTurn) { | |
| 4434 | + const drained = yield* Ref.modify(wakeBuffer, (current) => { | |
| 4435 | + const next: Array<EffectAcpSchema.SessionNotification> = []; | |
| 4436 | + return [current.slice(), next] as const; | |
| 4437 | + }); | |
| 4438 | + yield* Ref.set(continuationRequested, false); | |
| 4439 | + // Treat attach mode as prompt-settled so deferred finalize / quiet | |
| 4440 | + // windows can complete the continuation after wake traffic drains. | |
| 4441 | + context.promptSettled = true; | |
| 4442 | + context.promptSettledStatus = "completed"; | |
| 4443 | + if (drained.length === 0) { | |
| 4444 | + yield* finalizeTurn(context, "completed", undefined, { | |
| 4445 | + drainTrailingChunks: true, | |
| 4446 | + }); | |
| 4447 | + return; | |
| 4448 | + } | |
| 4449 | + for (const notification of drained) { | |
| 4450 | + yield* handleSessionUpdate(notification); | |
| 4451 | + } | |
| 4452 | + if (!context.finalized) { | |
| 4453 | + if (hasDeferredBackgroundWork(context)) { | |
| 4454 | + yield* rearmDeferredFinalize(context); | |
| 4455 | + } else { | |
| 4456 | + yield* scheduleDeferredFinalize(context); | |
| 4457 | + } | |
| 4458 | + } | |
| 4459 | + return; | |
| 4460 | + } | |
| 4461 | + const promptGeneration = yield* Ref.get(runtimeCallbackGeneration); | |
| 4462 | + yield* runtime.prompt({ prompt: prompt! }).pipe( | |
| 4463 | + // Wire settlement precedes the completion callback's permit request so | |
| 4464 | + // settled-soft classification can observe the native return even when | |
| 4465 | + // the completion fiber has not yet set promptSettled under the permit. | |
| 4466 | + Effect.tap(() => | |
| 4467 | + Deferred.succeed(context.promptWireSettled, undefined).pipe(Effect.asVoid), | |
| 4468 | + ), | |
| 4469 | + Effect.flatMap((result) => | |
| 4470 | + runRuntimeCallbackAtGeneration( | |
| 4471 | + promptGeneration, | |
| 4472 | + Effect.gen(function* () { | |
| 4473 | + if (context.finalized) return; | |
| 4474 | + const status = | |
| 4475 | + result.stopReason === "cancelled" | |
| 4476 | + ? context.interrupted | |
| 4477 | + ? "interrupted" | |
| 4478 | + : "cancelled" | |
| 4479 | + : "completed"; | |
| 4480 | + // Grok monitors (and async subagents) keep working after the root | |
| 4481 | + // prompt RPC returns. Defer finalize so their later updates and | |
| 4482 | + // wake-turn traffic still project onto this run. | |
| 4483 | + if ( | |
| 4484 | + flavor.deferFinalizeForBackgroundWork === true && | |
| 4485 | + !context.interrupted && | |
| 4486 | + hasDeferredBackgroundWork(context) | |
| 4487 | + ) { | |
| 4488 | + context.promptSettled = true; | |
| 4489 | + context.promptSettledStatus = status; | |
| 4490 | + return; | |
| 4491 | + } | |
| 4492 | + // Only completed turns drain trailing chunks. Interrupted turns | |
| 4493 | + // must not wait for residual output from a stopped prompt. | |
| 4494 | + yield* finalizeTurn(context, status, undefined, { | |
| 4495 | + drainTrailingChunks: status === "completed", | |
| 4496 | + }); | |
| 4497 | + }), | |
| 4498 | + ).pipe(Effect.asVoid), | |
| 4499 | + ), | |
| 4500 | + // Prompt failure is not wire-settled: only a successful resolve marks | |
| 4501 | + // the signal. catchCause must not complete promptWireSettled. | |
| 2219 | 4502 | Effect.catchCause((cause) => |
| 2220 | − finalizeTurn( | |
| 2221 | − context, | |
| 2222 | − context.interrupted ? "interrupted" : "failed", | |
| 2223 | − makeProviderFailure({ | |
| 2224 | − cause: Cause.squash(cause), | |
| 2225 | − class: "provider_error", | |
| 4503 | + runRuntimeCallbackAtGeneration( | |
| 4504 | + promptGeneration, | |
| 4505 | + Effect.gen(function* () { | |
| 4506 | + if (context.finalized) return; | |
| 4507 | + yield* finalizeTurn( | |
| 4508 | + context, | |
| 4509 | + context.interrupted ? "interrupted" : "failed", | |
| 4510 | + makeProviderFailure({ | |
| 4511 | + cause: Cause.squash(cause), | |
| 4512 | + class: "provider_error", | |
| 4513 | + }), | |
| 4514 | + ).pipe( | |
| 4515 | + Effect.andThen( | |
| 4516 | + Effect.logWarning("orchestration-v2.acp-prompt-failed", { | |
| 4517 | + driver, | |
| 4518 | + providerSessionId: input.providerSessionId, | |
| 4519 | + providerThreadId: turnInput.providerThread.id, | |
| 4520 | + providerTurnId, | |
| 4521 | + cause, | |
| 4522 | + }), | |
| 4523 | + ), | |
| 4524 | + ); | |
| 2226 | 4525 | }), |
| 2227 | − ).pipe( | |
| 2228 | − Effect.andThen( | |
| 2229 | − Effect.logWarning("orchestration-v2.acp-prompt-failed", { | |
| 2230 | − driver, | |
| 2231 | − providerSessionId: input.providerSessionId, | |
| 2232 | − providerThreadId: turnInput.providerThread.id, | |
| 2233 | − providerTurnId, | |
| 2234 | − cause, | |
| 2235 | − }), | |
| 2236 | − ), | |
| 2237 | − ), | |
| 4526 | + ).pipe(Effect.asVoid), | |
| 2238 | 4527 | ), |
| 2239 | 4528 | Effect.forkIn(sessionScope), |
| 2240 | 4529 | ); |
| @@ -2254,8 +4543,20 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 13 unmodified lines | ||
| 2254 | 4543 | ), |
| 2255 | 4544 | ); |
| 2256 | 4545 | |
| 4546 | + const startTurn = Effect.fn("AcpAdapterV2.startTurn.transition")(function* ( | |
| 4547 | + turnInput: ProviderAdapterV2TurnInput, | |
| 4548 | + ) { | |
| 4549 | + return yield* runtimeTransitionPermit.withPermit(startTurnUnlocked(turnInput)); | |
| 4550 | + }); | |
| 4551 | + | |
| 2257 | 4552 | yield* Effect.addFinalizer(() => |
| 2258 | 4553 | Effect.gen(function* () { |
| 4554 | + yield* continuationPermit.withPermit( | |
| 4555 | + Effect.gen(function* () { | |
| 4556 | + yield* Ref.set(continuationClosed, true); | |
| 4557 | + yield* Ref.update(continuationGeneration, (value) => value + 1); | |
| 4558 | + }), | |
| 4559 | + ); | |
| 2259 | 4560 | const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()]; |
| 2260 | 4561 | yield* Effect.forEach( |
| 2261 | 4562 | requests, |
| @@ -2265,14 +4566,40 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines | ||
| 2265 | 4566 | : Deferred.succeed(request.answers, null).pipe(Effect.ignore), |
| 2266 | 4567 | { discard: true }, |
| 2267 | 4568 | ); |
| 4569 | + const closingError = new EffectAcpErrors.AcpTransportError({ | |
| 4570 | + detail: "The ACP session closed before its admitted response reached the transport", | |
| 4571 | + cause: "ACP session transport closed", | |
| 4572 | + }); | |
| 4573 | + yield* Effect.forEach( | |
| 4574 | + requests, | |
| 4575 | + (request) => Deferred.fail(request.nativeResponseAcknowledgement, closingError), | |
| 4576 | + { discard: true }, | |
| 4577 | + ); | |
| 4578 | + const transportHadOutstandingResponses = yield* closeNativeTransport; | |
| 4579 | + yield* options.testHooks?.afterNativeResponseTransportClosed?.() ?? Effect.void; | |
| 2268 | 4580 | const sessionCapabilities = |
| 2269 | 4581 | started.initializeResult.agentCapabilities?.sessionCapabilities; |
| 2270 | 4582 | if (sessionCapabilities?.close != null) { |
| 2271 | − yield* runtime.closeSession().pipe(Effect.ignore); | |
| 4583 | + yield* runtimeTransitionPermit.withPermitsIfAvailable(1)( | |
| 4584 | + Effect.gen(function* () { | |
| 4585 | + const teardownState = yield* Ref.get(runtimeTeardownState); | |
| 4586 | + const restartRequired = yield* Ref.get(runtimeRestartRequired); | |
| 4587 | + if ( | |
| 4588 | + teardownState._tag === "Idle" && | |
| 4589 | + !restartRequired && | |
| 4590 | + !transportHadOutstandingResponses | |
| 4591 | + ) { | |
| 4592 | + yield* runtime.closeSession().pipe(Effect.ignore); | |
| 4593 | + } | |
| 4594 | + }), | |
| 4595 | + ); | |
| 2272 | 4596 | } |
| 2273 | 4597 | if (flavor.assertComplete !== undefined) { |
| 2274 | 4598 | yield* flavor.assertComplete.pipe(Effect.orDie); |
| 2275 | 4599 | } |
| 4600 | + if (runtimeScope !== undefined) { | |
| 4601 | + yield* Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore); | |
| 4602 | + } | |
| 2276 | 4603 | }), |
| 2277 | 4604 | ); |
| 2278 | 4605 | |
| @@ -2282,6 +4609,16 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines | ||
| 2282 | 4609 | providerSessionId: input.providerSessionId, |
| 2283 | 4610 | providerSession, |
| 2284 | 4611 | events: Stream.fromEffectRepeat(Queue.take(events)), |
| 4612 | + ...(postSettleContinuationEnabled | |
| 4613 | + ? { | |
| 4614 | + hasPendingBackgroundWork: Effect.gen(function* () { | |
| 4615 | + if ((yield* Ref.get(wakeBuffer)).length > 0) return true; | |
| 4616 | + if (yield* Ref.get(continuationRequested)) return true; | |
| 4617 | + if ((yield* Ref.get(runningBackgroundTaskIds)).size > 0) return true; | |
| 4618 | + return false; | |
| 4619 | + }), | |
| 4620 | + } | |
| 4621 | + : {}), | |
| 2285 | 4622 | ensureThread: Effect.fn("AcpAdapterV2.ensureThread")( |
| 2286 | 4623 | function* (threadInput: ProviderAdapterV2EnsureThreadInput) { |
| 2287 | 4624 | const now = yield* DateTime.now; |
| @@ -2320,35 +4657,41 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 32 unmodified lines | ||
| 2320 | 4657 | readonly modelSelection?: ModelSelection; |
| 2321 | 4658 | readonly runtimePolicy?: ProviderAdapterV2RuntimePolicy; |
| 2322 | 4659 | }) { |
| 2323 | − const sessionId = nativeThreadId(driver, threadInput.providerThread); | |
| 2324 | − if ((yield* Ref.get(activeSessionId)) !== sessionId) { | |
| 2325 | − yield* Ref.set(snapshot, { | |
| 2326 | − order: [], | |
| 2327 | − messages: new Map(), | |
| 2328 | − loadingRole: null, | |
| 2329 | − loadingIndex: 0, | |
| 2330 | − }); | |
| 2331 | − const activated = yield* activateSession( | |
| 2332 | − sessionId, | |
| 2333 | − threadInput.providerThread.appThreadId, | |
| 2334 | − ); | |
| 2335 | − yield* Ref.set(activeSessionId, activated.sessionId); | |
| 2336 | − yield* Ref.set(activeSessionSetup, activated); | |
| 2337 | − const nextSelection = threadInput.modelSelection ?? input.modelSelection; | |
| 2338 | − yield* configureSession( | |
| 2339 | − activated, | |
| 2340 | − nextSelection, | |
| 2341 | − threadInput.runtimePolicy ?? input.runtimePolicy, | |
| 2342 | − ); | |
| 2343 | − yield* Ref.set(activeSelection, nextSelection); | |
| 2344 | − } | |
| 2345 | − const now = yield* DateTime.now; | |
| 2346 | − return { | |
| 2347 | − ...threadInput.providerThread, | |
| 2348 | − providerSessionId: input.providerSessionId, | |
| 2349 | − status: "idle" as const, | |
| 2350 | − updatedAt: now, | |
| 2351 | − }; | |
| 4660 | + return yield* runtimeTransitionPermit.withPermit( | |
| 4661 | + Effect.gen(function* () { | |
| 4662 | + yield* awaitRuntimeTeardown(); | |
| 4663 | + const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired(); | |
| 4664 | + const sessionId = nativeThreadId(driver, threadInput.providerThread); | |
| 4665 | + if ((yield* Ref.get(activeSessionId)) !== sessionId || restartAfterInterrupt) { | |
| 4666 | + yield* Ref.set(snapshot, { | |
| 4667 | + order: [], | |
| 4668 | + messages: new Map(), | |
| 4669 | + loadingRole: null, | |
| 4670 | + loadingIndex: 0, | |
| 4671 | + }); | |
| 4672 | + const activated = yield* activateSession( | |
| 4673 | + sessionId, | |
| 4674 | + threadInput.providerThread.appThreadId, | |
| 4675 | + ); | |
| 4676 | + yield* Ref.set(activeSessionId, activated.sessionId); | |
| 4677 | + yield* Ref.set(activeSessionSetup, activated); | |
| 4678 | + const nextSelection = threadInput.modelSelection ?? input.modelSelection; | |
| 4679 | + yield* configureSession( | |
| 4680 | + activated, | |
| 4681 | + nextSelection, | |
| 4682 | + threadInput.runtimePolicy ?? input.runtimePolicy, | |
| 4683 | + ); | |
| 4684 | + yield* Ref.set(activeSelection, nextSelection); | |
| 4685 | + } | |
| 4686 | + const now = yield* DateTime.now; | |
| 4687 | + return { | |
| 4688 | + ...threadInput.providerThread, | |
| 4689 | + providerSessionId: input.providerSessionId, | |
| 4690 | + status: "idle" as const, | |
| 4691 | + updatedAt: now, | |
| 4692 | + }; | |
| 4693 | + }), | |
| 4694 | + ); | |
| 2352 | 4695 | }, |
| 2353 | 4696 | (effect, threadInput) => |
| 2354 | 4697 | effect.pipe( |
| @@ -2373,25 +4716,292 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 18 unmodified lines | ||
| 2373 | 4716 | ), |
| 2374 | 4717 | interruptTurn: Effect.fn("AcpAdapterV2.interruptTurn")( |
| 2375 | 4718 | function* (turnInput: ProviderAdapterV2InterruptInput) { |
| 2376 | − const context = yield* Ref.get(activeTurn); | |
| 2377 | − if (context?.providerTurnId !== turnInput.providerTurnId) { | |
| 2378 | − return yield* new ProviderAdapterProtocolError({ | |
| 2379 | − driver, | |
| 2380 | − detail: `ACP provider turn ${turnInput.providerTurnId} is not active`, | |
| 2381 | − }); | |
| 2382 | − } | |
| 2383 | − context.interrupted = true; | |
| 2384 | − yield* runtime.cancel.pipe(Effect.ensuring(cancelPendingRuntimeRequests())); | |
| 2385 | − const stopped = yield* Deferred.await(context.completed).pipe( | |
| 2386 | − Effect.timeoutOption("10 seconds"), | |
| 4719 | + return yield* Effect.uninterruptibleMask((restore) => | |
| 4720 | + Effect.gen(function* () { | |
| 4721 | + yield* restore(runtimeTransitionPermit.take(1)); | |
| 4722 | + return yield* Effect.gen(function* () { | |
| 4723 | + const transition = yield* Effect.gen(function* () { | |
| 4724 | + const interruptContext = yield* Ref.get(activeTurn); | |
| 4725 | + // Settled steering restart: the native prompt already | |
| 4726 | + // returned and this is not a user Stop, so keep the | |
| 4727 | + // process (and its background subagents) alive instead | |
| 4728 | + // of hard-killing it. The turn still terminalizes below. | |
| 4729 | + const softSteerCandidate = | |
| 4730 | + flavor.preserveRuntimeOnSettledInterrupt === true && | |
| 4731 | + turnInput.requestRuntimeRestart !== true && | |
| 4732 | + interruptContext?.providerTurnId === turnInput.providerTurnId; | |
| 4733 | + // Settlement is read under the callback permit after | |
| 4734 | + // draining admitted responses: the native prompt can have | |
| 4735 | + // returned on the wire while its completion callback has | |
| 4736 | + // not yet run, and a cancel sent in that window is exactly | |
| 4737 | + // what settled-soft mode exists to avoid. Also treat the | |
| 4738 | + // turn as settled when the wire signal is already done | |
| 4739 | + // (non-blocking; do not await under the permit). | |
| 4740 | + let settledSoftInterrupt = false; | |
| 4741 | + if (softSteerCandidate && interruptContext !== null) { | |
| 4742 | + const promptSettledUnderPermit = yield* runtimeCallbackPermit.withPermit( | |
| 4743 | + awaitAdmittedNativeResponses.pipe( | |
| 4744 | + Effect.andThen( | |
| 4745 | + Effect.sync(() => interruptContext.promptSettled === true), | |
| 4746 | + ), | |
| 4747 | + ), | |
| 4748 | + ); | |
| 4749 | + const wireSettled = yield* Deferred.isDone( | |
| 4750 | + interruptContext.promptWireSettled, | |
| 4751 | + ); | |
| 4752 | + settledSoftInterrupt = promptSettledUnderPermit || wireSettled; | |
| 4753 | + } | |
| 4754 | + const restartRuntime = | |
| 4755 | + !settledSoftInterrupt && | |
| 4756 | + (turnInput.requestRuntimeRestart === true || | |
| 4757 | + flavor.restartRuntimeOnEveryInterrupt === true); | |
| 4758 | + const hardRestart = | |
| 4759 | + restartRuntime && flavor.terminateRuntimeProcessGroupOnInterrupt === true; | |
| 4760 | + if (hardRestart) { | |
| 4761 | + const teardownState = yield* Ref.get(runtimeTeardownState); | |
| 4762 | + if (teardownState._tag === "Failed") { | |
| 4763 | + return yield* teardownState.error; | |
| 4764 | + } | |
| 4765 | + if (teardownState._tag === "InProgress") { | |
| 4766 | + // Concurrent Stop/restart: wait for the in-flight hard | |
| 4767 | + // teardown instead of failing the durable effect. | |
| 4768 | + yield* Effect.logInfo( | |
| 4769 | + "ACP interrupt awaiting in-progress hard teardown", | |
| 4770 | + { | |
| 4771 | + driver, | |
| 4772 | + providerTurnId: turnInput.providerTurnId, | |
| 4773 | + }, | |
| 4774 | + ); | |
| 4775 | + yield* Deferred.await(teardownState.completed); | |
| 4776 | + return undefined; | |
| 4777 | + } | |
| 4778 | + } | |
| 4779 | + const context = interruptContext; | |
| 4780 | + if (context?.providerTurnId !== turnInput.providerTurnId) { | |
| 4781 | + // A soft steering interrupt can clear the turn while | |
| 4782 | + // intentionally leaving the process alive. A queued user | |
| 4783 | + // Stop must still contain that orphan runtime. With a | |
| 4784 | + // different live turn active this stays a pure no-op | |
| 4785 | + // success instead: quarantine and teardown are | |
| 4786 | + // session-global and would maim the replacement turn. | |
| 4787 | + const containOrphanRuntime = | |
| 4788 | + context === null && | |
| 4789 | + hardRestart && | |
| 4790 | + turnInput.requestRuntimeRestart === true; | |
| 4791 | + // Transport death or a prior Stop already cleared the | |
| 4792 | + // turn. Failing here caused effect-worker retries while | |
| 4793 | + // the process was already gone; treat as success. | |
| 4794 | + yield* Effect.logWarning( | |
| 4795 | + containOrphanRuntime | |
| 4796 | + ? "ACP Stop raced a soft interrupt that cleared the turn; containing the orphan runtime" | |
| 4797 | + : "ACP interrupt raced transport teardown or a prior Stop; treating as already interrupted", | |
| 4798 | + { | |
| 4799 | + driver, | |
| 4800 | + requestedProviderTurnId: turnInput.providerTurnId, | |
| 4801 | + activeProviderTurnId: context?.providerTurnId ?? null, | |
| 4802 | + hardRestart, | |
| 4803 | + requestRuntimeRestart: turnInput.requestRuntimeRestart === true, | |
| 4804 | + teardownState: (yield* Ref.get(runtimeTeardownState))._tag, | |
| 4805 | + }, | |
| 4806 | + ); | |
| 4807 | + if (containOrphanRuntime) { | |
| 4808 | + const teardownBarrier = yield* Deferred.make< | |
| 4809 | + void, | |
| 4810 | + ProviderAdapterProtocolError | |
| 4811 | + >(); | |
| 4812 | + yield* runtimeCallbackPermit.withPermit( | |
| 4813 | + Effect.gen(function* () { | |
| 4814 | + yield* awaitAdmittedNativeResponses; | |
| 4815 | + const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration); | |
| 4816 | + yield* quarantineNativeTransportAtGeneration(stoppedGeneration); | |
| 4817 | + yield* Ref.set(runtimeTeardownState, { | |
| 4818 | + _tag: "InProgress", | |
| 4819 | + completed: teardownBarrier, | |
| 4820 | + }); | |
| 4821 | + yield* Ref.update( | |
| 4822 | + runtimeCallbackGeneration, | |
| 4823 | + (generation) => generation + 1, | |
| 4824 | + ); | |
| 4825 | + }), | |
| 4826 | + ); | |
| 4827 | + // Capture before quarantineStoppedRun clears carryover. | |
| 4828 | + const orphanCarryover = yield* Ref.getAndSet(carryoverSubagents, null); | |
| 4829 | + yield* quarantineStoppedRun(); | |
| 4830 | + // Match the main hard-restart path: cancel pending | |
| 4831 | + // approvals/elicitations after quarantine, before kill. | |
| 4832 | + yield* cancelPendingRuntimeRequests(); | |
| 4833 | + yield* terminalizeCarryoverSubagents(orphanCarryover); | |
| 4834 | + if (runtime.terminateProcessGroup === undefined) { | |
| 4835 | + const error = new ProviderAdapterProtocolError({ | |
| 4836 | + driver, | |
| 4837 | + detail: | |
| 4838 | + "ACP runtime does not expose its required process-group teardown; the session is poisoned", | |
| 4839 | + }); | |
| 4840 | + yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); | |
| 4841 | + yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore); | |
| 4842 | + return yield* error; | |
| 4843 | + } | |
| 4844 | + const teardownExit = yield* runtime.terminateProcessGroup.pipe( | |
| 4845 | + Effect.exit, | |
| 4846 | + ); | |
| 4847 | + if (Exit.isFailure(teardownExit)) { | |
| 4848 | + const error = new ProviderAdapterProtocolError({ | |
| 4849 | + driver, | |
| 4850 | + detail: | |
| 4851 | + "ACP orphan runtime process-group teardown failed; the session is poisoned", | |
| 4852 | + payload: Cause.squash(teardownExit.cause), | |
| 4853 | + }); | |
| 4854 | + yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); | |
| 4855 | + yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore); | |
| 4856 | + return yield* error; | |
| 4857 | + } | |
| 4858 | + yield* Ref.set(runtimeRestartRequired, true); | |
| 4859 | + yield* Ref.set(runtimeTeardownState, { _tag: "Idle" }); | |
| 4860 | + yield* Deferred.succeed(teardownBarrier, undefined).pipe(Effect.ignore); | |
| 4861 | + } | |
| 4862 | + return undefined; | |
| 4863 | + } | |
| 4864 | + const teardownBarrier = hardRestart | |
| 4865 | + ? yield* Deferred.make<void, ProviderAdapterProtocolError>() | |
| 4866 | + : null; | |
| 4867 | + if (teardownBarrier !== null) { | |
| 4868 | + yield* runtimeCallbackPermit.withPermit( | |
| 4869 | + Effect.gen(function* () { | |
| 4870 | + yield* awaitAdmittedNativeResponses; | |
| 4871 | + const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration); | |
| 4872 | + yield* quarantineNativeTransportAtGeneration(stoppedGeneration); | |
| 4873 | + yield* Ref.set(runtimeTeardownState, { | |
| 4874 | + _tag: "InProgress", | |
| 4875 | + completed: teardownBarrier, | |
| 4876 | + }); | |
| 4877 | + yield* Ref.update( | |
| 4878 | + runtimeCallbackGeneration, | |
| 4879 | + (generation) => generation + 1, | |
| 4880 | + ); | |
| 4881 | + yield* ( | |
| 4882 | + options.testHooks?.afterHardTeardownTransportDrained?.() ?? | |
| 4883 | + Effect.void | |
| 4884 | + ); | |
| 4885 | + }), | |
| 4886 | + ); | |
| 4887 | + } | |
| 4888 | + return { | |
| 4889 | + context, | |
| 4890 | + restartRuntime, | |
| 4891 | + hardRestart, | |
| 4892 | + teardownBarrier, | |
| 4893 | + settledSoftInterrupt, | |
| 4894 | + }; | |
| 4895 | + }); | |
| 4896 | + // Concurrent hard teardown already completed above. | |
| 4897 | + if (transition === undefined) return; | |
| 4898 | + const { | |
| 4899 | + context, | |
| 4900 | + restartRuntime, | |
| 4901 | + hardRestart, | |
| 4902 | + teardownBarrier, | |
| 4903 | + settledSoftInterrupt, | |
| 4904 | + } = transition; | |
| 4905 | + const poisonTeardown = Effect.fnUntraced(function* ( | |
| 4906 | + detail: string, | |
| 4907 | + payload?: unknown, | |
| 4908 | + ) { | |
| 4909 | + const error = new ProviderAdapterProtocolError({ | |
| 4910 | + driver, | |
| 4911 | + detail, | |
| 4912 | + ...(payload === undefined ? {} : { payload }), | |
| 4913 | + }); | |
| 4914 | + yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); | |
| 4915 | + yield* Deferred.fail(teardownBarrier!, error).pipe(Effect.ignore); | |
| 4916 | + return error; | |
| 4917 | + }); | |
| 4918 | + const runTransition = Effect.gen(function* () { | |
| 4919 | + context.interrupted = true; | |
| 4920 | + // Quarantine only when this run is discarded (user Stop / | |
| 4921 | + // hard process kill). Soft in-process restarts must keep | |
| 4922 | + // carryoverSubagents so a still-running subagent can | |
| 4923 | + // complete into the replacement turn. | |
| 4924 | + if (hardRestart || turnInput.requestRuntimeRestart === true) { | |
| 4925 | + yield* quarantineStoppedRun(); | |
| 4926 | + } | |
| 4927 | + yield* cancelPendingRuntimeRequests(); | |
| 4928 | + // Finalize before process-group kill so projection/UI cannot | |
| 4929 | + // lag a dead transport, and concurrent interrupt effects see | |
| 4930 | + // activeTurn cleared (idempotent success) rather than racing. | |
| 4931 | + if ( | |
| 4932 | + (hardRestart || context.promptSettled || settledSoftInterrupt) && | |
| 4933 | + !context.finalized | |
| 4934 | + ) { | |
| 4935 | + // hardRestart: always terminalize locally. | |
| 4936 | + // promptSettled / settledSoftInterrupt (incl. wire-settled): | |
| 4937 | + // native prompt already returned; only deferred background | |
| 4938 | + // work remains, so session/cancel has nothing to acknowledge. | |
| 4939 | + yield* finalizeTurn(context, "interrupted"); | |
| 4940 | + } | |
| 4941 | + if (hardRestart) { | |
| 4942 | + if (runtime.terminateProcessGroup === undefined) { | |
| 4943 | + return yield* poisonTeardown( | |
| 4944 | + "ACP runtime does not expose its required process-group teardown; the session is poisoned", | |
| 4945 | + ).pipe(Effect.flatMap(Effect.fail)); | |
| 4946 | + } | |
| 4947 | + const teardownExit = yield* runtime.terminateProcessGroup!.pipe( | |
| 4948 | + Effect.exit, | |
| 4949 | + ); | |
| 4950 | + if (Exit.isFailure(teardownExit)) { | |
| 4951 | + return yield* poisonTeardown( | |
| 4952 | + "ACP runtime process-group teardown failed; the session is poisoned", | |
| 4953 | + Cause.squash(teardownExit.cause), | |
| 4954 | + ).pipe(Effect.flatMap(Effect.fail)); | |
| 4955 | + } | |
| 4956 | + // Process group is gone; the next turn must spawn a | |
| 4957 | + // replacement even if the flavor only set hardRestart | |
| 4958 | + // without restartRuntimeAfterInterrupt. | |
| 4959 | + yield* Ref.set(runtimeRestartRequired, true); | |
| 4960 | + yield* Ref.set(runtimeTeardownState, { _tag: "Idle" }); | |
| 4961 | + yield* Deferred.succeed(teardownBarrier!, undefined).pipe(Effect.ignore); | |
| 4962 | + } else { | |
| 4963 | + // Settled soft interrupt: the native prompt already | |
| 4964 | + // returned, so session/cancel has nothing to | |
| 4965 | + // acknowledge and would only threaten still-running | |
| 4966 | + // background subagents. Skip it and leave the runtime | |
| 4967 | + // untouched for the replacement turn. | |
| 4968 | + if (!settledSoftInterrupt) { | |
| 4969 | + yield* runtime.cancel; | |
| 4970 | + } | |
| 4971 | + if (restartRuntime && flavor.restartRuntimeAfterInterrupt === true) { | |
| 4972 | + yield* Ref.set(runtimeRestartRequired, true); | |
| 4973 | + } | |
| 4974 | + } | |
| 4975 | + const stopped = yield* Deferred.await(context.completed).pipe( | |
| 4976 | + Effect.timeoutOption("10 seconds"), | |
| 4977 | + ); | |
| 4978 | + if (Option.isNone(stopped)) { | |
| 4979 | + return yield* new ProviderAdapterProtocolError({ | |
| 4980 | + driver, | |
| 4981 | + detail: `ACP provider turn ${turnInput.providerTurnId} did not acknowledge cancellation before the interrupt timeout`, | |
| 4982 | + }); | |
| 4983 | + } | |
| 4984 | + }); | |
| 4985 | + if (!hardRestart) { | |
| 4986 | + return yield* restore(runTransition); | |
| 4987 | + } | |
| 4988 | + return yield* runTransition.pipe( | |
| 4989 | + Effect.catchCause((cause) => | |
| 4990 | + Effect.gen(function* () { | |
| 4991 | + const state = yield* Ref.get(runtimeTeardownState); | |
| 4992 | + if (state._tag === "InProgress") { | |
| 4993 | + yield* poisonTeardown( | |
| 4994 | + "ACP hard teardown failed unexpectedly; the session is poisoned", | |
| 4995 | + Cause.squash(cause), | |
| 4996 | + ); | |
| 4997 | + } | |
| 4998 | + return yield* Effect.failCause(cause); | |
| 4999 | + }), | |
| 5000 | + ), | |
| 5001 | + ); | |
| 5002 | + }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1))); | |
| 5003 | + }), | |
| 2387 | 5004 | ); |
| 2388 | − if (Option.isNone(stopped)) { | |
| 2389 | − yield* Ref.update(activeTurn, (current) => (current === context ? null : current)); | |
| 2390 | − return yield* new ProviderAdapterProtocolError({ | |
| 2391 | − driver, | |
| 2392 | − detail: `ACP provider turn ${turnInput.providerTurnId} did not acknowledge cancellation before the interrupt timeout`, | |
| 2393 | − }); | |
| 2394 | − } | |
| 2395 | 5005 | }, |
| 2396 | 5006 | (effect, turnInput) => |
| 2397 | 5007 | effect.pipe( |
| @@ -2407,28 +5017,43 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 9 unmodified lines | ||
| 2407 | 5017 | ), |
| 2408 | 5018 | ), |
| 2409 | 5019 | respondToRuntimeRequest: (requestInput) => |
| 2410 | − Effect.gen(function* () { | |
| 2411 | − const pending = (yield* Ref.get(pendingRuntimeRequests)).get( | |
| 2412 | − String(requestInput.requestId), | |
| 2413 | − ); | |
| 2414 | − if (pending === undefined) { | |
| 2415 | − return yield* new ProviderAdapterProtocolError({ | |
| 2416 | − driver, | |
| 2417 | − detail: `No pending ACP runtime request ${requestInput.requestId}`, | |
| 2418 | − }); | |
| 2419 | − } | |
| 2420 | − if (pending.type === "user_input") { | |
| 2421 | − yield* Deferred.succeed(pending.answers, requestInput.answers ?? null); | |
| 2422 | − return; | |
| 2423 | − } | |
| 2424 | − if (requestInput.decision === undefined) { | |
| 2425 | − return yield* new ProviderAdapterProtocolError({ | |
| 2426 | − driver, | |
| 2427 | − detail: `ACP approval request ${requestInput.requestId} requires a decision`, | |
| 2428 | − }); | |
| 2429 | − } | |
| 2430 | − yield* Deferred.succeed(pending.decision, requestInput.decision); | |
| 2431 | − }).pipe( | |
| 5020 | + Effect.uninterruptibleMask((restore) => | |
| 5021 | + Effect.gen(function* () { | |
| 5022 | + yield* restore(runtimeTransitionPermit.take(1)); | |
| 5023 | + return yield* Effect.gen(function* () { | |
| 5024 | + yield* awaitRuntimeTeardown(); | |
| 5025 | + const generation = yield* Ref.get(runtimeCallbackGeneration); | |
| 5026 | + const pending = (yield* Ref.get(pendingRuntimeRequests)).get( | |
| 5027 | + String(requestInput.requestId), | |
| 5028 | + ); | |
| 5029 | + if (pending === undefined || pending.generation !== generation) { | |
| 5030 | + return yield* new ProviderAdapterProtocolError({ | |
| 5031 | + driver, | |
| 5032 | + detail: `No pending ACP runtime request ${requestInput.requestId}`, | |
| 5033 | + }); | |
| 5034 | + } | |
| 5035 | + const settled = | |
| 5036 | + pending.type === "user_input" | |
| 5037 | + ? yield* Deferred.succeed(pending.answers, requestInput.answers ?? null) | |
| 5038 | + : requestInput.decision === undefined | |
| 5039 | + ? yield* new ProviderAdapterProtocolError({ | |
| 5040 | + driver, | |
| 5041 | + detail: `ACP approval request ${requestInput.requestId} requires a decision`, | |
| 5042 | + }) | |
| 5043 | + : yield* Deferred.succeed(pending.decision, requestInput.decision); | |
| 5044 | + if (!settled) { | |
| 5045 | + return yield* new ProviderAdapterProtocolError({ | |
| 5046 | + driver, | |
| 5047 | + detail: `ACP runtime request ${requestInput.requestId} was already resolved`, | |
| 5048 | + }); | |
| 5049 | + } | |
| 5050 | + yield* awaitNativeResponseAcknowledgements([ | |
| 5051 | + [pending.transportRequestId, pending.nativeResponseAcknowledgement], | |
| 5052 | + ]); | |
| 5053 | + yield* Deferred.await(pending.nativeResponseAcknowledgement); | |
| 5054 | + }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1))); | |
| 5055 | + }), | |
| 5056 | + ).pipe( | |
| 2432 | 5057 | Effect.mapError( |
| 2433 | 5058 | (cause) => |
| 2434 | 5059 | new ProviderAdapterRuntimeRequestResponseError({ |
| @@ -2440,44 +5065,50 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines | ||
| 2440 | 5065 | ), |
| 2441 | 5066 | readThreadSnapshot: Effect.fn("AcpAdapterV2.readThreadSnapshot")( |
| 2442 | 5067 | function* (snapshotInput) { |
| 2443 | − const sessionId = nativeThreadId(driver, snapshotInput.providerThread); | |
| 2444 | − if ((yield* Ref.get(activeSessionId)) !== sessionId) { | |
| 2445 | − if (!capabilities.threads.canReadThreadSnapshot) { | |
| 2446 | − return yield* new ProviderAdapterProtocolError({ | |
| 2447 | − driver, | |
| 2448 | − detail: "ACP driver does not support session/load snapshots", | |
| 2449 | − }); | |
| 2450 | − } | |
| 2451 | − yield* Ref.set(snapshot, { | |
| 2452 | − order: [], | |
| 2453 | − messages: new Map(), | |
| 2454 | − loadingRole: null, | |
| 2455 | − loadingIndex: 0, | |
| 2456 | − }); | |
| 2457 | − const activated = yield* runtime.loadSession(sessionId, { | |
| 2458 | − mcpServers: acpMcpServers(snapshotInput.providerThread.appThreadId), | |
| 2459 | − }); | |
| 2460 | − yield* Ref.set(activeSessionId, activated.sessionId); | |
| 2461 | − yield* Ref.set(activeSessionSetup, activated); | |
| 2462 | − yield* Ref.set(activeSelection, null); | |
| 2463 | − } | |
| 2464 | − const state = yield* Ref.get(snapshot); | |
| 2465 | − const now = yield* DateTime.now; | |
| 2466 | − return { | |
| 2467 | − providerThread: { | |
| 2468 | − ...snapshotInput.providerThread, | |
| 2469 | − providerSessionId: input.providerSessionId, | |
| 2470 | − status: "idle" as const, | |
| 2471 | − updatedAt: now, | |
| 2472 | − }, | |
| 2473 | − providerTurns: [...(yield* Ref.get(providerTurns)).values()], | |
| 2474 | − messages: state.order.flatMap((key) => { | |
| 2475 | − const message = state.messages.get(key); | |
| 2476 | − return message === undefined ? [] : [message]; | |
| 5068 | + return yield* runtimeTransitionPermit.withPermit( | |
| 5069 | + Effect.gen(function* () { | |
| 5070 | + yield* awaitRuntimeTeardown(); | |
| 5071 | + yield* restartRuntimeAfterTeardownIfRequired(); | |
| 5072 | + const sessionId = nativeThreadId(driver, snapshotInput.providerThread); | |
| 5073 | + if ((yield* Ref.get(activeSessionId)) !== sessionId) { | |
| 5074 | + if (!capabilities.threads.canReadThreadSnapshot) { | |
| 5075 | + return yield* new ProviderAdapterProtocolError({ | |
| 5076 | + driver, | |
| 5077 | + detail: "ACP driver does not support session/load snapshots", | |
| 5078 | + }); | |
| 5079 | + } | |
| 5080 | + yield* Ref.set(snapshot, { | |
| 5081 | + order: [], | |
| 5082 | + messages: new Map(), | |
| 5083 | + loadingRole: null, | |
| 5084 | + loadingIndex: 0, | |
| 5085 | + }); | |
| 5086 | + const activated = yield* runtime.loadSession(sessionId, { | |
| 5087 | + mcpServers: acpMcpServers(snapshotInput.providerThread.appThreadId), | |
| 5088 | + }); | |
| 5089 | + yield* Ref.set(activeSessionId, activated.sessionId); | |
| 5090 | + yield* Ref.set(activeSessionSetup, activated); | |
| 5091 | + yield* Ref.set(activeSelection, null); | |
| 5092 | + } | |
| 5093 | + const state = yield* Ref.get(snapshot); | |
| 5094 | + const now = yield* DateTime.now; | |
| 5095 | + return { | |
| 5096 | + providerThread: { | |
| 5097 | + ...snapshotInput.providerThread, | |
| 5098 | + providerSessionId: input.providerSessionId, | |
| 5099 | + status: "idle" as const, | |
| 5100 | + updatedAt: now, | |
| 5101 | + }, | |
| 5102 | + providerTurns: [...(yield* Ref.get(providerTurns)).values()], | |
| 5103 | + messages: state.order.flatMap((key) => { | |
| 5104 | + const message = state.messages.get(key); | |
| 5105 | + return message === undefined ? [] : [message]; | |
| 5106 | + }), | |
| 5107 | + runtimeRequests: [], | |
| 5108 | + providerPayload: { protocol: ACP_PROTOCOL, sessionId }, | |
| 5109 | + }; | |
| 2477 | 5110 | }), |
| 2478 | − runtimeRequests: [], | |
| 2479 | − providerPayload: { protocol: ACP_PROTOCOL, sessionId }, | |
| 2480 | − }; | |
| 5111 | + ); | |
| 2481 | 5112 | }, |
| 2482 | 5113 | (effect, snapshotInput) => |
| 2483 | 5114 | effect.pipe( |
| @@ -2502,44 +5133,50 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 18 unmodified lines | ||
| 2502 | 5133 | ), |
| 2503 | 5134 | forkThread: Effect.fn("AcpAdapterV2.forkThread")( |
| 2504 | 5135 | function* (forkInput) { |
| 2505 | − if (!capabilities.threads.canForkThread) { | |
| 2506 | − return yield* new ProviderAdapterProtocolError({ | |
| 2507 | − driver, | |
| 2508 | − detail: "ACP driver did not negotiate session/fork", | |
| 2509 | − }); | |
| 2510 | − } | |
| 2511 | − if (forkInput.providerTurnId !== undefined) { | |
| 2512 | − return yield* new ProviderAdapterProtocolError({ | |
| 2513 | − driver, | |
| 2514 | − detail: "ACP session/fork can only fork the current session head", | |
| 2515 | − }); | |
| 2516 | − } | |
| 2517 | − const sourceSessionId = nativeThreadId(driver, forkInput.sourceProviderThread); | |
| 2518 | − const forked = yield* runtime.forkSession(sourceSessionId, { | |
| 2519 | − mcpServers: acpMcpServers(forkInput.targetThreadId), | |
| 2520 | − }); | |
| 2521 | − yield* Ref.set(activeSessionId, forked.sessionId); | |
| 2522 | − yield* Ref.set(activeSessionSetup, forked); | |
| 2523 | − yield* Ref.set(activeSelection, null); | |
| 2524 | − const now = yield* DateTime.now; | |
| 2525 | − return makeProviderThread({ | |
| 2526 | − driver, | |
| 2527 | − providerInstanceId: options.instanceId, | |
| 2528 | − idAllocator, | |
| 2529 | − appThreadId: forkInput.targetThreadId, | |
| 2530 | − providerSessionId: input.providerSessionId, | |
| 2531 | − nativeThreadId: forked.sessionId, | |
| 2532 | − ...(forkInput.ownerNodeId === undefined | |
| 2533 | − ? {} | |
| 2534 | − : { ownerNodeId: forkInput.ownerNodeId }), | |
| 2535 | − forkedFrom: { | |
| 2536 | − providerThreadId: forkInput.sourceProviderThread.id, | |
| 2537 | − ...(forkInput.providerTurnId === undefined | |
| 2538 | − ? {} | |
| 2539 | − : { providerTurnId: forkInput.providerTurnId }), | |
| 2540 | − }, | |
| 2541 | − now, | |
| 2542 | − }); | |
| 5136 | + return yield* runtimeTransitionPermit.withPermit( | |
| 5137 | + Effect.gen(function* () { | |
| 5138 | + yield* awaitRuntimeTeardown(); | |
| 5139 | + yield* restartRuntimeAfterTeardownIfRequired(); | |
| 5140 | + if (!capabilities.threads.canForkThread) { | |
| 5141 | + return yield* new ProviderAdapterProtocolError({ | |
| 5142 | + driver, | |
| 5143 | + detail: "ACP driver did not negotiate session/fork", | |
| 5144 | + }); | |
| 5145 | + } | |
| 5146 | + if (forkInput.providerTurnId !== undefined) { | |
| 5147 | + return yield* new ProviderAdapterProtocolError({ | |
| 5148 | + driver, | |
| 5149 | + detail: "ACP session/fork can only fork the current session head", | |
| 5150 | + }); | |
| 5151 | + } | |
| 5152 | + const sourceSessionId = nativeThreadId(driver, forkInput.sourceProviderThread); | |
| 5153 | + const forked = yield* runtime.forkSession(sourceSessionId, { | |
| 5154 | + mcpServers: acpMcpServers(forkInput.targetThreadId), | |
| 5155 | + }); | |
| 5156 | + yield* Ref.set(activeSessionId, forked.sessionId); | |
| 5157 | + yield* Ref.set(activeSessionSetup, forked); | |
| 5158 | + yield* Ref.set(activeSelection, null); | |
| 5159 | + const now = yield* DateTime.now; | |
| 5160 | + return makeProviderThread({ | |
| 5161 | + driver, | |
| 5162 | + providerInstanceId: options.instanceId, | |
| 5163 | + idAllocator, | |
| 5164 | + appThreadId: forkInput.targetThreadId, | |
| 5165 | + providerSessionId: input.providerSessionId, | |
| 5166 | + nativeThreadId: forked.sessionId, | |
| 5167 | + ...(forkInput.ownerNodeId === undefined | |
| 5168 | + ? {} | |
| 5169 | + : { ownerNodeId: forkInput.ownerNodeId }), | |
| 5170 | + forkedFrom: { | |
| 5171 | + providerThreadId: forkInput.sourceProviderThread.id, | |
| 5172 | + ...(forkInput.providerTurnId === undefined | |
| 5173 | + ? {} | |
| 5174 | + : { providerTurnId: forkInput.providerTurnId }), | |
| 5175 | + }, | |
| 5176 | + now, | |
| 5177 | + }); | |
| 5178 | + }), | |
| 5179 | + ); | |
| 2543 | 5180 | }, |
| 2544 | 5181 | (effect, forkInput) => |
| 2545 | 5182 | effect.pipe( |
| @@ -146,6 +146,46 @@ describe("CodexAdapterV2 assistant message streaming", () => { · 145 unmodified lines | ||
| 146 | 146 | assert.equal((yield* Ref.get(updates)).length, 2); |
| 147 | 147 | }), |
| 148 | 148 | ); |
| 149 | + | |
| 150 | + it.effect("retains buffered text until completion updates are emitted", () => | |
| 151 | + Effect.gen(function* () { | |
| 152 | + const updates = yield* Ref.make<ReadonlyArray<CodexAgentMessageDeltaUpdate>>([]); | |
| 153 | + const failNext = yield* Ref.make(true); | |
| 154 | + const coalescer = yield* makeCodexAgentMessageDeltaCoalescer({ | |
| 155 | + flushIntervalMs: 50, | |
| 156 | + emit: (update) => | |
| 157 | + Ref.getAndSet(failNext, false).pipe( | |
| 158 | + Effect.flatMap((shouldFail) => | |
| 159 | + shouldFail | |
| 160 | + ? Effect.die("projection unavailable") | |
| 161 | + : Ref.update(updates, (current) => [...current, update]), | |
| 162 | + ), | |
| 163 | + ), | |
| 164 | + }); | |
| 165 | + | |
| 166 | + yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "turn final" }); | |
| 167 | + const failedFlush = yield* coalescer.flushTurn("turn-1").pipe(Effect.exit); | |
| 168 | + assert.equal(failedFlush._tag, "Failure"); | |
| 169 | + yield* coalescer.flushTurn("turn-1"); | |
| 170 | + | |
| 171 | + yield* coalescer.append({ turnId: "turn-1", itemId: "message-2", delta: "item final" }); | |
| 172 | + yield* Ref.set(failNext, true); | |
| 173 | + const failedComplete = yield* coalescer | |
| 174 | + .complete({ turnId: "turn-1", itemId: "message-2" }) | |
| 175 | + .pipe(Effect.exit); | |
| 176 | + assert.equal(failedComplete._tag, "Failure"); | |
| 177 | + const completedText = yield* coalescer.complete({ | |
| 178 | + turnId: "turn-1", | |
| 179 | + itemId: "message-2", | |
| 180 | + }); | |
| 181 | + | |
| 182 | + assert.equal(completedText, "item final"); | |
| 183 | + assert.deepEqual(yield* Ref.get(updates), [ | |
| 184 | + { turnId: "turn-1", itemId: "message-1", text: "turn final", completed: true }, | |
| 185 | + { turnId: "turn-1", itemId: "message-2", text: "item final", completed: true }, | |
| 186 | + ]); | |
| 187 | + }), | |
| 188 | + ); | |
| 149 | 189 | }); |
| 150 | 190 | |
| 151 | 191 | describe("CodexAdapterV2 runtime policy", () => { |
| @@ -868,31 +868,37 @@ export const makeCodexAgentMessageDeltaCoalescer = Effect.fn( · 867 unmodified lines | ||
| 868 | 868 | }) => |
| 869 | 869 | flushLock.withPermit( |
| 870 | 870 | Effect.gen(function* () { |
| 871 | − const updates = yield* Ref.modify(buffered, (current) => { | |
| 871 | + const current = yield* Ref.get(buffered); | |
| 872 | + const updates: Array<CodexAgentMessageDeltaUpdate> = []; | |
| 873 | + for (const message of current.values()) { | |
| 874 | + if (!options.predicate(message) || (options.onlyDirty && !message.dirty)) { | |
| 875 | + continue; | |
| 876 | + } | |
| 877 | + updates.push({ | |
| 878 | + turnId: message.turnId, | |
| 879 | + itemId: message.itemId, | |
| 880 | + text: message.text, | |
| 881 | + completed: options.completed, | |
| 882 | + }); | |
| 883 | + } | |
| 884 | + const emitUpdates = Effect.forEach(updates, input.emit, { discard: true }); | |
| 885 | + yield* options.releaseSchedule === true | |
| 886 | + ? emitUpdates.pipe(Effect.ensuring(Ref.set(flushScheduled, false))) | |
| 887 | + : emitUpdates; | |
| 888 | + yield* Ref.update(buffered, (current) => { | |
| 872 | 889 | const next = new Map(current); |
| 873 | − const pending: Array<CodexAgentMessageDeltaUpdate> = []; | |
| 874 | 890 | for (const [key, message] of current) { |
| 875 | 891 | if (!options.predicate(message) || (options.onlyDirty && !message.dirty)) { |
| 876 | 892 | continue; |
| 877 | 893 | } |
| 878 | − pending.push({ | |
| 879 | − turnId: message.turnId, | |
| 880 | − itemId: message.itemId, | |
| 881 | − text: message.text, | |
| 882 | − completed: options.completed, | |
| 883 | − }); | |
| 884 | 894 | if (options.completed) { |
| 885 | 895 | next.delete(key); |
| 886 | 896 | } else { |
| 887 | 897 | next.set(key, { ...message, dirty: false }); |
| 888 | 898 | } |
| 889 | 899 | } |
| 890 | − return [pending, next] as const; | |
| 900 | + return next; | |
| 891 | 901 | }); |
| 892 | − if (options.releaseSchedule === true) { | |
| 893 | − yield* Ref.set(flushScheduled, false); | |
| 894 | − } | |
| 895 | − yield* Effect.forEach(updates, input.emit, { discard: true }); | |
| 896 | 902 | }), |
| 897 | 903 | ); |
| 898 | 904 | |
| @@ -936,14 +942,15 @@ export const makeCodexAgentMessageDeltaCoalescer = Effect.fn( · 37 unmodified lines | ||
| 936 | 942 | complete: ({ turnId, itemId, finalText }) => |
| 937 | 943 | flushLock.withPermit( |
| 938 | 944 | Effect.gen(function* () { |
| 939 | − const text = yield* Ref.modify(buffered, (current) => { | |
| 940 | − const key = codexAgentMessageBufferKey(turnId, itemId); | |
| 941 | − const existing = current.get(key); | |
| 945 | + const key = codexAgentMessageBufferKey(turnId, itemId); | |
| 946 | + const existing = (yield* Ref.get(buffered)).get(key); | |
| 947 | + const text = finalText && finalText.length > 0 ? finalText : (existing?.text ?? ""); | |
| 948 | + yield* input.emit({ turnId, itemId, text, completed: true }); | |
| 949 | + yield* Ref.update(buffered, (current) => { | |
| 942 | 950 | const next = new Map(current); |
| 943 | 951 | next.delete(key); |
| 944 | − return [finalText && finalText.length > 0 ? finalText : (existing?.text ?? ""), next]; | |
| 952 | + return next; | |
| 945 | 953 | }); |
| 946 | − yield* input.emit({ turnId, itemId, text, completed: true }); | |
| 947 | 954 | return text; |
| 948 | 955 | }), |
| 949 | 956 | ), |
| @@ -1,9 +1,25 @@ | ||
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | +import * as Effect from "effect/Effect"; | |
| 2 | 3 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 3 | 4 | |
| 4 | 5 | import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; |
| 5 | −import { AcpProviderCapabilitiesV2, acpPermissionDisposition } from "./AcpAdapterV2.ts"; | |
| 6 | −import { GrokProviderCapabilitiesV2 } from "./GrokAdapterV2.ts"; | |
| 6 | +import { | |
| 7 | + AcpProviderCapabilitiesV2, | |
| 8 | + acpCompletedTurnShouldTerminalizeTool, | |
| 9 | + acpPermissionDisposition, | |
| 10 | + acpRootSessionUpdateIngestsOutput, | |
| 11 | + acpRootTurnCompletionDrainMs, | |
| 12 | + acpRootTurnHasIngestedOutput, | |
| 13 | + acpRootTurnIsIdle, | |
| 14 | + acpRootTurnSettleDebounceMs, | |
| 15 | + acpRootTurnShouldRearmRecoveryTimers, | |
| 16 | + acpSupportsImagePrompts, | |
| 17 | +} from "./AcpAdapterV2.ts"; | |
| 18 | +import { | |
| 19 | + makeGrokAcpAdapterFlavor, | |
| 20 | + GrokProviderCapabilitiesV2, | |
| 21 | + type GrokAdapterV2Options, | |
| 22 | +} from "./GrokAdapterV2.ts"; | |
| 7 | 23 | |
| 8 | 24 | function permissionRequest( |
| 9 | 25 | kind: EffectAcpSchema.ToolKind, |
| @@ -37,7 +53,221 @@ function runtimePolicy(input: { · 27 unmodified lines | ||
| 37 | 53 | }); |
| 38 | 54 | } |
| 39 | 55 | |
| 56 | +describe("acpRootTurnSettleDebounceMs", () => { | |
| 57 | + it("keeps the historical debounce constant for re-enable experiments", () => { | |
| 58 | + assert.equal(acpRootTurnSettleDebounceMs, 2_000); | |
| 59 | + }); | |
| 60 | +}); | |
| 61 | + | |
| 62 | +describe("acpRootTurnCompletionDrainMs", () => { | |
| 63 | + it("gives trailing root chunks a short landing window", () => { | |
| 64 | + assert.equal(acpRootTurnCompletionDrainMs, 100); | |
| 65 | + }); | |
| 66 | +}); | |
| 67 | + | |
| 68 | +describe("acpRootSessionUpdateIngestsOutput", () => { | |
| 69 | + const sessionId = "session-1"; | |
| 70 | + | |
| 71 | + it("ignores empty assistant chunks used as Grok keepalives", () => { | |
| 72 | + assert.isFalse( | |
| 73 | + acpRootSessionUpdateIngestsOutput({ | |
| 74 | + sessionId, | |
| 75 | + update: { | |
| 76 | + sessionUpdate: "agent_message_chunk", | |
| 77 | + content: { type: "text", text: "" }, | |
| 78 | + }, | |
| 79 | + }), | |
| 80 | + ); | |
| 81 | + }); | |
| 82 | + | |
| 83 | + it("accepts non-empty assistant and reasoning chunks", () => { | |
| 84 | + assert.isTrue( | |
| 85 | + acpRootSessionUpdateIngestsOutput({ | |
| 86 | + sessionId, | |
| 87 | + update: { | |
| 88 | + sessionUpdate: "agent_message_chunk", | |
| 89 | + content: { type: "text", text: "hello" }, | |
| 90 | + }, | |
| 91 | + }), | |
| 92 | + ); | |
| 93 | + assert.isTrue( | |
| 94 | + acpRootSessionUpdateIngestsOutput({ | |
| 95 | + sessionId, | |
| 96 | + update: { | |
| 97 | + sessionUpdate: "agent_thought_chunk", | |
| 98 | + content: { type: "text", text: "thinking" }, | |
| 99 | + }, | |
| 100 | + }), | |
| 101 | + ); | |
| 102 | + }); | |
| 103 | + | |
| 104 | + it("accepts tool and plan updates", () => { | |
| 105 | + assert.isTrue( | |
| 106 | + acpRootSessionUpdateIngestsOutput({ | |
| 107 | + sessionId, | |
| 108 | + update: { | |
| 109 | + sessionUpdate: "tool_call", | |
| 110 | + toolCallId: "tool-1", | |
| 111 | + title: "Read", | |
| 112 | + kind: "read", | |
| 113 | + status: "pending", | |
| 114 | + }, | |
| 115 | + }), | |
| 116 | + ); | |
| 117 | + assert.isTrue( | |
| 118 | + acpRootSessionUpdateIngestsOutput({ | |
| 119 | + sessionId, | |
| 120 | + update: { | |
| 121 | + sessionUpdate: "plan", | |
| 122 | + entries: [{ content: "Step 1", status: "pending", priority: "medium" }], | |
| 123 | + }, | |
| 124 | + }), | |
| 125 | + ); | |
| 126 | + }); | |
| 127 | +}); | |
| 128 | + | |
| 129 | +describe("acpRootTurnHasIngestedOutput", () => { | |
| 130 | + const empty = { | |
| 131 | + assistant: { current: null, nextSegment: 0 }, | |
| 132 | + reasoning: { current: null, nextSegment: 0 }, | |
| 133 | + tools: new Map(), | |
| 134 | + plan: null, | |
| 135 | + } as const; | |
| 136 | + | |
| 137 | + it("is false before any root turn items land", () => { | |
| 138 | + assert.isFalse(acpRootTurnHasIngestedOutput(empty)); | |
| 139 | + }); | |
| 140 | + | |
| 141 | + it("is true once assistant segments have streamed", () => { | |
| 142 | + assert.isTrue( | |
| 143 | + acpRootTurnHasIngestedOutput({ | |
| 144 | + ...empty, | |
| 145 | + assistant: { current: null, nextSegment: 1 }, | |
| 146 | + }), | |
| 147 | + ); | |
| 148 | + }); | |
| 149 | +}); | |
| 150 | + | |
| 151 | +describe("acpRootTurn recovery timer re-arm", () => { | |
| 152 | + it("re-arms idle settle after pending clears on active turns", () => { | |
| 153 | + assert.isTrue(acpRootTurnShouldRearmRecoveryTimers({ finalized: false, interrupted: false })); | |
| 154 | + }); | |
| 155 | + | |
| 156 | + it("skips re-arm when the turn is already terminal", () => { | |
| 157 | + assert.isFalse(acpRootTurnShouldRearmRecoveryTimers({ finalized: true, interrupted: false })); | |
| 158 | + assert.isFalse(acpRootTurnShouldRearmRecoveryTimers({ finalized: false, interrupted: true })); | |
| 159 | + }); | |
| 160 | +}); | |
| 161 | + | |
| 162 | +describe("acpRootTurnIsIdle", () => { | |
| 163 | + const quiet = { | |
| 164 | + finalized: false, | |
| 165 | + interrupted: false, | |
| 166 | + assistantStreamOpen: false, | |
| 167 | + reasoningStreamOpen: false, | |
| 168 | + hasRunningTool: false, | |
| 169 | + hasPendingRuntimeRequest: false, | |
| 170 | + hasToolHistory: false, | |
| 171 | + hasRunningSubagent: false, | |
| 172 | + hasOutput: true, | |
| 173 | + } as const; | |
| 174 | + | |
| 175 | + it("is false while assistant text is still streaming", () => { | |
| 176 | + assert.isFalse(acpRootTurnIsIdle({ ...quiet, assistantStreamOpen: true })); | |
| 177 | + }); | |
| 178 | + | |
| 179 | + it("is false while a tool is running", () => { | |
| 180 | + assert.isFalse(acpRootTurnIsIdle({ ...quiet, hasRunningTool: true })); | |
| 181 | + }); | |
| 182 | + | |
| 183 | + it("is false after tool history (prompt RPC owns terminalization)", () => { | |
| 184 | + assert.isFalse(acpRootTurnIsIdle({ ...quiet, hasToolHistory: true })); | |
| 185 | + }); | |
| 186 | + | |
| 187 | + it("is false while a native subagent task is still running", () => { | |
| 188 | + assert.isFalse(acpRootTurnIsIdle({ ...quiet, hasRunningSubagent: true })); | |
| 189 | + }); | |
| 190 | + | |
| 191 | + it("is false when only reasoning or tools have streamed", () => { | |
| 192 | + assert.isFalse(acpRootTurnIsIdle({ ...quiet, hasOutput: false })); | |
| 193 | + }); | |
| 194 | + | |
| 195 | + it("is false for assistant-only quiet (preamble-before-tools must not settle)", () => { | |
| 196 | + assert.isFalse(acpRootTurnIsIdle(quiet)); | |
| 197 | + }); | |
| 198 | + | |
| 199 | + it("is false when tools finished and root is quiet (no speculative multi-wave settle)", () => { | |
| 200 | + assert.isFalse( | |
| 201 | + acpRootTurnIsIdle({ | |
| 202 | + ...quiet, | |
| 203 | + hasToolHistory: true, | |
| 204 | + hasRunningTool: false, | |
| 205 | + }), | |
| 206 | + ); | |
| 207 | + }); | |
| 208 | +}); | |
| 209 | + | |
| 40 | 210 | describe("GrokAdapterV2 capabilities", () => { |
| 211 | + it("wires hard Stop teardown but soft non-Stop interrupts in the constructor flavor", () => { | |
| 212 | + const flavor = makeGrokAcpAdapterFlavor({ | |
| 213 | + makeRuntime: () => Effect.never, | |
| 214 | + } as unknown as GrokAdapterV2Options); | |
| 215 | + | |
| 216 | + assert.isFalse(flavor.interruptPromptOnCancel); | |
| 217 | + // User Stop (requestRuntimeRestart) keeps the hard process-group kill and | |
| 218 | + // respawn: Grok cancel is detach-and-continue, so only a process kill | |
| 219 | + // stops the work. | |
| 220 | + assert.isTrue(flavor.restartRuntimeAfterInterrupt); | |
| 221 | + assert.isTrue(flavor.terminateRuntimeProcessGroupOnInterrupt); | |
| 222 | + // Non-Stop interrupts (steering, restart_active) reuse the process and | |
| 223 | + // session; the cancelled work backgrounds and the model decides its fate. | |
| 224 | + assert.isUndefined(flavor.restartRuntimeOnEveryInterrupt); | |
| 225 | + assert.isTrue(flavor.preserveRuntimeOnSettledInterrupt); | |
| 226 | + }); | |
| 227 | + | |
| 228 | + it("terminalizes only foreground tools under the actual Grok flavor", () => { | |
| 229 | + const flavor = makeGrokAcpAdapterFlavor({ | |
| 230 | + makeRuntime: () => Effect.never, | |
| 231 | + } as unknown as GrokAdapterV2Options); | |
| 232 | + const foreground = { | |
| 233 | + toolCallId: "foreground-1", | |
| 234 | + title: "Terminal", | |
| 235 | + status: "inProgress" as const, | |
| 236 | + data: { | |
| 237 | + rawInput: { command: "true" }, | |
| 238 | + rawOutput: { type: "Bash", exit_code: 0 }, | |
| 239 | + }, | |
| 240 | + }; | |
| 241 | + const monitor = { | |
| 242 | + toolCallId: "monitor-1", | |
| 243 | + title: "Monitor", | |
| 244 | + status: "inProgress" as const, | |
| 245 | + data: { | |
| 246 | + rawInput: { variant: "Monitor", command: "sleep 30" }, | |
| 247 | + rawOutput: { | |
| 248 | + type: "Monitor", | |
| 249 | + taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", | |
| 250 | + }, | |
| 251 | + }, | |
| 252 | + }; | |
| 253 | + const subagent = { | |
| 254 | + toolCallId: "subagent-1", | |
| 255 | + title: "Task", | |
| 256 | + status: "inProgress" as const, | |
| 257 | + data: { | |
| 258 | + rawInput: { | |
| 259 | + description: "Inspect interrupt handling", | |
| 260 | + prompt: "Review the adapter.", | |
| 261 | + subagent_type: "generalPurpose", | |
| 262 | + }, | |
| 263 | + }, | |
| 264 | + }; | |
| 265 | + | |
| 266 | + assert.isTrue(acpCompletedTurnShouldTerminalizeTool(foreground, flavor)); | |
| 267 | + assert.isFalse(acpCompletedTurnShouldTerminalizeTool(monitor, flavor)); | |
| 268 | + assert.isFalse(acpCompletedTurnShouldTerminalizeTool(subagent, flavor)); | |
| 269 | + }); | |
| 270 | + | |
| 41 | 271 | it("keeps optional protocol features conservative until a flavor or handshake confirms them", () => { |
| 42 | 272 | assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsModelSwitchInSession); |
| 43 | 273 | assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsRuntimeModeSwitchInSession); |
| @@ -45,6 +275,27 @@ describe("GrokAdapterV2 capabilities", () => { · 1 unmodified line | ||
| 45 | 275 | assert.isFalse(AcpProviderCapabilitiesV2.tools.supportsMcpTools); |
| 46 | 276 | }); |
| 47 | 277 | |
| 278 | + it("overrides ACP image capability false so screenshot attachments can prompt", () => { | |
| 279 | + // Handshake alone would refuse attachments (Grok advertises image:false). | |
| 280 | + assert.isFalse( | |
| 281 | + acpSupportsImagePrompts({ | |
| 282 | + negotiatedImage: false, | |
| 283 | + }), | |
| 284 | + ); | |
| 285 | + // Flavor override unblocks image content blocks for Grok. | |
| 286 | + assert.isTrue( | |
| 287 | + acpSupportsImagePrompts({ | |
| 288 | + flavorSupportsImagePrompts: true, | |
| 289 | + negotiatedImage: false, | |
| 290 | + }), | |
| 291 | + ); | |
| 292 | + assert.isTrue( | |
| 293 | + acpSupportsImagePrompts({ | |
| 294 | + negotiatedImage: true, | |
| 295 | + }), | |
| 296 | + ); | |
| 297 | + }); | |
| 298 | + | |
| 48 | 299 | it("declares Grok Task envelopes as native subagents", () => { |
| 49 | 300 | assert.isFalse(GrokProviderCapabilitiesV2.threads.canForkThread); |
| 50 | 301 | assert.isTrue(GrokProviderCapabilitiesV2.subagents.supportsSubagents); |
| @@ -21,17 +21,26 @@ import { · 20 unmodified lines | ||
| 21 | 21 | resolveGrokAcpBaseModelId, |
| 22 | 22 | } from "../../provider/acp/GrokAcpSupport.ts"; |
| 23 | 23 | import { |
| 24 | + extractXAiAcpBackgroundToolMutation, | |
| 25 | + extractXAiAcpSubagentEndNotice, | |
| 26 | + extractXAiAcpSubagentUpdate, | |
| 24 | 27 | extractXAiAskUserQuestionIdentity, |
| 25 | 28 | extractXAiAskUserQuestions, |
| 26 | − extractXAiAcpSubagentUpdate, | |
| 29 | + extractXAiBackgroundTaskCompletion, | |
| 30 | + extractXAiKilledBackgroundTasks, | |
| 31 | + extractXAiMonitorTaskId, | |
| 32 | + isXAiPersistentMonitor, | |
| 27 | 33 | makeXAiAskUserQuestionCancelledResponse, |
| 28 | 34 | makeXAiAskUserQuestionResponse, |
| 35 | + normalizeXAiAcpToolCallState, | |
| 36 | + registerXAiBackgroundTaskTracking, | |
| 29 | 37 | XAiAskUserQuestionRequest, |
| 30 | 38 | } from "../../provider/acp/XAiAcpExtension.ts"; |
| 31 | 39 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; |
| 32 | 40 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; |
| 33 | 41 | import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; |
| 34 | 42 | import { IdAllocatorV2 } from "../IdAllocator.ts"; |
| 43 | +import { ProviderContinuationRequests } from "../ProviderContinuationRequests.ts"; | |
| 35 | 44 | import { ProviderAdapterV2 } from "../ProviderAdapter.ts"; |
| 36 | 45 | import { |
| 37 | 46 | ProviderAdapterDriverCreateError, |
| @@ -41,6 +50,7 @@ import { · 3 unmodified lines | ||
| 41 | 50 | import { |
| 42 | 51 | AcpProviderCapabilitiesV2, |
| 43 | 52 | makeAcpAdapterV2, |
| 53 | + type AcpAdapterV2ExtensionContext, | |
| 44 | 54 | type AcpAdapterV2Flavor, |
| 45 | 55 | type AcpAdapterV2RuntimeInput, |
| 46 | 56 | } from "./AcpAdapterV2.ts"; |
| @@ -88,6 +98,7 @@ export interface GrokAdapterV2Options { · 41 unmodified lines | ||
| 88 | 98 | readonly idAllocator: IdAllocatorV2["Service"]; |
| 89 | 99 | readonly serverConfig: ServerConfig["Service"]; |
| 90 | 100 | readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"]; |
| 101 | + readonly continuationRequests?: Parameters<typeof makeAcpAdapterV2>[0]["continuationRequests"]; | |
| 91 | 102 | readonly makeRuntime?: ( |
| 92 | 103 | input: AcpAdapterV2RuntimeInput, |
| 93 | 104 | ) => Effect.Effect< |
| @@ -101,7 +112,16 @@ export interface GrokAdapterV2Options { · 7 unmodified lines | ||
| 101 | 112 | export const registerGrokAcpExtensions: NonNullable<AcpAdapterV2Flavor["registerExtensions"]> = ({ |
| 102 | 113 | runtime, |
| 103 | 114 | requestUserInput, |
| 115 | + applyBackgroundTaskMutation, | |
| 104 | 116 | }) => |
| 117 | + registerXAiBackgroundTaskTracking(runtime, applyBackgroundTaskMutation).pipe( | |
| 118 | + Effect.andThen(registerGrokAskUserQuestionExtensions({ runtime, requestUserInput })), | |
| 119 | + ); | |
| 120 | + | |
| 121 | +const registerGrokAskUserQuestionExtensions = ({ | |
| 122 | + runtime, | |
| 123 | + requestUserInput, | |
| 124 | +}: Pick<AcpAdapterV2ExtensionContext, "runtime" | "requestUserInput">) => | |
| 105 | 125 | Effect.forEach( |
| 106 | 126 | ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const, |
| 107 | 127 | (method) => |
| @@ -115,37 +135,78 @@ export const registerGrokAcpExtensions: NonNullable<AcpAdapterV2Flavor["register · 7 unmodified lines | ||
| 115 | 135 | })); |
| 116 | 136 | return requestUserInput({ |
| 117 | 137 | nativeItemId: `${identity.sessionId}:xai-question:${identity.toolCallId}`, |
| 138 | + nativeMethod: method, | |
| 118 | 139 | nativeRequestId: identity.toolCallId, |
| 140 | + nativeSessionId: identity.sessionId, | |
| 119 | 141 | questions, |
| 120 | 142 | }).pipe( |
| 121 | − Effect.map((answers) => | |
| 122 | − answers === null | |
| 123 | − ? makeXAiAskUserQuestionCancelledResponse() | |
| 124 | − : makeXAiAskUserQuestionResponse(params, answers), | |
| 143 | + Effect.flatMap(({ acknowledgeNativeResponse, answers }) => | |
| 144 | + Effect.succeed( | |
| 145 | + answers === null | |
| 146 | + ? makeXAiAskUserQuestionCancelledResponse() | |
| 147 | + : makeXAiAskUserQuestionResponse(params, answers), | |
| 148 | + ).pipe(Effect.tap(() => acknowledgeNativeResponse)), | |
| 125 | 149 | ), |
| 126 | 150 | ); |
| 127 | 151 | }), |
| 128 | 152 | { discard: true }, |
| 129 | 153 | ); |
| 130 | 154 | |
| 131 | −export function makeGrokAdapterV2(options: GrokAdapterV2Options) { | |
| 132 | − const flavor: AcpAdapterV2Flavor = { | |
| 155 | +export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdapterV2Flavor { | |
| 156 | + return { | |
| 133 | 157 | driver: GROK_PROVIDER, |
| 134 | 158 | capabilities: GrokProviderCapabilitiesV2, |
| 159 | + // Idle settle over-settled preamble-before-tools turns and cancelled the | |
| 160 | + // prompt while Grok continued, freezing T3 projection mid-turn. | |
| 161 | + settleRootTurnWhenIdle: false, | |
| 162 | + interruptPromptOnCancel: false, | |
| 163 | + // User Stop (requestRuntimeRestart) still hard-kills the process group | |
| 164 | + // and respawns: Grok `session/cancel` is detach-and-continue (the CLI | |
| 165 | + // re-runs cancelled foreground work as a background task no client RPC | |
| 166 | + // can kill, E3 harness 2026-07-18), so only a process kill truly stops | |
| 167 | + // work. Non-Stop interrupts (mid-prompt steering, restart_active) omit | |
| 168 | + // requestRuntimeRestart and stay soft: the session survives in the same | |
| 169 | + // process, cancelled work backgrounds, and the model itself decides via | |
| 170 | + // kill_command_or_subagent whether it keeps running. | |
| 171 | + restartRuntimeAfterInterrupt: true, | |
| 172 | + terminateRuntimeProcessGroupOnInterrupt: true, | |
| 173 | + // Steering restarts on a settled turn additionally skip session/cancel so | |
| 174 | + // fire-and-forget subagents survive the steer (E1 harness confirmed the | |
| 175 | + // Grok CLI accepts a concurrent session/prompt in that state). | |
| 176 | + preserveRuntimeOnSettledInterrupt: true, | |
| 177 | + // Grok ACP initialize reports promptCapabilities.image:false but the agent | |
| 178 | + // still accepts image content blocks (verified with real screenshots). | |
| 179 | + supportsImagePrompts: true, | |
| 135 | 180 | resolveModelId: (selection) => resolveGrokAcpBaseModelId(selection.model), |
| 136 | 181 | makeRuntime: |
| 137 | 182 | options.makeRuntime ?? |
| 138 | 183 | ((input) => |
| 139 | 184 | makeGrokAcpRuntime({ |
| 140 | 185 | ...input, |
| 186 | + interruptPromptOnCancel: input.interruptPromptOnCancel ?? false, | |
| 141 | 187 | grokSettings: options.settings, |
| 142 | 188 | environment: options.environment, |
| 143 | 189 | childProcessSpawner: options.childProcessSpawner, |
| 144 | 190 | })), |
| 145 | 191 | registerExtensions: registerGrokAcpExtensions, |
| 146 | 192 | extractSubagentUpdate: extractXAiAcpSubagentUpdate, |
| 193 | + extractSubagentEndNotice: extractXAiAcpSubagentEndNotice, | |
| 194 | + normalizeToolCall: normalizeXAiAcpToolCallState, | |
| 195 | + extractBackgroundTaskId: extractXAiMonitorTaskId, | |
| 196 | + extractBackgroundToolMutation: extractXAiAcpBackgroundToolMutation, | |
| 197 | + extractBackgroundTaskCompletion: (toolCall) => [ | |
| 198 | + ...extractXAiBackgroundTaskCompletion(toolCall), | |
| 199 | + ...extractXAiKilledBackgroundTasks(toolCall), | |
| 200 | + ], | |
| 201 | + isPersistentBackgroundTool: isXAiPersistentMonitor, | |
| 202 | + deferFinalizeForBackgroundWork: true, | |
| 203 | + enablePostSettleContinuation: true, | |
| 147 | 204 | ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), |
| 148 | 205 | }; |
| 206 | +} | |
| 207 | + | |
| 208 | +export function makeGrokAdapterV2(options: GrokAdapterV2Options) { | |
| 209 | + const flavor = makeGrokAcpAdapterFlavor(options); | |
| 149 | 210 | return makeAcpAdapterV2({ |
| 150 | 211 | instanceId: options.instanceId, |
| 151 | 212 | flavor, |
| @@ -153,6 +214,9 @@ export function makeGrokAdapterV2(options: GrokAdapterV2Options) { · 1 unmodified line | ||
| 153 | 214 | idAllocator: options.idAllocator, |
| 154 | 215 | serverConfig: options.serverConfig, |
| 155 | 216 | ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), |
| 217 | + ...(options.continuationRequests === undefined | |
| 218 | + ? {} | |
| 219 | + : { continuationRequests: options.continuationRequests }), | |
| 156 | 220 | }); |
| 157 | 221 | } |
| 158 | 222 | |
| @@ -176,6 +240,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapte · 17 unmodified lines | ||
| 176 | 240 | const idAllocator = yield* IdAllocatorV2; |
| 177 | 241 | const providerEventLoggers = yield* ProviderEventLoggers; |
| 178 | 242 | const serverConfig = yield* ServerConfig; |
| 243 | + const continuationRequests = yield* ProviderContinuationRequests; | |
| 179 | 244 | const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); |
| 180 | 245 | return makeGrokAdapterV2({ |
| 181 | 246 | instanceId: input.instanceId, |
| @@ -185,6 +250,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapte · 3 unmodified lines | ||
| 185 | 250 | fileSystem, |
| 186 | 251 | idAllocator, |
| 187 | 252 | serverConfig, |
| 253 | + continuationRequests, | |
| 188 | 254 | nativeLogging: (threadId) => |
| 189 | 255 | makeNativeLogger({ |
| 190 | 256 | nativeEventLogger: providerEventLoggers.native, |
| @@ -226,6 +292,7 @@ export const layer: Layer.Layer< · 35 unmodified lines | ||
| 226 | 292 | const idAllocator = yield* IdAllocatorV2; |
| 227 | 293 | const providerEventLoggers = yield* ProviderEventLoggers; |
| 228 | 294 | const serverConfig = yield* ServerConfig; |
| 295 | + const continuationRequests = yield* ProviderContinuationRequests; | |
| 229 | 296 | const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); |
| 230 | 297 | return makeGrokAdapterV2({ |
| 231 | 298 | instanceId: GROK_DEFAULT_INSTANCE_ID, |
| @@ -235,6 +302,7 @@ export const layer: Layer.Layer< · 3 unmodified lines | ||
| 235 | 302 | fileSystem, |
| 236 | 303 | idAllocator, |
| 237 | 304 | serverConfig, |
| 305 | + continuationRequests, | |
| 238 | 306 | nativeLogging: (threadId) => |
| 239 | 307 | makeNativeLogger({ |
| 240 | 308 | nativeEventLogger: providerEventLoggers.native, |
| @@ -17,7 +17,11 @@ import * as Ref from "effect/Ref"; · 16 unmodified lines | ||
| 17 | 17 | |
| 18 | 18 | import { CheckpointRollbackServiceV2 } from "./CheckpointRollbackService.ts"; |
| 19 | 19 | import type { OrchestrationEffectV2 } from "./EffectOutbox.ts"; |
| 20 | −import { executorLayer, OrchestrationEffectExecutorV2 } from "./EffectWorker.ts"; | |
| 20 | +import { | |
| 21 | + executorLayer, | |
| 22 | + isNonRetryableProviderTurnControlFailure, | |
| 23 | + OrchestrationEffectExecutorV2, | |
| 24 | +} from "./EffectWorker.ts"; | |
| 21 | 25 | import { RunFinalizationService } from "./RunFinalizationService.ts"; |
| 22 | 26 | import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; |
| 23 | 27 | import { ProviderTurnControlServiceV2 } from "./ProviderTurnControlService.ts"; |
| @@ -133,6 +137,40 @@ function makeExecutorLayer(input: { · 109 unmodified lines | ||
| 133 | 137 | return executorLayer.pipe(Layer.provide(dependencies)); |
| 134 | 138 | } |
| 135 | 139 | |
| 140 | +it("does not retry pure interrupt races where the turn is already gone", () => { | |
| 141 | + assert.isTrue( | |
| 142 | + isNonRetryableProviderTurnControlFailure( | |
| 143 | + "provider-turn.interrupt", | |
| 144 | + "ProviderAdapterInterruptError: ... ACP provider turn provider-turn:x is not active", | |
| 145 | + ), | |
| 146 | + ); | |
| 147 | + assert.isTrue( | |
| 148 | + isNonRetryableProviderTurnControlFailure( | |
| 149 | + "provider-turn.interrupt", | |
| 150 | + "Provider session provider-session:x is not active.", | |
| 151 | + ), | |
| 152 | + ); | |
| 153 | + // Restart is compound (interrupt + detach + start). Do not swallow start failures. | |
| 154 | + assert.isFalse( | |
| 155 | + isNonRetryableProviderTurnControlFailure( | |
| 156 | + "provider-turn.restart", | |
| 157 | + "Provider session provider-session:x is not active.", | |
| 158 | + ), | |
| 159 | + ); | |
| 160 | + assert.isFalse( | |
| 161 | + isNonRetryableProviderTurnControlFailure( | |
| 162 | + "provider-turn.start", | |
| 163 | + "Provider session provider-session:x is not active.", | |
| 164 | + ), | |
| 165 | + ); | |
| 166 | + assert.isFalse( | |
| 167 | + isNonRetryableProviderTurnControlFailure( | |
| 168 | + "provider-turn.interrupt", | |
| 169 | + "ACP hard teardown failed unexpectedly; the session is poisoned", | |
| 170 | + ), | |
| 171 | + ); | |
| 172 | +}); | |
| 173 | + | |
| 136 | 174 | it.effect("detaches a handed-off session only after the old turn terminalizes", () => |
| 137 | 175 | Effect.gen(function* () { |
| 138 | 176 | const now = yield* DateTime.now; |
| @@ -25,6 +25,30 @@ export class OrchestrationEffectExecutionError extends Schema.TaggedErrorClass<O · 24 unmodified lines | ||
| 25 | 25 | }, |
| 26 | 26 | ) {} |
| 27 | 27 | |
| 28 | +/** | |
| 29 | + * Pure interrupt races with hard process teardown or a dead session produce | |
| 30 | + * "not active" protocol errors. Retrying those only delays recovery. | |
| 31 | + * | |
| 32 | + * Do not apply this to `provider-turn.restart`: that compound effect also runs | |
| 33 | + * detach and start. Swallowing a start failure that happens to mention | |
| 34 | + * "is not active" would drop the outbox item without ever starting the | |
| 35 | + * replacement turn. | |
| 36 | + */ | |
| 37 | +export function isNonRetryableProviderTurnControlFailure( | |
| 38 | + effectType: string, | |
| 39 | + errorText: string, | |
| 40 | +): boolean { | |
| 41 | + if (effectType !== "provider-turn.interrupt") { | |
| 42 | + return false; | |
| 43 | + } | |
| 44 | + return ( | |
| 45 | + /is not active/i.test(errorText) || | |
| 46 | + /hard teardown is already in progress/i.test(errorText) || | |
| 47 | + /treating as already interrupted/i.test(errorText) || | |
| 48 | + /treating as already stopped/i.test(errorText) | |
| 49 | + ); | |
| 50 | +} | |
| 51 | + | |
| 28 | 52 | export interface OrchestrationEffectExecutorV2Shape { |
| 29 | 53 | readonly execute: ( |
| 30 | 54 | effect: OrchestrationEffectV2, |
| @@ -348,14 +372,19 @@ export const layerWithOptions = ( · 317 unmodified lines | ||
| 348 | 372 | } |
| 349 | 373 | |
| 350 | 374 | const error = Cause.pretty(exit.cause); |
| 375 | + const nonRetryable = isNonRetryableProviderTurnControlFailure(effect.request.type, error); | |
| 351 | 376 | yield* Effect.logWarning("Orchestration effect execution failed", { |
| 352 | 377 | effectId: effect.id, |
| 353 | 378 | effectType: effect.request.type, |
| 354 | 379 | attemptCount: effect.attemptCount, |
| 380 | + nonRetryable, | |
| 355 | 381 | error, |
| 356 | 382 | }); |
| 357 | − const updated = | |
| 358 | − effect.attemptCount >= maxAttempts | |
| 383 | + // Prefer succeed for terminal interrupt races so the outbox does not | |
| 384 | + // keep a failed interrupt around; fail only when we must not retry. | |
| 385 | + const updated = nonRetryable | |
| 386 | + ? yield* outbox.succeed({ effectId: effect.id, workerId }) | |
| 387 | + : effect.attemptCount >= maxAttempts | |
| 359 | 388 | ? yield* outbox.fail({ effectId: effect.id, workerId, error }) |
| 360 | 389 | : yield* outbox.retry({ |
| 361 | 390 | effectId: effect.id, |
| @@ -573,6 +573,7 @@ function activeLocalTurnItems( · 572 unmodified lines | ||
| 573 | 573 | item, |
| 574 | 574 | runs: projection.runs, |
| 575 | 575 | attempts: projection.attempts, |
| 576 | + items: projection.turnItems, | |
| 576 | 577 | }), |
| 577 | 578 | ) |
| 578 | 579 | .map((item, position) => ({ |
| @@ -668,6 +669,7 @@ function visibleTurnItemsThroughRun(input: { · 89 unmodified lines | ||
| 668 | 669 | isOrchestrationV2SupersededInterrupt({ |
| 669 | 670 | item, |
| 670 | 671 | attempts: input.sourceProjection.attempts, |
| 672 | + items: input.sourceProjection.turnItems, | |
| 671 | 673 | }) |
| 672 | 674 | ) { |
| 673 | 675 | return false; |
| @@ -407,6 +407,8 @@ export interface ProviderAdapterV2SteerInput { · 406 unmodified lines | ||
| 407 | 407 | export interface ProviderAdapterV2InterruptInput { |
| 408 | 408 | readonly providerThread: OrchestrationV2ProviderThread; |
| 409 | 409 | readonly providerTurnId: ProviderTurnId; |
| 410 | + /** When true, the next `startTurn` may respawn the provider runtime (Grok Stop recovery). */ | |
| 411 | + readonly requestRuntimeRestart?: boolean; | |
| 410 | 412 | } |
| 411 | 413 | |
| 412 | 414 | export interface ProviderAdapterV2RuntimeRequestResponseInput { |
| @@ -2,6 +2,7 @@ import { ProviderDriverKind, ProviderThreadId, ThreadId } from "@t3tools/contrac · 1 unmodified line | ||
| 2 | 2 | import * as Context from "effect/Context"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | +import * as Option from "effect/Option"; | |
| 5 | 6 | import * as Queue from "effect/Queue"; |
| 6 | 7 | |
| 7 | 8 | export interface ProviderContinuationRequest { |
| @@ -9,6 +10,9 @@ export interface ProviderContinuationRequest { · 1 unmodified line | ||
| 9 | 10 | readonly providerThreadId: ProviderThreadId; |
| 10 | 11 | readonly driver: ProviderDriverKind; |
| 11 | 12 | readonly detail: string | null; |
| 13 | + readonly dispatchIfCurrent?: <A, E, R>( | |
| 14 | + effect: Effect.Effect<A, E, R>, | |
| 15 | + ) => Effect.Effect<Option.Option<A>, E, R>; | |
| 12 | 16 | } |
| 13 | 17 | |
| 14 | 18 | /** |
| @@ -0,0 +1,193 @@ | ||
| 1 | +import { assert, describe, it } from "@effect/vitest"; | |
| 2 | +import { | |
| 3 | + ProviderDriverKind, | |
| 4 | + ProviderThreadId, | |
| 5 | + ThreadId, | |
| 6 | + type OrchestrationV2ThreadProjection, | |
| 7 | +} from "@t3tools/contracts"; | |
| 8 | +import * as Deferred from "effect/Deferred"; | |
| 9 | +import * as Effect from "effect/Effect"; | |
| 10 | +import * as Layer from "effect/Layer"; | |
| 11 | +import * as Option from "effect/Option"; | |
| 12 | +import * as Queue from "effect/Queue"; | |
| 13 | +import * as Ref from "effect/Ref"; | |
| 14 | +import * as Semaphore from "effect/Semaphore"; | |
| 15 | + | |
| 16 | +import { layer as idAllocatorLayer } from "./IdAllocator.ts"; | |
| 17 | +import { | |
| 18 | + type ProviderContinuationRequest, | |
| 19 | + ProviderContinuationRequests, | |
| 20 | + layer as continuationRequestsLayer, | |
| 21 | +} from "./ProviderContinuationRequests.ts"; | |
| 22 | +import { workerLive } from "./ProviderContinuationService.ts"; | |
| 23 | +import { ThreadManagementService } from "./ThreadManagementService.ts"; | |
| 24 | + | |
| 25 | +const threadId = ThreadId.make("thread-provider-continuation"); | |
| 26 | +const providerThreadId = ProviderThreadId.make("provider-thread-continuation"); | |
| 27 | +const driver = ProviderDriverKind.make("continuation-test"); | |
| 28 | +const projection = { | |
| 29 | + thread: { archivedAt: null }, | |
| 30 | + messages: [], | |
| 31 | +} as unknown as OrchestrationV2ThreadProjection; | |
| 32 | + | |
| 33 | +const request = ( | |
| 34 | + dispatchIfCurrent?: ProviderContinuationRequest["dispatchIfCurrent"], | |
| 35 | + detail: string | null = null, | |
| 36 | +): ProviderContinuationRequest => ({ | |
| 37 | + threadId, | |
| 38 | + providerThreadId, | |
| 39 | + driver, | |
| 40 | + detail, | |
| 41 | + ...(dispatchIfCurrent === undefined ? {} : { dispatchIfCurrent }), | |
| 42 | +}); | |
| 43 | + | |
| 44 | +const makeGuard = Effect.fnUntraced(function* (completed?: Deferred.Deferred<void>) { | |
| 45 | + const generation = yield* Ref.make(0); | |
| 46 | + const permit = yield* Semaphore.make(1); | |
| 47 | + const capture = Effect.gen(function* () { | |
| 48 | + const captured = yield* Ref.updateAndGet(generation, (value) => value + 1); | |
| 49 | + return <A, E, R>(effect: Effect.Effect<A, E, R>) => | |
| 50 | + permit | |
| 51 | + .withPermit( | |
| 52 | + Effect.gen(function* () { | |
| 53 | + if ((yield* Ref.get(generation)) !== captured) return Option.none(); | |
| 54 | + return Option.some(yield* effect); | |
| 55 | + }), | |
| 56 | + ) | |
| 57 | + .pipe( | |
| 58 | + completed === undefined | |
| 59 | + ? (effect) => effect | |
| 60 | + : Effect.ensuring(Deferred.succeed(completed, undefined)), | |
| 61 | + ); | |
| 62 | + }); | |
| 63 | + return { | |
| 64 | + capture, | |
| 65 | + invalidate: permit.withPermit(Ref.update(generation, (value) => value + 1)), | |
| 66 | + }; | |
| 67 | +}); | |
| 68 | + | |
| 69 | +function testLayer(input: { | |
| 70 | + readonly dispatched: Queue.Queue<unknown>; | |
| 71 | + readonly getThreadProjection: () => Effect.Effect<OrchestrationV2ThreadProjection>; | |
| 72 | +}) { | |
| 73 | + const threads = Layer.mock(ThreadManagementService)({ | |
| 74 | + getThreadProjection: input.getThreadProjection, | |
| 75 | + dispatch: (command) => Queue.offer(input.dispatched, command).pipe(Effect.as({} as never)), | |
| 76 | + }); | |
| 77 | + const worker = workerLive.pipe( | |
| 78 | + Layer.provide(Layer.mergeAll(idAllocatorLayer, continuationRequestsLayer, threads)), | |
| 79 | + ); | |
| 80 | + return Layer.merge(continuationRequestsLayer, worker); | |
| 81 | +} | |
| 82 | + | |
| 83 | +describe("ProviderContinuationService", () => { | |
| 84 | + it.effect("dispatches a current request exactly once", () => { | |
| 85 | + return Effect.gen(function* () { | |
| 86 | + const dispatched = yield* Queue.unbounded<unknown>(); | |
| 87 | + yield* Effect.gen(function* () { | |
| 88 | + const requests = yield* ProviderContinuationRequests; | |
| 89 | + yield* requests.offer(request()); | |
| 90 | + yield* Queue.take(dispatched); | |
| 91 | + yield* Effect.yieldNow; | |
| 92 | + assert.isTrue(Option.isNone(yield* Queue.poll(dispatched))); | |
| 93 | + }).pipe( | |
| 94 | + Effect.provide( | |
| 95 | + testLayer({ dispatched, getThreadProjection: () => Effect.succeed(projection) }), | |
| 96 | + ), | |
| 97 | + Effect.scoped, | |
| 98 | + ); | |
| 99 | + }); | |
| 100 | + }); | |
| 101 | + | |
| 102 | + it.effect("drops a request invalidated before dispatch", () => { | |
| 103 | + return Effect.gen(function* () { | |
| 104 | + const dispatched = yield* Queue.unbounded<unknown>(); | |
| 105 | + yield* Effect.gen(function* () { | |
| 106 | + const requests = yield* ProviderContinuationRequests; | |
| 107 | + const guard = yield* makeGuard(); | |
| 108 | + const dispatchIfCurrent = yield* guard.capture; | |
| 109 | + yield* guard.invalidate; | |
| 110 | + yield* requests.offer(request(dispatchIfCurrent)); | |
| 111 | + yield* Effect.yieldNow; | |
| 112 | + yield* Effect.yieldNow; | |
| 113 | + assert.isTrue(Option.isNone(yield* Queue.poll(dispatched))); | |
| 114 | + }).pipe( | |
| 115 | + Effect.provide( | |
| 116 | + testLayer({ dispatched, getThreadProjection: () => Effect.succeed(projection) }), | |
| 117 | + ), | |
| 118 | + Effect.scoped, | |
| 119 | + ); | |
| 120 | + }); | |
| 121 | + }); | |
| 122 | + | |
| 123 | + it.effect("drops a request invalidated while projection is blocked", () => { | |
| 124 | + return Effect.gen(function* () { | |
| 125 | + const dispatched = yield* Queue.unbounded<unknown>(); | |
| 126 | + const projectionEntered = yield* Deferred.make<void>(); | |
| 127 | + const releaseProjection = yield* Deferred.make<void>(); | |
| 128 | + const guardCompleted = yield* Deferred.make<void>(); | |
| 129 | + yield* Effect.gen(function* () { | |
| 130 | + const requests = yield* ProviderContinuationRequests; | |
| 131 | + const guard = yield* makeGuard(guardCompleted); | |
| 132 | + const dispatchIfCurrent = yield* guard.capture; | |
| 133 | + yield* requests.offer(request(dispatchIfCurrent)); | |
| 134 | + yield* Deferred.await(projectionEntered); | |
| 135 | + yield* guard.invalidate; | |
| 136 | + yield* Deferred.succeed(releaseProjection, undefined); | |
| 137 | + yield* Deferred.await(guardCompleted); | |
| 138 | + assert.isTrue(Option.isNone(yield* Queue.poll(dispatched))); | |
| 139 | + }).pipe( | |
| 140 | + Effect.provide( | |
| 141 | + testLayer({ | |
| 142 | + dispatched, | |
| 143 | + getThreadProjection: () => | |
| 144 | + Deferred.succeed(projectionEntered, undefined).pipe( | |
| 145 | + Effect.andThen(Deferred.await(releaseProjection)), | |
| 146 | + Effect.as(projection), | |
| 147 | + ), | |
| 148 | + }), | |
| 149 | + ), | |
| 150 | + Effect.scoped, | |
| 151 | + ); | |
| 152 | + }); | |
| 153 | + }); | |
| 154 | + | |
| 155 | + it.effect("does not revive an old request when a later generation is current", () => { | |
| 156 | + return Effect.gen(function* () { | |
| 157 | + const dispatched = yield* Queue.unbounded<unknown>(); | |
| 158 | + const firstProjectionEntered = yield* Deferred.make<void>(); | |
| 159 | + const releaseFirstProjection = yield* Deferred.make<void>(); | |
| 160 | + let projectionCalls = 0; | |
| 161 | + yield* Effect.gen(function* () { | |
| 162 | + const requests = yield* ProviderContinuationRequests; | |
| 163 | + const guard = yield* makeGuard(); | |
| 164 | + const first = yield* guard.capture; | |
| 165 | + yield* requests.offer(request(first, "A")); | |
| 166 | + yield* Deferred.await(firstProjectionEntered); | |
| 167 | + const second = yield* guard.capture; | |
| 168 | + yield* requests.offer(request(second, "B")); | |
| 169 | + yield* Deferred.succeed(releaseFirstProjection, undefined); | |
| 170 | + const command = yield* Queue.take(dispatched); | |
| 171 | + assert.equal((command as { readonly text?: unknown }).text, "B"); | |
| 172 | + yield* Effect.yieldNow; | |
| 173 | + assert.isTrue(Option.isNone(yield* Queue.poll(dispatched))); | |
| 174 | + }).pipe( | |
| 175 | + Effect.provide( | |
| 176 | + testLayer({ | |
| 177 | + dispatched, | |
| 178 | + getThreadProjection: () => { | |
| 179 | + projectionCalls += 1; | |
| 180 | + return projectionCalls === 1 | |
| 181 | + ? Deferred.succeed(firstProjectionEntered, undefined).pipe( | |
| 182 | + Effect.andThen(Deferred.await(releaseFirstProjection)), | |
| 183 | + Effect.as(projection), | |
| 184 | + ) | |
| 185 | + : Effect.succeed(projection); | |
| 186 | + }, | |
| 187 | + }), | |
| 188 | + ), | |
| 189 | + Effect.scoped, | |
| 190 | + ); | |
| 191 | + }); | |
| 192 | + }); | |
| 193 | +}); | |
| @@ -32,14 +32,20 @@ export const workerLive = Layer.effectDiscard( · 31 unmodified lines | ||
| 32 | 32 | threadId: request.threadId, |
| 33 | 33 | providerThreadId: request.providerThreadId, |
| 34 | 34 | }); |
| 35 | + // Still run the guard so adapters can clear sticky continuationRequested. | |
| 36 | + if (request.dispatchIfCurrent !== undefined) { | |
| 37 | + yield* request.dispatchIfCurrent(Effect.void); | |
| 38 | + } | |
| 35 | 39 | return; |
| 36 | 40 | } |
| 41 | + // The ordinal is display metadata only: allocate.message appends a | |
| 42 | + // random UUID, so a stale projection read here cannot collide ids. | |
| 37 | 43 | const messageId = yield* ids.allocate.message({ |
| 38 | 44 | threadId: request.threadId, |
| 39 | 45 | ordinal: projection.messages.length + 1, |
| 40 | 46 | }); |
| 41 | 47 | const commandId = CommandId.make(`provider-continuation:${messageId}`); |
| 42 | − yield* threads.dispatch({ | |
| 48 | + const dispatch = threads.dispatch({ | |
| 43 | 49 | type: "message.dispatch", |
| 44 | 50 | commandId, |
| 45 | 51 | threadId: request.threadId, |
| @@ -50,6 +56,11 @@ export const workerLive = Layer.effectDiscard( · 4 unmodified lines | ||
| 50 | 56 | createdBy: "agent", |
| 51 | 57 | creationSource: "provider", |
| 52 | 58 | }); |
| 59 | + if (request.dispatchIfCurrent === undefined) { | |
| 60 | + yield* dispatch; | |
| 61 | + return; | |
| 62 | + } | |
| 63 | + yield* request.dispatchIfCurrent(dispatch); | |
| 53 | 64 | }, |
| 54 | 65 | ); |
| 55 | 66 | |
| @@ -1233,6 +1233,99 @@ it.effect( · 1232 unmodified lines | ||
| 1233 | 1233 | }), |
| 1234 | 1234 | ); |
| 1235 | 1235 | |
| 1236 | +it.effect("ProviderSessionManagerV2 does not apply a stale idle pin to a replacement session", () => | |
| 1237 | + Effect.gen(function* () { | |
| 1238 | + const state = yield* Ref.make(emptyState); | |
| 1239 | + const firstCheck = yield* Ref.make(true); | |
| 1240 | + const checkEntered = yield* Deferred.make<void>(); | |
| 1241 | + const checkGate = yield* Deferred.make<void>(); | |
| 1242 | + const effect = Effect.gen(function* () { | |
| 1243 | + const eventSink = yield* EventSinkV2; | |
| 1244 | + const idAllocator = yield* IdAllocatorV2; | |
| 1245 | + const manager = yield* ProviderSessionManagerV2; | |
| 1246 | + const now = yield* DateTime.now; | |
| 1247 | + const threadId = yield* idAllocator.allocate.thread({ | |
| 1248 | + fixtureName: "provider-session-manager-stale-pin", | |
| 1249 | + projectId: yield* idAllocator.allocate.project({ | |
| 1250 | + fixtureName: "provider-session-manager-stale-pin", | |
| 1251 | + }), | |
| 1252 | + }); | |
| 1253 | + const providerSessionId = yield* idAllocator.allocate.providerSession({ | |
| 1254 | + providerInstanceId: modelSelection.instanceId, | |
| 1255 | + threadId, | |
| 1256 | + }); | |
| 1257 | + | |
| 1258 | + yield* eventSink.write({ | |
| 1259 | + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], | |
| 1260 | + }); | |
| 1261 | + yield* manager.open({ | |
| 1262 | + threadId, | |
| 1263 | + providerSessionId, | |
| 1264 | + modelSelection, | |
| 1265 | + runtimePolicy, | |
| 1266 | + }); | |
| 1267 | + | |
| 1268 | + // Park the first idle fiber inside an uninterruptible pending-work probe. | |
| 1269 | + yield* TestClock.adjust("1 second"); | |
| 1270 | + yield* Deferred.await(checkEntered); | |
| 1271 | + | |
| 1272 | + // close removes the map entry first, then waits to interrupt the idle | |
| 1273 | + // fiber (still uninterruptible). That window lets a replacement open | |
| 1274 | + // under the same providerSessionId before the stale probe finishes. | |
| 1275 | + const closeFiber = yield* manager.close(providerSessionId).pipe(Effect.forkDetach); | |
| 1276 | + for (let i = 0; i < 20; i += 1) { | |
| 1277 | + yield* Effect.yieldNow; | |
| 1278 | + } | |
| 1279 | + yield* manager.open({ | |
| 1280 | + threadId, | |
| 1281 | + providerSessionId, | |
| 1282 | + modelSelection, | |
| 1283 | + runtimePolicy, | |
| 1284 | + }); | |
| 1285 | + assert.equal((yield* Ref.get(state)).openCount, 2); | |
| 1286 | + | |
| 1287 | + // Stale probe reports pending work against the old runtime; the pin | |
| 1288 | + // stamp must no-op on the replacement (runtime / generation mismatch). | |
| 1289 | + yield* Deferred.succeed(checkGate, undefined); | |
| 1290 | + yield* Fiber.join(closeFiber); | |
| 1291 | + for (let i = 0; i < 10; i += 1) { | |
| 1292 | + yield* Effect.yieldNow; | |
| 1293 | + } | |
| 1294 | + | |
| 1295 | + assert.isTrue(Option.isSome(yield* manager.get(providerSessionId))); | |
| 1296 | + assert.equal((yield* Ref.get(state)).closeCount, 1); | |
| 1297 | + | |
| 1298 | + // Replacement has no pending background work. After one idle window it | |
| 1299 | + // must release. A stale pin stamp would have deferred release until | |
| 1300 | + // maxIdlePinMs. | |
| 1301 | + yield* TestClock.adjust("1 second"); | |
| 1302 | + yield* Effect.yieldNow; | |
| 1303 | + assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); | |
| 1304 | + assert.equal((yield* Ref.get(state)).closeCount, 2); | |
| 1305 | + }); | |
| 1306 | + | |
| 1307 | + yield* effect.pipe( | |
| 1308 | + Effect.provide( | |
| 1309 | + makeTestLayer({ | |
| 1310 | + state, | |
| 1311 | + idleTimeoutMs: 1000, | |
| 1312 | + maxIdlePinMs: 60_000, | |
| 1313 | + hasPendingBackgroundWork: Effect.uninterruptible( | |
| 1314 | + Effect.gen(function* () { | |
| 1315 | + if (yield* Ref.getAndSet(firstCheck, false)) { | |
| 1316 | + yield* Deferred.succeed(checkEntered, undefined); | |
| 1317 | + yield* Deferred.await(checkGate); | |
| 1318 | + return true; | |
| 1319 | + } | |
| 1320 | + return false; | |
| 1321 | + }), | |
| 1322 | + ), | |
| 1323 | + }), | |
| 1324 | + ), | |
| 1325 | + ); | |
| 1326 | + }), | |
| 1327 | +); | |
| 1328 | + | |
| 1236 | 1329 | it.effect( |
| 1237 | 1330 | "ProviderSessionManagerV2 keeps active sessions alive until the provider turn terminates", |
| 1238 | 1331 | () => |
| @@ -605,31 +605,47 @@ export const layerWithOptions = ( · 604 unmodified lines | ||
| 605 | 605 | ) { |
| 606 | 606 | return; |
| 607 | 607 | } |
| 608 | + // Capture runtime identity before yielding: a replacement session | |
| 609 | + // can reuse the same providerSessionId while this fiber is parked. | |
| 610 | + const probedRuntime = entry.runtime; | |
| 608 | 611 | const hasPendingWork = |
| 609 | − entry.runtime.hasPendingBackgroundWork === undefined | |
| 612 | + probedRuntime.hasPendingBackgroundWork === undefined | |
| 610 | 613 | ? false |
| 611 | − : yield* entry.runtime.hasPendingBackgroundWork.pipe( | |
| 614 | + : yield* probedRuntime.hasPendingBackgroundWork.pipe( | |
| 612 | 615 | Effect.catchCause(() => Effect.succeed(false)), |
| 613 | 616 | ); |
| 614 | 617 | if (hasPendingWork) { |
| 615 | 618 | const now = yield* Clock.currentTimeMillis; |
| 616 | 619 | const pinnedSinceMs = entry.pinnedSinceMs ?? now; |
| 617 | 620 | if (now - pinnedSinceMs < maxIdlePinMs) { |
| 618 | − yield* Effect.logInfo("orchestration-v2.driver-session.idle-release-deferred", { | |
| 619 | − providerSessionId: input.providerSessionId, | |
| 620 | − pinnedForMs: now - pinnedSinceMs, | |
| 621 | − }); | |
| 622 | − yield* Ref.update(sessions, (latest) => { | |
| 621 | + const shouldContinuePin = yield* Ref.modify(sessions, (latest) => { | |
| 623 | 622 | const latestEntry = latest.get(key); |
| 624 | − if (latestEntry === undefined || latestEntry.busyCount > 0) { | |
| 625 | − return latest; | |
| 623 | + if ( | |
| 624 | + latestEntry === undefined || | |
| 625 | + latestEntry.busyCount > 0 || | |
| 626 | + latestEntry.idleGeneration !== input.generation || | |
| 627 | + latestEntry.runtime !== probedRuntime | |
| 628 | + ) { | |
| 629 | + return [false, latest] as const; | |
| 626 | 630 | } |
| 627 | 631 | const updated = new Map(latest); |
| 628 | 632 | updated.set(key, { ...latestEntry, pinnedSinceMs }); |
| 629 | − return updated; | |
| 633 | + return [true, updated] as const; | |
| 630 | 634 | }); |
| 631 | − yield* scheduleIdleReleaseInternal(input.providerSessionId); | |
| 632 | − return; | |
| 635 | + if (!shouldContinuePin) { | |
| 636 | + // Generation or runtime advanced while we probed pending work; | |
| 637 | + // the current owner of the entry owns idle release. | |
| 638 | + return; | |
| 639 | + } | |
| 640 | + yield* Effect.logInfo("orchestration-v2.driver-session.idle-release-deferred", { | |
| 641 | + providerSessionId: input.providerSessionId, | |
| 642 | + pinnedForMs: now - pinnedSinceMs, | |
| 643 | + }); | |
| 644 | + // Re-check on this fiber after another idle window. Do not call | |
| 645 | + // scheduleIdleReleaseInternal: that cancels entry.idleFiber, which | |
| 646 | + // is this fiber, and can self-deadlock on Fiber.interrupt. | |
| 647 | + yield* Effect.sleep(Duration.millis(idleTimeoutMs)); | |
| 648 | + return yield* releaseIfStillIdle(input); | |
| 633 | 649 | } |
| 634 | 650 | yield* Effect.logWarning("orchestration-v2.driver-session.idle-release-pin-expired", { |
| 635 | 651 | providerSessionId: input.providerSessionId, |
| @@ -129,6 +129,28 @@ export const layer: Layer.Layer< · 128 unmodified lines | ||
| 129 | 129 | } |
| 130 | 130 | const session = yield* sessions.get(input.providerSessionId); |
| 131 | 131 | if (Option.isNone(session)) { |
| 132 | + // Interrupt/restart against a already-released session must not fail | |
| 133 | + // the durable effect (and retry 5x). The turn may still look running | |
| 134 | + // in projection until recovery/finalization; there is no live adapter | |
| 135 | + // to interrupt. | |
| 136 | + if (input.operation === "interrupt" || input.operation === "restart") { | |
| 137 | + yield* Effect.logWarning( | |
| 138 | + "Provider interrupt/restart found no live session; treating as already stopped", | |
| 139 | + { | |
| 140 | + threadId: input.threadId, | |
| 141 | + operation: input.operation, | |
| 142 | + providerSessionId: input.providerSessionId, | |
| 143 | + providerTurnId: input.providerTurnId, | |
| 144 | + providerTurnStatus: providerTurn.status, | |
| 145 | + }, | |
| 146 | + ); | |
| 147 | + return { | |
| 148 | + projection, | |
| 149 | + providerThread: interruptProviderThread, | |
| 150 | + providerTurn, | |
| 151 | + session: Option.none(), | |
| 152 | + }; | |
| 153 | + } | |
| 132 | 154 | return yield* new ProviderTurnControlError({ |
| 133 | 155 | threadId: input.threadId, |
| 134 | 156 | operation: input.operation, |
| @@ -147,6 +169,7 @@ export const layer: Layer.Layer< · 12 unmodified lines | ||
| 147 | 169 | yield* loaded.session.value.interruptTurn({ |
| 148 | 170 | providerThread: loaded.providerThread, |
| 149 | 171 | providerTurnId: loaded.providerTurn.id, |
| 172 | + requestRuntimeRestart: true, | |
| 150 | 173 | }); |
| 151 | 174 | }).pipe( |
| 152 | 175 | Effect.mapError((cause) => |
| @@ -163,13 +186,28 @@ export const layer: Layer.Layer< · 10 unmodified lines | ||
| 163 | 186 | interruptAndAwaitTerminal: (input) => |
| 164 | 187 | Effect.gen(function* () { |
| 165 | 188 | const loaded = yield* load({ ...input, operation: "restart" }); |
| 166 | − if (Option.isSome(loaded.session)) { | |
| 167 | − yield* loaded.session.value.interruptTurn({ | |
| 168 | − providerThread: loaded.providerThread, | |
| 169 | − providerTurnId: loaded.providerTurn.id, | |
| 170 | − }); | |
| 189 | + if (Option.isNone(loaded.session)) { | |
| 190 | + // No live adapter: nothing can emit a terminal provider-turn update | |
| 191 | + // from interrupt. Do not poll for projection terminalization or the | |
| 192 | + // restart effect stalls; let detach/start proceed. | |
| 193 | + if (loaded.providerTurn.status === "running") { | |
| 194 | + yield* Effect.logWarning( | |
| 195 | + "Provider restart interrupt skipped; no live session for a still-projected running turn", | |
| 196 | + { | |
| 197 | + threadId: input.threadId, | |
| 198 | + providerSessionId: input.providerSessionId, | |
| 199 | + providerTurnId: input.providerTurnId, | |
| 200 | + }, | |
| 201 | + ); | |
| 202 | + } | |
| 203 | + return; | |
| 171 | 204 | } |
| 172 | 205 | |
| 206 | + yield* loaded.session.value.interruptTurn({ | |
| 207 | + providerThread: loaded.providerThread, | |
| 208 | + providerTurnId: loaded.providerTurn.id, | |
| 209 | + }); | |
| 210 | + | |
| 173 | 211 | for (let remaining = 1_000; remaining > 0; remaining -= 1) { |
| 174 | 212 | const projection = yield* projections.getThreadProjection(input.threadId); |
| 175 | 213 | const providerTurn = projection.providerTurns.find( |
| @@ -439,6 +439,23 @@ export const layer: Layer.Layer< · 438 unmodified lines | ||
| 439 | 439 | }), |
| 440 | 440 | Effect.catchCause(() => Effect.succeed(false)), |
| 441 | 441 | ), |
| 442 | + hasUnpairedRunInterruptRequest: () => | |
| 443 | + projectionStore.getThreadProjection(projection.thread.id).pipe( | |
| 444 | + Effect.map((current) => { | |
| 445 | + const requestId = idAllocator.derive.runSignalTurnItem({ | |
| 446 | + runId: run.id, | |
| 447 | + signal: "interrupt-request", | |
| 448 | + }); | |
| 449 | + const resultId = idAllocator.derive.runSignalTurnItem({ | |
| 450 | + runId: run.id, | |
| 451 | + signal: "interrupt-result", | |
| 452 | + }); | |
| 453 | + const hasRequest = current.turnItems.some((item) => item.id === requestId); | |
| 454 | + const hasResult = current.turnItems.some((item) => item.id === resultId); | |
| 455 | + return hasRequest && !hasResult; | |
| 456 | + }), | |
| 457 | + Effect.catchCause(() => Effect.succeed(false)), | |
| 458 | + ), | |
| 442 | 459 | message: { |
| 443 | 460 | messageId: message.id, |
| 444 | 461 | text: |
| @@ -31,7 +31,7 @@ import * as Stream from "effect/Stream"; · 30 unmodified lines | ||
| 31 | 31 | import { ServerSettingsService } from "../serverSettings.ts"; |
| 32 | 32 | import { CheckpointServiceV2 } from "./CheckpointService.ts"; |
| 33 | 33 | import { EventSinkV2 } from "./EventSink.ts"; |
| 34 | −import { layer as idAllocatorLayer } from "./IdAllocator.ts"; | |
| 34 | +import { IdAllocatorV2, layer as idAllocatorLayer } from "./IdAllocator.ts"; | |
| 35 | 35 | import type { ProviderAdapterV2Event, ProviderAdapterV2SessionRuntime } from "./ProviderAdapter.ts"; |
| 36 | 36 | import { ProviderEventIngestorV2 } from "./ProviderEventIngestor.ts"; |
| 37 | 37 | import { |
| @@ -542,6 +542,194 @@ it.effect("does not pin ingestion on background items when the root turn is inte · 504 unmodified lines | ||
| 542 | 542 | }), |
| 543 | 543 | ); |
| 544 | 544 | |
| 545 | +it.effect("omits run_interrupt_result when a superseding attempt already owns the run", () => | |
| 546 | + Effect.gen(function* () { | |
| 547 | + const written = yield* captureInterruptTerminalTurnItems({ | |
| 548 | + key: "steer-supersede", | |
| 549 | + shouldFinalizeRun: () => Effect.succeed(false), | |
| 550 | + }); | |
| 551 | + assert.deepEqual( | |
| 552 | + written.map((item) => item.type), | |
| 553 | + [], | |
| 554 | + ); | |
| 555 | + }), | |
| 556 | +); | |
| 557 | + | |
| 558 | +it.effect("emits run_interrupt_result when superseded attempt still has a hard-stop request", () => | |
| 559 | + Effect.gen(function* () { | |
| 560 | + const written = yield* captureInterruptTerminalTurnItems({ | |
| 561 | + key: "stop-then-steer-supersede", | |
| 562 | + shouldFinalizeRun: () => Effect.succeed(false), | |
| 563 | + hasUnpairedRunInterruptRequest: () => Effect.succeed(true), | |
| 564 | + }); | |
| 565 | + assert.deepEqual( | |
| 566 | + written.map((item) => item.type), | |
| 567 | + ["run_interrupt_result"], | |
| 568 | + ); | |
| 569 | + const ids = backgroundScenarioIds("stop-then-steer-supersede"); | |
| 570 | + const expectedRequestId = yield* Effect.gen(function* () { | |
| 571 | + const idAllocator = yield* IdAllocatorV2; | |
| 572 | + return idAllocator.derive.runSignalTurnItem({ | |
| 573 | + runId: ids.runId, | |
| 574 | + signal: "interrupt-request", | |
| 575 | + }); | |
| 576 | + }).pipe(Effect.provide(idAllocatorLayer)); | |
| 577 | + assert.equal(written[0]?.parentItemId, expectedRequestId); | |
| 578 | + }), | |
| 579 | +); | |
| 580 | + | |
| 581 | +it.effect("omits run_interrupt_result when superseded attempt request is already paired", () => | |
| 582 | + Effect.gen(function* () { | |
| 583 | + const written = yield* captureInterruptTerminalTurnItems({ | |
| 584 | + key: "stop-then-steer-already-paired", | |
| 585 | + shouldFinalizeRun: () => Effect.succeed(false), | |
| 586 | + hasUnpairedRunInterruptRequest: () => Effect.succeed(false), | |
| 587 | + }); | |
| 588 | + assert.deepEqual( | |
| 589 | + written.map((item) => item.type), | |
| 590 | + [], | |
| 591 | + ); | |
| 592 | + }), | |
| 593 | +); | |
| 594 | + | |
| 595 | +it.effect("emits run_interrupt_result when hard-stop finalizes the active attempt", () => | |
| 596 | + Effect.gen(function* () { | |
| 597 | + const written = yield* captureInterruptTerminalTurnItems({ | |
| 598 | + key: "hard-stop", | |
| 599 | + shouldFinalizeRun: () => Effect.succeed(true), | |
| 600 | + }); | |
| 601 | + assert.deepEqual( | |
| 602 | + written.map((item) => item.type), | |
| 603 | + ["run_interrupt_result"], | |
| 604 | + ); | |
| 605 | + }), | |
| 606 | +); | |
| 607 | + | |
| 608 | +function captureInterruptTerminalTurnItems(input: { | |
| 609 | + readonly key: string; | |
| 610 | + readonly shouldFinalizeRun: () => Effect.Effect<boolean, never>; | |
| 611 | + readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect<boolean, never>; | |
| 612 | +}) { | |
| 613 | + return Effect.gen(function* () { | |
| 614 | + const ids = backgroundScenarioIds(input.key); | |
| 615 | + const providerInstanceId = ProviderInstanceId.make("codex"); | |
| 616 | + const writtenItems = yield* Ref.make< | |
| 617 | + ReadonlyArray<{ readonly type: string; readonly parentItemId: string | null }> | |
| 618 | + >([]); | |
| 619 | + const ingestionDone = yield* Deferred.make<void>(); | |
| 620 | + const captureTurnItem = (payload: { | |
| 621 | + readonly type: string; | |
| 622 | + readonly parentItemId: string | null; | |
| 623 | + }) => | |
| 624 | + Ref.update(writtenItems, (current) => [ | |
| 625 | + ...current, | |
| 626 | + { type: payload.type, parentItemId: payload.parentItemId }, | |
| 627 | + ]); | |
| 628 | + const testLayer = runExecutionServiceLayer.pipe( | |
| 629 | + Layer.provide( | |
| 630 | + Layer.mergeAll( | |
| 631 | + Layer.mock(CheckpointServiceV2)({ captureBaseline: () => Effect.void }), | |
| 632 | + Layer.mock(EventSinkV2)({ | |
| 633 | + write: (payload) => | |
| 634 | + Effect.gen(function* () { | |
| 635 | + for (const event of payload.events) { | |
| 636 | + if (event.type === "turn-item.updated") { | |
| 637 | + yield* captureTurnItem(event.payload); | |
| 638 | + } | |
| 639 | + } | |
| 640 | + return []; | |
| 641 | + }), | |
| 642 | + writeWithEffects: (payload) => | |
| 643 | + Effect.gen(function* () { | |
| 644 | + for (const event of payload.events) { | |
| 645 | + if (event.type === "turn-item.updated") { | |
| 646 | + yield* captureTurnItem(event.payload); | |
| 647 | + } | |
| 648 | + } | |
| 649 | + return []; | |
| 650 | + }), | |
| 651 | + writeIfRunCurrent: () => Effect.succeed({ committed: true, storedEvents: [] }), | |
| 652 | + }), | |
| 653 | + idAllocatorLayer, | |
| 654 | + Layer.mock(ProviderEventIngestorV2)({ | |
| 655 | + ingestNormalized: () => Effect.succeed([]), | |
| 656 | + }), | |
| 657 | + ServerSettingsService.layerTest(), | |
| 658 | + ), | |
| 659 | + ), | |
| 660 | + ); | |
| 661 | + | |
| 662 | + yield* Effect.gen(function* () { | |
| 663 | + const runExecution = yield* RunExecutionServiceV2; | |
| 664 | + yield* runExecution.startRootRun({ | |
| 665 | + commandId: CommandId.make(`command:${input.key}`), | |
| 666 | + appThread: { id: ids.threadId } as OrchestrationV2AppThread, | |
| 667 | + providerSessionId: ProviderSessionId.make(`session:${input.key}`), | |
| 668 | + session: { | |
| 669 | + events: Stream.empty, | |
| 670 | + subscribeEvents: Effect.succeed({ | |
| 671 | + events: Stream.fromIterable([rootTerminalEvent(ids, "interrupted")]), | |
| 672 | + close: Deferred.succeed(ingestionDone, undefined), | |
| 673 | + }), | |
| 674 | + startTurn: () => Effect.void, | |
| 675 | + } as unknown as ProviderAdapterV2SessionRuntime, | |
| 676 | + run: { | |
| 677 | + id: ids.runId, | |
| 678 | + threadId: ids.threadId, | |
| 679 | + ordinal: 1, | |
| 680 | + providerInstanceId, | |
| 681 | + } as OrchestrationV2Run, | |
| 682 | + rootNode: { | |
| 683 | + id: ids.rootNodeId, | |
| 684 | + providerTurnId: ids.rootProviderTurnId, | |
| 685 | + } as OrchestrationV2ExecutionNode, | |
| 686 | + checkpointScope: { | |
| 687 | + id: CheckpointScopeId.make(`checkpoint-scope:${input.key}`), | |
| 688 | + } as OrchestrationV2CheckpointScope, | |
| 689 | + providerThread: { | |
| 690 | + id: ids.providerThreadId, | |
| 691 | + driver, | |
| 692 | + } as OrchestrationV2ProviderThread, | |
| 693 | + attempt: { | |
| 694 | + id: ids.attemptId, | |
| 695 | + providerTurnId: ids.rootProviderTurnId, | |
| 696 | + } as OrchestrationV2RunAttempt, | |
| 697 | + attemptId: ids.attemptId, | |
| 698 | + providerTurnOrdinal: 1, | |
| 699 | + shouldFinalizeRun: input.shouldFinalizeRun, | |
| 700 | + ...(input.hasUnpairedRunInterruptRequest === undefined | |
| 701 | + ? {} | |
| 702 | + : { | |
| 703 | + hasUnpairedRunInterruptRequest: input.hasUnpairedRunInterruptRequest, | |
| 704 | + }), | |
| 705 | + message: { | |
| 706 | + messageId: MessageId.make(`message:${input.key}`), | |
| 707 | + text: "interrupt projection", | |
| 708 | + attachments: [], | |
| 709 | + createdBy: "user", | |
| 710 | + creationSource: "web", | |
| 711 | + }, | |
| 712 | + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, | |
| 713 | + runtimePolicy: { | |
| 714 | + runtimeMode: "full-access", | |
| 715 | + interactionMode: "default", | |
| 716 | + cwd: process.cwd(), | |
| 717 | + approvalPolicy: "never", | |
| 718 | + sandboxPolicy: { | |
| 719 | + type: "readOnly", | |
| 720 | + access: { type: "fullAccess" }, | |
| 721 | + networkAccess: false, | |
| 722 | + }, | |
| 723 | + }, | |
| 724 | + }); | |
| 725 | + }).pipe(Effect.provide(testLayer)); | |
| 726 | + | |
| 727 | + const closed = yield* Deferred.await(ingestionDone).pipe(Effect.timeoutOption("2 seconds")); | |
| 728 | + assert.isTrue(Option.isSome(closed), "event ingestion fiber did not finish"); | |
| 729 | + return yield* Ref.get(writtenItems); | |
| 730 | + }); | |
| 731 | +} | |
| 732 | + | |
| 545 | 733 | interface BackgroundScenarioIds { |
| 546 | 734 | readonly threadId: ThreadId; |
| 547 | 735 | readonly childThreadId: ThreadId; |
| @@ -260,6 +260,7 @@ export interface RunExecutionServiceV2StartRootRunInput { · 259 unmodified lines | ||
| 260 | 260 | readonly relatedProviderThreadIds?: ReadonlyArray<ProviderThreadId>; |
| 261 | 261 | readonly shouldStartProviderTurn?: () => Effect.Effect<boolean, never>; |
| 262 | 262 | readonly shouldFinalizeRun?: () => Effect.Effect<boolean, never>; |
| 263 | + readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect<boolean, never>; | |
| 263 | 264 | readonly message: ProviderAdapterV2TurnMessage; |
| 264 | 265 | readonly modelSelection: ModelSelection; |
| 265 | 266 | readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; |
| @@ -323,6 +324,7 @@ export const layer: Layer.Layer< · 57 unmodified lines | ||
| 323 | 324 | readonly providerThread: OrchestrationV2ProviderThread; |
| 324 | 325 | readonly attempt: OrchestrationV2RunAttempt; |
| 325 | 326 | readonly shouldFinalizeRun?: () => Effect.Effect<boolean, never>; |
| 327 | + readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect<boolean, never>; | |
| 326 | 328 | readonly terminal: ProviderTerminalEvent; |
| 327 | 329 | readonly failureItemPersisted: boolean; |
| 328 | 330 | }) => |
| @@ -336,30 +338,37 @@ export const layer: Layer.Layer< · 7 unmodified lines | ||
| 336 | 338 | const shouldFinalizeRun = |
| 337 | 339 | input.shouldFinalizeRun === undefined ? true : yield* input.shouldFinalizeRun(); |
| 338 | 340 | if (!shouldFinalizeRun) { |
| 339 | − // A newer attempt already owns the run and the command that created | |
| 340 | − // it terminalized this attempt as superseded. Preserve that domain | |
| 341 | − // status while retaining the provider's interruption artifact. | |
| 341 | + // Superseded attempt (steer / selection restart). Emit | |
| 342 | + // run_interrupt_result only when hard Stop left an unpaired request | |
| 343 | + // for this run; plain steers and already-paired stops emit nothing. | |
| 342 | 344 | if (input.terminal.status === "interrupted") { |
| 343 | − yield* eventSink.write({ | |
| 344 | − events: [ | |
| 345 | − { | |
| 346 | − id: yield* idAllocator.allocate.event({ threadId: input.run.threadId }), | |
| 347 | − type: "turn-item.updated" as const, | |
| 348 | − threadId: input.run.threadId, | |
| 349 | − runId: input.run.id, | |
| 350 | − nodeId: input.rootNode.id, | |
| 351 | − providerInstanceId: input.run.providerInstanceId, | |
| 352 | − occurredAt: completedAt, | |
| 353 | − payload: makeInterruptResultTurnItem({ | |
| 354 | − idAllocator, | |
| 355 | − run: input.run, | |
| 356 | − rootNode: input.rootNode, | |
| 357 | − providerThread: input.providerThread, | |
| 358 | − completedAt, | |
| 359 | − }), | |
| 360 | − }, | |
| 361 | − ], | |
| 362 | − }); | |
| 345 | + const hasUnpairedRequest = | |
| 346 | + input.hasUnpairedRunInterruptRequest === undefined | |
| 347 | + ? false | |
| 348 | + : yield* input.hasUnpairedRunInterruptRequest(); | |
| 349 | + if (hasUnpairedRequest) { | |
| 350 | + yield* eventSink.writeWithEffects({ | |
| 351 | + effects: [], | |
| 352 | + events: [ | |
| 353 | + { | |
| 354 | + id: yield* idAllocator.allocate.event({ threadId: input.run.threadId }), | |
| 355 | + type: "turn-item.updated" as const, | |
| 356 | + threadId: input.run.threadId, | |
| 357 | + runId: input.run.id, | |
| 358 | + nodeId: input.rootNode.id, | |
| 359 | + providerInstanceId: input.run.providerInstanceId, | |
| 360 | + occurredAt: completedAt, | |
| 361 | + payload: makeInterruptResultTurnItem({ | |
| 362 | + idAllocator, | |
| 363 | + run: input.run, | |
| 364 | + rootNode: input.rootNode, | |
| 365 | + providerThread: input.providerThread, | |
| 366 | + completedAt, | |
| 367 | + }), | |
| 368 | + }, | |
| 369 | + ], | |
| 370 | + }); | |
| 371 | + } | |
| 363 | 372 | } |
| 364 | 373 | return; |
| 365 | 374 | } |
| @@ -594,6 +603,11 @@ export const layer: Layer.Layer< · 228 unmodified lines | ||
| 594 | 603 | ...(input.shouldFinalizeRun === undefined |
| 595 | 604 | ? {} |
| 596 | 605 | : { shouldFinalizeRun: input.shouldFinalizeRun }), |
| 606 | + ...(input.hasUnpairedRunInterruptRequest === undefined | |
| 607 | + ? {} | |
| 608 | + : { | |
| 609 | + hasUnpairedRunInterruptRequest: input.hasUnpairedRunInterruptRequest, | |
| 610 | + }), | |
| 597 | 611 | terminal, |
| 598 | 612 | failureItemPersisted: terminal.status === "failed", |
| 599 | 613 | }).pipe( |
| @@ -776,6 +790,12 @@ export const layer: Layer.Layer< · 176 unmodified lines | ||
| 776 | 790 | ...(input.shouldFinalizeRun === undefined |
| 777 | 791 | ? {} |
| 778 | 792 | : { shouldFinalizeRun: input.shouldFinalizeRun }), |
| 793 | + ...(input.hasUnpairedRunInterruptRequest === undefined | |
| 794 | + ? {} | |
| 795 | + : { | |
| 796 | + hasUnpairedRunInterruptRequest: | |
| 797 | + input.hasUnpairedRunInterruptRequest, | |
| 798 | + }), | |
| 779 | 799 | terminal: makeFailedTerminalEvent( |
| 780 | 800 | makeProviderFailure({ |
| 781 | 801 | cause: Cause.squash(cause), |
| @@ -847,6 +867,12 @@ export const layer: Layer.Layer< · 65 unmodified lines | ||
| 847 | 867 | ...(input.shouldFinalizeRun === undefined |
| 848 | 868 | ? {} |
| 849 | 869 | : { shouldFinalizeRun: input.shouldFinalizeRun }), |
| 870 | + ...(input.hasUnpairedRunInterruptRequest === undefined | |
| 871 | + ? {} | |
| 872 | + : { | |
| 873 | + hasUnpairedRunInterruptRequest: | |
| 874 | + input.hasUnpairedRunInterruptRequest, | |
| 875 | + }), | |
| 850 | 876 | terminal: makeFailedTerminalEvent( |
| 851 | 877 | makeProviderFailure({ |
| 852 | 878 | cause: Cause.squash(cause), |
| @@ -490,7 +490,12 @@ it.live("restarts selection as a new attempt and retries after old-session clean · 489 unmodified lines | ||
| 490 | 490 | projection.providerTurns.map((turn) => turn.status), |
| 491 | 491 | ["interrupted", "completed"], |
| 492 | 492 | ); |
| 493 | − assert.isTrue(projection.turnItems.some((item) => item.type === "run_interrupt_result")); | |
| 493 | + assert.isFalse( | |
| 494 | + projection.turnItems.some( | |
| 495 | + (item) => item.type === "run_interrupt_request" || item.type === "run_interrupt_result", | |
| 496 | + ), | |
| 497 | + "selection restart supersede must not project hard-Stop interrupt items", | |
| 498 | + ); | |
| 494 | 499 | assert.equal(projection.runs[0]?.modelSelection.model, replacementSelection.model); |
| 495 | 500 | assert.isTrue(captured.failedReplacementOpen); |
| 496 | 501 | // The old pooled process remains available to its other threads; this |
| @@ -24,7 +24,7 @@ export function assertCursorMessageSteeringOutput( · 23 unmodified lines | ||
| 24 | 24 | |
| 25 | 25 | const projection = projectionFor(result, transcript.scenario); |
| 26 | 26 | assertSemanticProjectionIntegrity(projection); |
| 27 | − assertTurnItemTypes(projection, ["user_message", "run_interrupt_result", "assistant_message"]); | |
| 27 | + assertTurnItemTypes(projection, ["user_message", "assistant_message"]); | |
| 28 | 28 | assertRuntimeRequestCounts(projection, { total: 0 }); |
| 29 | 29 | assertUserMessagesInclude(projection, [ |
| 30 | 30 | MESSAGE_STEERING_INITIAL_PROMPT, |
| @@ -47,9 +47,10 @@ export function assertCursorMessageSteeringOutput( · 16 unmodified lines | ||
| 47 | 47 | ); |
| 48 | 48 | assert.equal(projection.runs[0]?.activeAttemptId, projection.attempts[1]?.id); |
| 49 | 49 | assert.equal(projection.runs[0]?.rootNodeId, projection.attempts[1]?.rootNodeId); |
| 50 | − assert.notInclude( | |
| 51 | − projection.visibleTurnItems.map((row) => row.item.type), | |
| 52 | − "run_interrupt_result", | |
| 53 | − "restart steering must keep its internal interruption out of visible chat history", | |
| 50 | + assert.isFalse( | |
| 51 | + projection.turnItems.some( | |
| 52 | + (item) => item.type === "run_interrupt_request" || item.type === "run_interrupt_result", | |
| 53 | + ), | |
| 54 | + "steer supersede must not project run_interrupt_* items (those are hard Stop only)", | |
| 54 | 55 | ); |
| 55 | 56 | } |
| @@ -23,7 +23,7 @@ export function assertGrokMessageSteeringOutput( · 22 unmodified lines | ||
| 23 | 23 | |
| 24 | 24 | const projection = projectionFor(result, transcript.scenario); |
| 25 | 25 | assertSemanticProjectionIntegrity(projection); |
| 26 | − assertTurnItemTypes(projection, ["user_message", "run_interrupt_result", "assistant_message"]); | |
| 26 | + assertTurnItemTypes(projection, ["user_message", "assistant_message"]); | |
| 27 | 27 | assertRuntimeRequestCounts(projection, { total: 0 }); |
| 28 | 28 | assertUserMessagesInclude(projection, [ |
| 29 | 29 | MESSAGE_STEERING_INITIAL_PROMPT, |
| @@ -46,9 +46,10 @@ export function assertGrokMessageSteeringOutput( · 16 unmodified lines | ||
| 46 | 46 | ); |
| 47 | 47 | assert.equal(projection.runs[0]?.activeAttemptId, projection.attempts[1]?.id); |
| 48 | 48 | assert.equal(projection.runs[0]?.rootNodeId, projection.attempts[1]?.rootNodeId); |
| 49 | − assert.notInclude( | |
| 50 | − projection.visibleTurnItems.map((row) => row.item.type), | |
| 51 | − "run_interrupt_result", | |
| 52 | − "restart steering must keep its internal interruption out of visible chat history", | |
| 49 | + assert.isFalse( | |
| 50 | + projection.turnItems.some( | |
| 51 | + (item) => item.type === "run_interrupt_request" || item.type === "run_interrupt_result", | |
| 52 | + ), | |
| 53 | + "steer supersede must not project run_interrupt_* items (those are hard Stop only)", | |
| 53 | 54 | ); |
| 54 | 55 | } |
| @@ -753,6 +753,40 @@ describe("AcpSessionRuntime", () => { · 752 unmodified lines | ||
| 753 | 753 | ), |
| 754 | 754 | ); |
| 755 | 755 | |
| 756 | + it.effect("completes ad-hoc loadSession after replay becomes idle while RPC stays pending", () => | |
| 757 | + Effect.gen(function* () { | |
| 758 | + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; | |
| 759 | + yield* runtime.start(); | |
| 760 | + const loaded = yield* runtime.loadSession("mock-session-1").pipe(Effect.timeout("2 seconds")); | |
| 761 | + | |
| 762 | + expect(loaded.sessionId).toBe("mock-session-1"); | |
| 763 | + expect(loaded.sessionSetupResult._meta).toMatchObject({ | |
| 764 | + t3SessionLoadReady: "replay_idle", | |
| 765 | + }); | |
| 766 | + }).pipe( | |
| 767 | + Effect.provide( | |
| 768 | + AcpSessionRuntime.layer({ | |
| 769 | + authMethodId: "test", | |
| 770 | + spawn: { | |
| 771 | + command: mockAgentCommand, | |
| 772 | + args: mockAgentArgs, | |
| 773 | + env: { | |
| 774 | + T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY: "1", | |
| 775 | + T3_ACP_LOAD_SESSION_DELAY_MS: "10000", | |
| 776 | + }, | |
| 777 | + }, | |
| 778 | + cwd: process.cwd(), | |
| 779 | + sessionLoadReplayIdleGap: "50 millis", | |
| 780 | + sessionLoadTimeout: "1 second", | |
| 781 | + clientInfo: { name: "t3-test", version: "0.0.0" }, | |
| 782 | + }), | |
| 783 | + ), | |
| 784 | + Effect.scoped, | |
| 785 | + Effect.provide(NodeServices.layer), | |
| 786 | + TestClock.withLive, | |
| 787 | + ), | |
| 788 | + ); | |
| 789 | + | |
| 756 | 790 | it.effect("rejects invalid config option values before sending session/set_config_option", () => { |
| 757 | 791 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-")); |
| 758 | 792 | const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | parsePermissionRequest, |
| 10 | 10 | parseSessionModeState, |
| 11 | 11 | parseSessionUpdateEvent, |
| 12 | + sessionUpdateCountsAsLoadReplayActivity, | |
| 12 | 13 | sessionUpdateIsReplay, |
| 13 | 14 | syntheticLoadSessionResponseFromInitialize, |
| 14 | 15 | toolCallProgressLength, |
| @@ -86,6 +87,98 @@ describe("AcpRuntimeModel", () => { · 71 unmodified lines | ||
| 86 | 87 | ).toBe(false); |
| 87 | 88 | }); |
| 88 | 89 | |
| 90 | + it("ignores Grok keepalive chunks when tracking session/load replay activity", () => { | |
| 91 | + expect( | |
| 92 | + sessionUpdateCountsAsLoadReplayActivity({ | |
| 93 | + sessionId: "session-1", | |
| 94 | + update: { | |
| 95 | + sessionUpdate: "agent_message_chunk", | |
| 96 | + content: { type: "text", text: "" }, | |
| 97 | + }, | |
| 98 | + } satisfies EffectAcpSchema.SessionNotification), | |
| 99 | + ).toBe(false); | |
| 100 | + expect( | |
| 101 | + sessionUpdateCountsAsLoadReplayActivity({ | |
| 102 | + _meta: { isReplay: true }, | |
| 103 | + sessionId: "session-1", | |
| 104 | + update: { | |
| 105 | + sessionUpdate: "tool_call", | |
| 106 | + toolCallId: "replay-tool", | |
| 107 | + title: "Replay", | |
| 108 | + kind: "search", | |
| 109 | + status: "completed", | |
| 110 | + }, | |
| 111 | + } satisfies EffectAcpSchema.SessionNotification), | |
| 112 | + ).toBe(true); | |
| 113 | + }); | |
| 114 | + | |
| 115 | + it("ignores load-replay activity from other sessions while a load gate is active", () => { | |
| 116 | + expect( | |
| 117 | + sessionUpdateCountsAsLoadReplayActivity( | |
| 118 | + { | |
| 119 | + sessionId: "session-other", | |
| 120 | + update: { | |
| 121 | + sessionUpdate: "agent_message_chunk", | |
| 122 | + content: { type: "text", text: "unrelated" }, | |
| 123 | + }, | |
| 124 | + } satisfies EffectAcpSchema.SessionNotification, | |
| 125 | + "session-loading", | |
| 126 | + ), | |
| 127 | + ).toBe(false); | |
| 128 | + expect( | |
| 129 | + sessionUpdateCountsAsLoadReplayActivity( | |
| 130 | + { | |
| 131 | + sessionId: "session-loading", | |
| 132 | + update: { | |
| 133 | + sessionUpdate: "agent_message_chunk", | |
| 134 | + content: { type: "text", text: "replay" }, | |
| 135 | + }, | |
| 136 | + } satisfies EffectAcpSchema.SessionNotification, | |
| 137 | + "session-loading", | |
| 138 | + ), | |
| 139 | + ).toBe(true); | |
| 140 | + }); | |
| 141 | + | |
| 142 | + it("counts mode/config/session/usage updates as load-replay activity", () => { | |
| 143 | + expect( | |
| 144 | + sessionUpdateCountsAsLoadReplayActivity({ | |
| 145 | + sessionId: "session-1", | |
| 146 | + update: { | |
| 147 | + sessionUpdate: "current_mode_update", | |
| 148 | + currentModeId: "code", | |
| 149 | + }, | |
| 150 | + } satisfies EffectAcpSchema.SessionNotification), | |
| 151 | + ).toBe(true); | |
| 152 | + expect( | |
| 153 | + sessionUpdateCountsAsLoadReplayActivity({ | |
| 154 | + sessionId: "session-1", | |
| 155 | + update: { | |
| 156 | + sessionUpdate: "config_option_update", | |
| 157 | + configOptions: [], | |
| 158 | + }, | |
| 159 | + } satisfies EffectAcpSchema.SessionNotification), | |
| 160 | + ).toBe(true); | |
| 161 | + expect( | |
| 162 | + sessionUpdateCountsAsLoadReplayActivity({ | |
| 163 | + sessionId: "session-1", | |
| 164 | + update: { | |
| 165 | + sessionUpdate: "session_info_update", | |
| 166 | + title: "Restored", | |
| 167 | + }, | |
| 168 | + } satisfies EffectAcpSchema.SessionNotification), | |
| 169 | + ).toBe(true); | |
| 170 | + expect( | |
| 171 | + sessionUpdateCountsAsLoadReplayActivity({ | |
| 172 | + sessionId: "session-1", | |
| 173 | + update: { | |
| 174 | + sessionUpdate: "usage_update", | |
| 175 | + used: 1, | |
| 176 | + size: 10, | |
| 177 | + }, | |
| 178 | + } satisfies EffectAcpSchema.SessionNotification), | |
| 179 | + ).toBe(true); | |
| 180 | + }); | |
| 181 | + | |
| 89 | 182 | it("builds a synthetic load response from initialize model state", () => { |
| 90 | 183 | const response = syntheticLoadSessionResponseFromInitialize({ |
| 91 | 184 | protocolVersion: 1, |
| @@ -700,8 +700,38 @@ export function sessionUpdateIsReplay(params: EffectAcpSchema.SessionNotificatio · 699 unmodified lines | ||
| 700 | 700 | return isRecord(meta) && meta.isReplay === true; |
| 701 | 701 | } |
| 702 | 702 | |
| 703 | +/** Replay chunks and substantive updates during session/load; not Grok keepalives. */ | |
| 704 | +export function sessionUpdateCountsAsLoadReplayActivity( | |
| 705 | + params: EffectAcpSchema.SessionNotification, | |
| 706 | + gatedSessionId?: string, | |
| 707 | +): boolean { | |
| 708 | + if (gatedSessionId !== undefined && params.sessionId !== gatedSessionId) { | |
| 709 | + return false; | |
| 710 | + } | |
| 711 | + if (sessionUpdateIsReplay(params)) return true; | |
| 712 | + const update = params.update; | |
| 713 | + switch (update.sessionUpdate) { | |
| 714 | + case "agent_message_chunk": | |
| 715 | + case "agent_thought_chunk": | |
| 716 | + return update.content.type === "text" && update.content.text.length > 0; | |
| 717 | + case "tool_call": | |
| 718 | + case "tool_call_update": | |
| 719 | + case "plan": | |
| 720 | + case "available_commands_update": | |
| 721 | + case "current_mode_update": | |
| 722 | + case "config_option_update": | |
| 723 | + case "session_info_update": | |
| 724 | + case "usage_update": | |
| 725 | + return true; | |
| 726 | + default: | |
| 727 | + return false; | |
| 728 | + } | |
| 729 | +} | |
| 730 | + | |
| 703 | 731 | export interface SessionLoadGate { |
| 704 | 732 | readonly active: boolean; |
| 733 | + /** Only notifications for this session refresh load-replay idle activity. */ | |
| 734 | + readonly sessionId: string; | |
| 705 | 735 | readonly lastActivityAtMillis: number | undefined; |
| 706 | 736 | readonly idleGap: Duration.Duration; |
| 707 | 737 | readonly initializeResult: EffectAcpSchema.InitializeResponse; |
| @@ -0,0 +1,1030 @@ | ||
| 1 | +// @effect-diagnostics nodeBuiltinImport:off | |
| 2 | +import * as NodeChildProcess from "node:child_process"; | |
| 3 | +import * as NodeFS from "node:fs"; | |
| 4 | +import * as NodePath from "node:path"; | |
| 5 | +import * as NodeURL from "node:url"; | |
| 6 | + | |
| 7 | +import { describe, expect, it } from "@effect/vitest"; | |
| 8 | +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 9 | +import * as Clock from "effect/Clock"; | |
| 10 | +import * as Effect from "effect/Effect"; | |
| 11 | +import * as Exit from "effect/Exit"; | |
| 12 | +import * as Scope from "effect/Scope"; | |
| 13 | + | |
| 14 | +import { | |
| 15 | + capturePosixOwnershipLedger, | |
| 16 | + isSafeCgroupPath, | |
| 17 | + makeLinuxCgroupController, | |
| 18 | + makePosixProcessTreeController, | |
| 19 | + observePosixOwnershipLedger, | |
| 20 | + observePosixOwnershipLedgerContinuously, | |
| 21 | + parseCgroup2Mount, | |
| 22 | + parseCgroup2Mounts, | |
| 23 | + parseUnifiedCgroupPath, | |
| 24 | + posixProcessIsZombie, | |
| 25 | + resolveLinuxCgroupTargetCommand, | |
| 26 | + signalLinuxCgroupRootTerm, | |
| 27 | + sweepStaleLinuxCgroupSiblings, | |
| 28 | + terminateLinuxCgroupLease, | |
| 29 | + terminatePosixOwnedProcessTree, | |
| 30 | + wrapCommandForLinuxCgroup, | |
| 31 | + type AcpOwnedPosixProcess, | |
| 32 | + type AcpLinuxCgroupLease, | |
| 33 | + type AcpPosixOwnershipRoot, | |
| 34 | + type AcpPosixProcessIdentity, | |
| 35 | + type AcpPosixProcessTreeController, | |
| 36 | +} from "./AcpSessionRuntime.ts"; | |
| 37 | + | |
| 38 | +const threadSpawnHelperSource = NodeURL.fileURLToPath( | |
| 39 | + new URL("../../../scripts/acp-thread-spawn-helper.c", import.meta.url), | |
| 40 | +); | |
| 41 | + | |
| 42 | +const identity = ( | |
| 43 | + pid: number, | |
| 44 | + ppid: number, | |
| 45 | + pgid: number, | |
| 46 | + sid: number, | |
| 47 | + startTime = String(pid), | |
| 48 | + state = "S", | |
| 49 | +): AcpPosixProcessIdentity => ({ | |
| 50 | + executable: `/proc/${pid}/exe`, | |
| 51 | + pgid, | |
| 52 | + pid, | |
| 53 | + ppid, | |
| 54 | + sid, | |
| 55 | + startTime, | |
| 56 | + state, | |
| 57 | +}); | |
| 58 | + | |
| 59 | +function makeController(input: { | |
| 60 | + readonly processes: ReadonlyArray<AcpPosixProcessIdentity>; | |
| 61 | + readonly onProcess?: ( | |
| 62 | + processes: Map<number, AcpPosixProcessIdentity>, | |
| 63 | + pid: number, | |
| 64 | + signal: NodeJS.Signals, | |
| 65 | + ) => void; | |
| 66 | +}) { | |
| 67 | + const processes = new Map(input.processes.map((process) => [process.pid, process])); | |
| 68 | + const signals: Array<string> = []; | |
| 69 | + const controller: AcpPosixProcessTreeController = { | |
| 70 | + childPidsOf: (pid) => | |
| 71 | + [...processes.values()] | |
| 72 | + .filter((process) => process.ppid === pid) | |
| 73 | + .map((process) => process.pid), | |
| 74 | + childrenOf: (pid) => [...processes.values()].filter((process) => process.ppid === pid), | |
| 75 | + identity: (pid) => processes.get(pid), | |
| 76 | + snapshot: () => [...processes.values()], | |
| 77 | + signalProcess: (pid, signal) => { | |
| 78 | + signals.push(`process:${pid}:${signal}`); | |
| 79 | + if (input.onProcess) return input.onProcess(processes, pid, signal); | |
| 80 | + processes.delete(pid); | |
| 81 | + }, | |
| 82 | + }; | |
| 83 | + return { controller, processes, signals }; | |
| 84 | +} | |
| 85 | + | |
| 86 | +const server = () => identity(process.pid, 1, process.pid, process.pid, "server"); | |
| 87 | + | |
| 88 | +function processExists(pid: number): boolean { | |
| 89 | + try { | |
| 90 | + process.kill(pid, 0); | |
| 91 | + return true; | |
| 92 | + } catch { | |
| 93 | + return false; | |
| 94 | + } | |
| 95 | +} | |
| 96 | + | |
| 97 | +describe("terminatePosixOwnedProcessTree", () => { | |
| 98 | + it("parses unified cgroup paths and escaped cgroup2 mountinfo", () => { | |
| 99 | + expect(parseUnifiedCgroupPath("0::/user.slice/app.scope\n")).toBe("/user.slice/app.scope"); | |
| 100 | + expect(parseUnifiedCgroupPath("0::/one\n0::/two\n")).toBeUndefined(); | |
| 101 | + expect(isSafeCgroupPath("/user.slice/app.scope")).toBe(true); | |
| 102 | + expect(isSafeCgroupPath("/user.slice/../escape")).toBe(false); | |
| 103 | + expect(isSafeCgroupPath("/user.slice/app.scope (deleted)")).toBe(false); | |
| 104 | + expect( | |
| 105 | + parseCgroup2Mount( | |
| 106 | + "42 31 0:37 /user.slice /sys/fs/cgroup/user\\040mount rw - cgroup2 cgroup2 rw\n", | |
| 107 | + ), | |
| 108 | + ).toEqual({ mountPoint: "/sys/fs/cgroup/user mount", root: "/user.slice" }); | |
| 109 | + expect(parseCgroup2Mount("42 31 0:37 / /sys/fs/cgroup rw - tmpfs tmpfs rw\n")).toBeUndefined(); | |
| 110 | + expect( | |
| 111 | + parseCgroup2Mounts( | |
| 112 | + "41 31 0:36 /other /sys/fs/cgroup/other rw - cgroup2 cgroup2 rw\n" + | |
| 113 | + "42 31 0:37 /user.slice /sys/fs/cgroup/user rw - cgroup2 cgroup2 rw\n", | |
| 114 | + ), | |
| 115 | + ).toHaveLength(2); | |
| 116 | + }); | |
| 117 | + | |
| 118 | + it.live("sweeps empty t3-acp sibling leases owned by dead pids only", () => | |
| 119 | + Effect.gen(function* () { | |
| 120 | + const scratchRoot = NodePath.join(process.cwd(), "tmp"); | |
| 121 | + NodeFS.mkdirSync(scratchRoot, { recursive: true }); | |
| 122 | + const parent = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-stale-siblings-")); | |
| 123 | + // Temp dirs are not cgroupfs: rmdir fails when control files exist, so the | |
| 124 | + // test injects populated/remove while still exercising the real selection | |
| 125 | + // and path-safety logic against a real parent directory tree. | |
| 126 | + const populatedByPath = new Map<string, "0" | "1">(); | |
| 127 | + const writeSibling = (name: string, populated: "0" | "1" | null) => { | |
| 128 | + const path = NodePath.join(parent, name); | |
| 129 | + NodeFS.mkdirSync(path); | |
| 130 | + if (populated !== null) populatedByPath.set(path, populated); | |
| 131 | + return path; | |
| 132 | + }; | |
| 133 | + const deadPid = 2_147_483_646; | |
| 134 | + // Synthetic live foreign pid (not this process, still reported alive by the probe). | |
| 135 | + const liveForeignPid = 2_147_483_645; | |
| 136 | + const staleEmpty = writeSibling(`t3-acp-${deadPid}-aaaa`, "0"); | |
| 137 | + const currentPidSibling = writeSibling(`t3-acp-${process.pid}-bbbb`, "0"); | |
| 138 | + const liveForeignSibling = writeSibling(`t3-acp-${liveForeignPid}-cccc`, "0"); | |
| 139 | + const stalePopulated = writeSibling(`t3-acp-${deadPid}-dddd`, "1"); | |
| 140 | + const unrelated = writeSibling("other-lease", "0"); | |
| 141 | + const isProcessAlive = (pid: number) => pid === process.pid || pid === liveForeignPid; | |
| 142 | + try { | |
| 143 | + sweepStaleLinuxCgroupSiblings(parent, { | |
| 144 | + isProcessAlive, | |
| 145 | + readPopulated: (path) => populatedByPath.get(path), | |
| 146 | + remove: (path) => { | |
| 147 | + populatedByPath.delete(path); | |
| 148 | + NodeFS.rmdirSync(path); | |
| 149 | + }, | |
| 150 | + }); | |
| 151 | + expect(NodeFS.existsSync(staleEmpty)).toBe(false); | |
| 152 | + expect(NodeFS.existsSync(currentPidSibling)).toBe(true); | |
| 153 | + expect(NodeFS.existsSync(liveForeignSibling)).toBe(true); | |
| 154 | + expect(NodeFS.existsSync(stalePopulated)).toBe(true); | |
| 155 | + expect(NodeFS.existsSync(unrelated)).toBe(true); | |
| 156 | + // Missing populated state must not remove a sibling. | |
| 157 | + writeSibling(`t3-acp-${deadPid}-eeee`, null); | |
| 158 | + sweepStaleLinuxCgroupSiblings(parent, { | |
| 159 | + isProcessAlive, | |
| 160 | + readPopulated: (path) => populatedByPath.get(path), | |
| 161 | + remove: (path) => { | |
| 162 | + populatedByPath.delete(path); | |
| 163 | + NodeFS.rmdirSync(path); | |
| 164 | + }, | |
| 165 | + }); | |
| 166 | + expect(NodeFS.existsSync(NodePath.join(parent, `t3-acp-${deadPid}-eeee`))).toBe(true); | |
| 167 | + expect(NodeFS.existsSync(currentPidSibling)).toBe(true); | |
| 168 | + } finally { | |
| 169 | + NodeFS.rmSync(parent, { recursive: true, force: true }); | |
| 170 | + } | |
| 171 | + }), | |
| 172 | + ); | |
| 173 | + | |
| 174 | + it.live("preserves exact argv and strips wrapper-only environment before exec", () => | |
| 175 | + Effect.gen(function* () { | |
| 176 | + if ((yield* HostProcessPlatform) !== "linux") return; | |
| 177 | + const lease = makeLinuxCgroupController().create(); | |
| 178 | + if (lease === undefined) return; | |
| 179 | + const scratchRoot = NodePath.join(process.cwd(), "tmp"); | |
| 180 | + NodeFS.mkdirSync(scratchRoot, { recursive: true }); | |
| 181 | + const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-wrapper-")); | |
| 182 | + const linkedNode = NodePath.join(scratch, "T3 Code AppImage 'quoted'"); | |
| 183 | + const bareGrok = NodePath.join(scratch, "grok"); | |
| 184 | + const relativeBin = NodePath.join(scratch, "relative-bin"); | |
| 185 | + const directoryBin = NodePath.join(scratch, "directory-bin"); | |
| 186 | + const validBin = NodePath.join(scratch, "valid-bin"); | |
| 187 | + const outputPath = NodePath.join(scratch, "argv.json"); | |
| 188 | + NodeFS.mkdirSync(relativeBin); | |
| 189 | + NodeFS.mkdirSync(NodePath.join(directoryBin, "grok"), { recursive: true }); | |
| 190 | + NodeFS.mkdirSync(validBin); | |
| 191 | + NodeFS.symlinkSync(process.execPath, linkedNode); | |
| 192 | + NodeFS.symlinkSync(process.execPath, bareGrok); | |
| 193 | + NodeFS.symlinkSync(process.execPath, NodePath.join(relativeBin, "grok")); | |
| 194 | + NodeFS.symlinkSync(process.execPath, NodePath.join(validBin, "grok")); | |
| 195 | + expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: scratch })).toBe(bareGrok); | |
| 196 | + expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "" })).toBe(bareGrok); | |
| 197 | + // Undefined PATH uses Node's default search path (/usr/bin:/bin), not cwd alone | |
| 198 | + // (bareGrok lives only in cwd / PATH=scratch). | |
| 199 | + expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: undefined })).toBeUndefined(); | |
| 200 | + expect(resolveLinuxCgroupTargetCommand("node", scratch, { PATH: undefined })).toBeDefined(); | |
| 201 | + expect(resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: "relative-bin" })).toBe( | |
| 202 | + NodePath.join(relativeBin, "grok"), | |
| 203 | + ); | |
| 204 | + expect( | |
| 205 | + resolveLinuxCgroupTargetCommand("grok", scratch, { | |
| 206 | + PATH: `directory-bin${NodePath.delimiter}valid-bin`, | |
| 207 | + }), | |
| 208 | + ).toBe(NodePath.join(validBin, "grok")); | |
| 209 | + const target = [ | |
| 210 | + 'const fs = require("node:fs");', | |
| 211 | + "fs.writeFileSync(process.argv[1], JSON.stringify({", | |
| 212 | + " args: process.argv.slice(2),", | |
| 213 | + " electron: process.env.ELECTRON_RUN_AS_NODE,", | |
| 214 | + " wrapper: process.env.T3_ACP_CGROUP_WRAPPER,", | |
| 215 | + "}));", | |
| 216 | + ].join("\n"); | |
| 217 | + const wrapped = wrapCommandForLinuxCgroup(lease, linkedNode, [ | |
| 218 | + "-e", | |
| 219 | + target, | |
| 220 | + outputPath, | |
| 221 | + "space value", | |
| 222 | + "single'quote", | |
| 223 | + 'double"quote', | |
| 224 | + ]); | |
| 225 | + try { | |
| 226 | + const result = NodeChildProcess.spawnSync(wrapped.command, wrapped.args, { | |
| 227 | + encoding: "utf8", | |
| 228 | + env: { | |
| 229 | + ...process.env, | |
| 230 | + ELECTRON_RUN_AS_NODE: "1", | |
| 231 | + T3_ACP_CGROUP_WRAPPER: "1", | |
| 232 | + }, | |
| 233 | + }); | |
| 234 | + expect(result.status, result.stderr).toBe(0); | |
| 235 | + expect(NodeFS.readFileSync(outputPath, "utf8")).toBe( | |
| 236 | + '{"args":["space value","single\'quote","double\\"quote"]}', | |
| 237 | + ); | |
| 238 | + } finally { | |
| 239 | + yield* terminateLinuxCgroupLease(lease).pipe(Effect.ignore); | |
| 240 | + NodeFS.rmSync(scratch, { recursive: true, force: true }); | |
| 241 | + } | |
| 242 | + }), | |
| 243 | + ); | |
| 244 | + | |
| 245 | + it.live("kills a post-TERM detached double fork without touching an unrelated sentinel", () => | |
| 246 | + Effect.gen(function* () { | |
| 247 | + if ((yield* HostProcessPlatform) !== "linux") return; | |
| 248 | + const controller = makeLinuxCgroupController(); | |
| 249 | + const lease = controller.create(); | |
| 250 | + if (lease === undefined) return; | |
| 251 | + expect(parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8"))).not.toBe( | |
| 252 | + lease.relativePath, | |
| 253 | + ); | |
| 254 | + const scratchRoot = NodePath.join(process.cwd(), "tmp"); | |
| 255 | + NodeFS.mkdirSync(scratchRoot, { recursive: true }); | |
| 256 | + const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-cgroup-kill-")); | |
| 257 | + const readyPath = NodePath.join(scratch, "ready"); | |
| 258 | + const childPath = NodePath.join(scratch, "child"); | |
| 259 | + const childProgram = "setInterval(() => {}, 1000);"; | |
| 260 | + const launcherProgram = [ | |
| 261 | + 'const fs = require("node:fs");', | |
| 262 | + 'const cp = require("node:child_process");', | |
| 263 | + "const child = cp.spawn(process.execPath, ['-e', process.argv[2]], { detached: true, stdio: 'ignore' });", | |
| 264 | + "fs.writeFileSync(process.argv[1], String(child.pid));", | |
| 265 | + "child.unref();", | |
| 266 | + ].join("\n"); | |
| 267 | + const providerProgram = [ | |
| 268 | + 'const fs = require("node:fs");', | |
| 269 | + 'const cp = require("node:child_process");', | |
| 270 | + "fs.writeFileSync(process.argv[1], String(process.pid));", | |
| 271 | + "process.on('SIGTERM', () => {", | |
| 272 | + " const launcher = cp.spawn(process.execPath, ['-e', process.argv[3], process.argv[2], process.argv[4]], { detached: true, stdio: 'ignore' });", | |
| 273 | + " launcher.unref();", | |
| 274 | + " process.exit(0);", | |
| 275 | + "});", | |
| 276 | + "setInterval(() => {}, 1000);", | |
| 277 | + ].join("\n"); | |
| 278 | + const wrapped = wrapCommandForLinuxCgroup(lease, process.execPath, [ | |
| 279 | + "-e", | |
| 280 | + providerProgram, | |
| 281 | + readyPath, | |
| 282 | + childPath, | |
| 283 | + launcherProgram, | |
| 284 | + childProgram, | |
| 285 | + ]); | |
| 286 | + const provider = NodeChildProcess.spawn(wrapped.command, wrapped.args, { | |
| 287 | + detached: true, | |
| 288 | + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", T3_ACP_CGROUP_WRAPPER: "1" }, | |
| 289 | + stdio: "ignore", | |
| 290 | + }); | |
| 291 | + provider.unref(); | |
| 292 | + const sentinel = NodeChildProcess.spawn(process.execPath, ["-e", childProgram], { | |
| 293 | + detached: true, | |
| 294 | + stdio: "ignore", | |
| 295 | + }); | |
| 296 | + sentinel.unref(); | |
| 297 | + let detachedChildPid: number | undefined; | |
| 298 | + try { | |
| 299 | + yield* Effect.gen(function* () { | |
| 300 | + while (!NodeFS.existsSync(readyPath)) yield* Effect.sleep("10 millis"); | |
| 301 | + }).pipe(Effect.timeout("2 seconds")); | |
| 302 | + process.kill(provider.pid!, "SIGTERM"); | |
| 303 | + detachedChildPid = yield* Effect.gen(function* () { | |
| 304 | + while (true) { | |
| 305 | + try { | |
| 306 | + const pid = Number(NodeFS.readFileSync(childPath, "utf8")); | |
| 307 | + if (Number.isSafeInteger(pid) && pid > 1) return pid; | |
| 308 | + } catch { | |
| 309 | + // The TERM-time launcher has not published its child yet. | |
| 310 | + } | |
| 311 | + yield* Effect.sleep("10 millis"); | |
| 312 | + } | |
| 313 | + }).pipe(Effect.timeout("2 seconds")); | |
| 314 | + expect(processExists(detachedChildPid)).toBe(true); | |
| 315 | + yield* terminateLinuxCgroupLease(lease); | |
| 316 | + expect(processExists(sentinel.pid!)).toBe(true); | |
| 317 | + yield* Effect.gen(function* () { | |
| 318 | + while (processExists(detachedChildPid!)) yield* Effect.sleep("10 millis"); | |
| 319 | + }).pipe(Effect.timeout("2 seconds")); | |
| 320 | + expect(NodeFS.existsSync(lease.path)).toBe(false); | |
| 321 | + | |
| 322 | + const replacement = controller.create(); | |
| 323 | + expect(replacement?.path).not.toBe(lease.path); | |
| 324 | + if (replacement !== undefined) { | |
| 325 | + NodeFS.mkdirSync(NodePath.join(replacement.path, "nested")); | |
| 326 | + yield* terminateLinuxCgroupLease(replacement); | |
| 327 | + expect(NodeFS.existsSync(replacement.path)).toBe(false); | |
| 328 | + } | |
| 329 | + } finally { | |
| 330 | + yield* terminateLinuxCgroupLease(lease).pipe(Effect.ignore); | |
| 331 | + try { | |
| 332 | + process.kill(-provider.pid!, "SIGKILL"); | |
| 333 | + } catch { | |
| 334 | + // The contained provider already exited. | |
| 335 | + } | |
| 336 | + try { | |
| 337 | + process.kill(-sentinel.pid!, "SIGKILL"); | |
| 338 | + } catch { | |
| 339 | + // The unrelated sentinel already exited. | |
| 340 | + } | |
| 341 | + if (detachedChildPid !== undefined && processExists(detachedChildPid)) { | |
| 342 | + try { | |
| 343 | + process.kill(detachedChildPid, "SIGKILL"); | |
| 344 | + } catch { | |
| 345 | + // The detached child already exited. | |
| 346 | + } | |
| 347 | + } | |
| 348 | + NodeFS.rmSync(scratch, { recursive: true, force: true }); | |
| 349 | + } | |
| 350 | + }), | |
| 351 | + ); | |
| 352 | + | |
| 353 | + it.live("re-kills repopulated cgroups and verifies root removal after retryable errors", () => | |
| 354 | + Effect.gen(function* () { | |
| 355 | + let exists = true; | |
| 356 | + let killCalls = 0; | |
| 357 | + let populated = false; | |
| 358 | + let removeCalls = 0; | |
| 359 | + const lease: AcpLinuxCgroupLease = { | |
| 360 | + contains: () => false, | |
| 361 | + exists: () => exists, | |
| 362 | + path: "/test/t3-acp-repopulation", | |
| 363 | + relativePath: "/test/t3-acp-repopulation", | |
| 364 | + kill: () => { | |
| 365 | + killCalls += 1; | |
| 366 | + populated = false; | |
| 367 | + }, | |
| 368 | + populated: () => populated, | |
| 369 | + remove: () => { | |
| 370 | + removeCalls += 1; | |
| 371 | + if (removeCalls === 1) { | |
| 372 | + populated = true; | |
| 373 | + const error = new Error("repopulated") as NodeJS.ErrnoException; | |
| 374 | + error.code = "EBUSY"; | |
| 375 | + throw error; | |
| 376 | + } | |
| 377 | + if (removeCalls === 2) { | |
| 378 | + const error = new Error("nested path vanished") as NodeJS.ErrnoException; | |
| 379 | + error.code = "ENOENT"; | |
| 380 | + throw error; | |
| 381 | + } | |
| 382 | + exists = false; | |
| 383 | + }, | |
| 384 | + }; | |
| 385 | + | |
| 386 | + yield* terminateLinuxCgroupLease(lease); | |
| 387 | + expect(killCalls).toBeGreaterThanOrEqual(3); | |
| 388 | + expect(removeCalls).toBe(3); | |
| 389 | + expect(exists).toBe(false); | |
| 390 | + yield* terminateLinuxCgroupLease(lease); | |
| 391 | + expect(removeCalls).toBe(3); | |
| 392 | + }), | |
| 393 | + ); | |
| 394 | + | |
| 395 | + it("sends TERM only to the exact captured root while it remains in the child cgroup", () => { | |
| 396 | + const fixture = makeController({ | |
| 397 | + processes: [server(), identity(100, process.pid, 100, 100, "owned")], | |
| 398 | + }); | |
| 399 | + const ownedRoot: AcpOwnedPosixProcess = { | |
| 400 | + ...identity(100, process.pid, 100, 100, "owned"), | |
| 401 | + parentExecutable: server().executable, | |
| 402 | + parentStartTime: server().startTime, | |
| 403 | + }; | |
| 404 | + const root: AcpPosixOwnershipRoot = { captureAttempted: true, value: ownedRoot }; | |
| 405 | + const lease: AcpLinuxCgroupLease = { | |
| 406 | + contains: () => true, | |
| 407 | + exists: () => true, | |
| 408 | + path: "/test/t3-acp-root", | |
| 409 | + relativePath: "/test/t3-acp-root", | |
| 410 | + kill: () => undefined, | |
| 411 | + populated: () => true, | |
| 412 | + remove: () => undefined, | |
| 413 | + }; | |
| 414 | + signalLinuxCgroupRootTerm({ controller: fixture.controller, lease, root }); | |
| 415 | + expect(fixture.signals).toEqual(["process:100:SIGTERM"]); | |
| 416 | + | |
| 417 | + const reused = makeController({ | |
| 418 | + processes: [server(), identity(100, process.pid, 100, 100, "reused")], | |
| 419 | + }); | |
| 420 | + signalLinuxCgroupRootTerm({ controller: reused.controller, lease, root }); | |
| 421 | + expect(reused.signals).toEqual([]); | |
| 422 | + | |
| 423 | + const migrated = makeController({ | |
| 424 | + processes: [server(), identity(100, process.pid, 100, 100, "owned")], | |
| 425 | + }); | |
| 426 | + signalLinuxCgroupRootTerm({ | |
| 427 | + controller: migrated.controller, | |
| 428 | + lease: { ...lease, contains: () => false }, | |
| 429 | + root, | |
| 430 | + }); | |
| 431 | + expect(migrated.signals).toEqual([]); | |
| 432 | + }); | |
| 433 | + | |
| 434 | + it.live("finds a child forked by a non-leader pthread", () => | |
| 435 | + Effect.gen(function* () { | |
| 436 | + if ((yield* HostProcessPlatform) !== "linux") return; | |
| 437 | + const scratchRoot = NodePath.join(process.cwd(), "tmp"); | |
| 438 | + NodeFS.mkdirSync(scratchRoot, { recursive: true }); | |
| 439 | + const scratch = NodeFS.mkdtempSync(NodePath.join(scratchRoot, "acp-pthread-spawn-")); | |
| 440 | + const binary = NodePath.join(scratch, "helper"); | |
| 441 | + const pidPath = NodePath.join(scratch, "pids"); | |
| 442 | + expect(NodeFS.statSync(threadSpawnHelperSource).isFile()).toBe(true); | |
| 443 | + const compile = NodeChildProcess.spawnSync( | |
| 444 | + "cc", | |
| 445 | + ["-pthread", threadSpawnHelperSource, "-o", binary], | |
| 446 | + { | |
| 447 | + encoding: "utf8", | |
| 448 | + }, | |
| 449 | + ); | |
| 450 | + if ((compile.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") { | |
| 451 | + NodeFS.rmSync(scratch, { recursive: true, force: true }); | |
| 452 | + return; | |
| 453 | + } | |
| 454 | + expect(compile.status, compile.stderr).toBe(0); | |
| 455 | + const helper = NodeChildProcess.spawn(binary, [pidPath], { detached: true, stdio: "ignore" }); | |
| 456 | + helper.unref(); | |
| 457 | + try { | |
| 458 | + const published = yield* Effect.gen(function* () { | |
| 459 | + while (true) { | |
| 460 | + try { | |
| 461 | + const ids = NodeFS.readFileSync(pidPath, "utf8").trim().split(/\s+/).map(Number); | |
| 462 | + if (ids.length === 2 && ids.every(Number.isSafeInteger)) return ids; | |
| 463 | + } catch { | |
| 464 | + // The worker has not published its TID and child yet. | |
| 465 | + } | |
| 466 | + yield* Effect.sleep("10 millis"); | |
| 467 | + } | |
| 468 | + }).pipe(Effect.timeout("2 seconds")); | |
| 469 | + const [workerTid, childPid] = published; | |
| 470 | + expect(workerTid).not.toBe(helper.pid); | |
| 471 | + expect(makePosixProcessTreeController("linux").childrenOf(helper.pid!)).toContainEqual( | |
| 472 | + expect.objectContaining({ pid: childPid, ppid: helper.pid }), | |
| 473 | + ); | |
| 474 | + } finally { | |
| 475 | + try { | |
| 476 | + process.kill(-helper.pid!, "SIGKILL"); | |
| 477 | + } catch { | |
| 478 | + // The isolated fixture already exited. | |
| 479 | + } | |
| 480 | + NodeFS.rmSync(scratch, { recursive: true, force: true }); | |
| 481 | + } | |
| 482 | + }), | |
| 483 | + ); | |
| 484 | + | |
| 485 | + it.live("keeps ownership across a fork-before-exec executable transition", () => | |
| 486 | + Effect.gen(function* () { | |
| 487 | + const beforeExec = { ...identity(110, 100, 110, 110), executable: "/provider/fork" }; | |
| 488 | + const fixture = makeController({ | |
| 489 | + processes: [server(), identity(100, process.pid, 100, 100), beforeExec], | |
| 490 | + }); | |
| 491 | + const frontier = new Map<number, AcpOwnedPosixProcess>(); | |
| 492 | + const childQueues = new Map<number, Array<number>>(); | |
| 493 | + const ledger = new Map<string, AcpOwnedPosixProcess>(); | |
| 494 | + const root: AcpPosixOwnershipRoot = { value: undefined }; | |
| 495 | + observePosixOwnershipLedger({ | |
| 496 | + childQueues, | |
| 497 | + controller: fixture.controller, | |
| 498 | + frontier, | |
| 499 | + ledger, | |
| 500 | + root, | |
| 501 | + rootPid: 100, | |
| 502 | + }); | |
| 503 | + fixture.processes.set(110, { ...beforeExec, executable: "/usr/bin/bash" }); | |
| 504 | + observePosixOwnershipLedger({ | |
| 505 | + childQueues, | |
| 506 | + controller: fixture.controller, | |
| 507 | + frontier, | |
| 508 | + ledger, | |
| 509 | + root, | |
| 510 | + rootPid: 100, | |
| 511 | + }); | |
| 512 | + | |
| 513 | + expect([...ledger.values()].find((process) => process.pid === 110)?.executable).toBe( | |
| 514 | + "/usr/bin/bash", | |
| 515 | + ); | |
| 516 | + yield* terminatePosixOwnedProcessTree({ | |
| 517 | + controller: fixture.controller, | |
| 518 | + grace: 0, | |
| 519 | + ledger, | |
| 520 | + rootPid: 100, | |
| 521 | + }); | |
| 522 | + expect(fixture.signals).toContain("process:110:SIGTERM"); | |
| 523 | + expect(fixture.processes.has(110)).toBe(false); | |
| 524 | + }), | |
| 525 | + ); | |
| 526 | + | |
| 527 | + it("never adopts a replacement PID after initial root capture failed", () => { | |
| 528 | + const fixture = makeController({ processes: [server()] }); | |
| 529 | + const frontier = new Map<number, AcpOwnedPosixProcess>(); | |
| 530 | + const childQueues = new Map<number, Array<number>>(); | |
| 531 | + const ledger = new Map<string, AcpOwnedPosixProcess>(); | |
| 532 | + const root: AcpPosixOwnershipRoot = { value: undefined }; | |
| 533 | + const capture = () => { | |
| 534 | + try { | |
| 535 | + observePosixOwnershipLedger({ | |
| 536 | + childQueues, | |
| 537 | + controller: fixture.controller, | |
| 538 | + frontier, | |
| 539 | + ledger, | |
| 540 | + root, | |
| 541 | + rootPid: 100, | |
| 542 | + }); | |
| 543 | + return undefined; | |
| 544 | + } catch (error) { | |
| 545 | + return error; | |
| 546 | + } | |
| 547 | + }; | |
| 548 | + expect(capture()).toMatchObject({ | |
| 549 | + detail: "ACP process 100 exited before its ownership ledger was captured", | |
| 550 | + }); | |
| 551 | + fixture.processes.set(100, identity(100, process.pid, 100, 100, "reused")); | |
| 552 | + expect(capture()).toMatchObject({ | |
| 553 | + detail: "ACP process 100 exited before its ownership ledger was captured", | |
| 554 | + }); | |
| 555 | + expect(ledger.size).toBe(0); | |
| 556 | + }); | |
| 557 | + | |
| 558 | + it.live("rotates more than 64 live parents without scanning retained tombstones", () => | |
| 559 | + Effect.gen(function* () { | |
| 560 | + const parents = Array.from({ length: 130 }, (_, index) => | |
| 561 | + identity(1_000 + index, 100, 1_000 + index, 1_000 + index), | |
| 562 | + ); | |
| 563 | + let childListReads = 0; | |
| 564 | + let identityCalls = 0; | |
| 565 | + let snapshotCalls = 0; | |
| 566 | + const fixture = makeController({ | |
| 567 | + processes: [server(), identity(100, process.pid, 100, 100)], | |
| 568 | + }); | |
| 569 | + const controller: AcpPosixProcessTreeController = { | |
| 570 | + ...fixture.controller, | |
| 571 | + childPidsOf: (pid) => { | |
| 572 | + childListReads += 1; | |
| 573 | + return fixture.controller.childPidsOf(pid); | |
| 574 | + }, | |
| 575 | + identity: (pid) => { | |
| 576 | + identityCalls += 1; | |
| 577 | + return fixture.controller.identity(pid); | |
| 578 | + }, | |
| 579 | + snapshot: () => { | |
| 580 | + snapshotCalls += 1; | |
| 581 | + return fixture.controller.snapshot(); | |
| 582 | + }, | |
| 583 | + }; | |
| 584 | + const frontier = new Map<number, AcpOwnedPosixProcess>(); | |
| 585 | + const childQueues = new Map<number, Array<number>>(); | |
| 586 | + const ledger = new Map<string, AcpOwnedPosixProcess>(); | |
| 587 | + const root: AcpPosixOwnershipRoot = { value: undefined }; | |
| 588 | + for (let index = 0; index < 5_000; index += 1) { | |
| 589 | + const tombstone = identity(100_000 + index, 1, 100_000 + index, 100_000 + index); | |
| 590 | + ledger.set(`${tombstone.pid}:${tombstone.startTime}`, { | |
| 591 | + ...tombstone, | |
| 592 | + parentExecutable: undefined, | |
| 593 | + parentStartTime: "", | |
| 594 | + }); | |
| 595 | + } | |
| 596 | + observePosixOwnershipLedger({ | |
| 597 | + childQueues, | |
| 598 | + controller, | |
| 599 | + frontier, | |
| 600 | + ledger, | |
| 601 | + root, | |
| 602 | + rootPid: 100, | |
| 603 | + }); | |
| 604 | + for (const parent of parents) { | |
| 605 | + fixture.processes.set(parent.pid, parent); | |
| 606 | + fixture.processes.set( | |
| 607 | + parent.pid + 10_000, | |
| 608 | + identity(parent.pid + 10_000, parent.pid, parent.pgid, parent.sid), | |
| 609 | + ); | |
| 610 | + } | |
| 611 | + let passes = 0; | |
| 612 | + while ( | |
| 613 | + !parents.every((parent) => | |
| 614 | + [...ledger.values()].some((owned) => owned.pid === parent.pid + 10_000), | |
| 615 | + ) && | |
| 616 | + passes < 40 | |
| 617 | + ) { | |
| 618 | + childListReads = 0; | |
| 619 | + identityCalls = 0; | |
| 620 | + observePosixOwnershipLedger({ | |
| 621 | + childQueues, | |
| 622 | + controller, | |
| 623 | + frontier, | |
| 624 | + ledger, | |
| 625 | + maxProcesses: 64, | |
| 626 | + root, | |
| 627 | + rootPid: 100, | |
| 628 | + }); | |
| 629 | + expect(identityCalls + childListReads).toBeLessThanOrEqual(64); | |
| 630 | + passes += 1; | |
| 631 | + } | |
| 632 | + | |
| 633 | + const missing = parents.filter( | |
| 634 | + (parent) => ![...ledger.values()].some((owned) => owned.pid === parent.pid + 10_000), | |
| 635 | + ); | |
| 636 | + expect(missing, `missing after ${passes} passes`).toEqual([]); | |
| 637 | + expect(passes).toBeGreaterThan(2); | |
| 638 | + yield* terminatePosixOwnedProcessTree({ | |
| 639 | + controller, | |
| 640 | + grace: 0, | |
| 641 | + ledger, | |
| 642 | + rootPid: 100, | |
| 643 | + }); | |
| 644 | + expect(snapshotCalls).toBe(10); | |
| 645 | + expect(fixture.processes.size).toBe(1); | |
| 646 | + }), | |
| 647 | + ); | |
| 648 | + | |
| 649 | + it.live("polls only known descendants and stops with its scope", () => | |
| 650 | + Effect.gen(function* () { | |
| 651 | + let childrenCalls = 0; | |
| 652 | + let identityCalls = 0; | |
| 653 | + let snapshotCalls = 0; | |
| 654 | + const fixture = makeController({ | |
| 655 | + processes: [server(), identity(100, process.pid, 100, 100), identity(110, 100, 110, 110)], | |
| 656 | + }); | |
| 657 | + const controller: AcpPosixProcessTreeController = { | |
| 658 | + ...fixture.controller, | |
| 659 | + childPidsOf: (pid) => { | |
| 660 | + childrenCalls += 1; | |
| 661 | + return fixture.controller.childPidsOf(pid); | |
| 662 | + }, | |
| 663 | + identity: (pid) => { | |
| 664 | + identityCalls += 1; | |
| 665 | + return fixture.controller.identity(pid); | |
| 666 | + }, | |
| 667 | + snapshot: () => { | |
| 668 | + snapshotCalls += 1; | |
| 669 | + return fixture.controller.snapshot(); | |
| 670 | + }, | |
| 671 | + }; | |
| 672 | + const scope = yield* Scope.make(); | |
| 673 | + const frontier = new Map<number, AcpOwnedPosixProcess>(); | |
| 674 | + const childQueues = new Map<number, Array<number>>(); | |
| 675 | + let ledgerValuesCalls = 0; | |
| 676 | + const ledger = new (class extends Map<string, AcpOwnedPosixProcess> { | |
| 677 | + override values(): MapIterator<AcpOwnedPosixProcess> { | |
| 678 | + ledgerValuesCalls += 1; | |
| 679 | + return super.values(); | |
| 680 | + } | |
| 681 | + })(); | |
| 682 | + const root: AcpPosixOwnershipRoot = { value: undefined }; | |
| 683 | + for (let index = 0; index < 5_000; index += 1) { | |
| 684 | + const tombstone = identity(10_000 + index, 1, 10_000 + index, 10_000 + index); | |
| 685 | + ledger.set(`${tombstone.pid}:${tombstone.startTime}`, { | |
| 686 | + ...tombstone, | |
| 687 | + parentExecutable: undefined, | |
| 688 | + parentStartTime: "", | |
| 689 | + }); | |
| 690 | + } | |
| 691 | + yield* observePosixOwnershipLedgerContinuously({ | |
| 692 | + childQueues, | |
| 693 | + controller, | |
| 694 | + frontier, | |
| 695 | + ledger, | |
| 696 | + root, | |
| 697 | + rootPid: 100, | |
| 698 | + }).pipe(Effect.forkIn(scope)); | |
| 699 | + const timerStartedAt = yield* Clock.currentTimeMillis; | |
| 700 | + yield* Effect.sleep(0); | |
| 701 | + const timerDelay = (yield* Clock.currentTimeMillis) - timerStartedAt; | |
| 702 | + yield* Effect.sleep("80 millis"); | |
| 703 | + | |
| 704 | + expect(timerDelay).toBeLessThan(250); | |
| 705 | + expect(ledgerValuesCalls).toBe(0); | |
| 706 | + expect(snapshotCalls).toBe(0); | |
| 707 | + expect(childrenCalls).toBeGreaterThan(0); | |
| 708 | + expect(childrenCalls).toBeLessThanOrEqual(12); | |
| 709 | + const callsAtClose = identityCalls + childrenCalls; | |
| 710 | + yield* Scope.close(scope, Exit.void); | |
| 711 | + yield* Effect.sleep("60 millis"); | |
| 712 | + expect(identityCalls + childrenCalls).toBe(callsAtClose); | |
| 713 | + }), | |
| 714 | + ); | |
| 715 | + | |
| 716 | + it.live("terminates nested owned groups bottom-up and catches a TERM fork race", () => | |
| 717 | + Effect.gen(function* () { | |
| 718 | + let forked = false; | |
| 719 | + const fixture = makeController({ | |
| 720 | + processes: [ | |
| 721 | + server(), | |
| 722 | + identity(100, process.pid, 100, 100), | |
| 723 | + identity(110, 100, 110, 110), | |
| 724 | + identity(115, 110, 115, 110), | |
| 725 | + identity(120, 110, 120, 120), | |
| 726 | + identity(121, 120, 120, 120), | |
| 727 | + ], | |
| 728 | + onProcess: (processes, pid, signal) => { | |
| 729 | + if (signal === "SIGTERM" && pid === 121 && !forked) { | |
| 730 | + forked = true; | |
| 731 | + processes.set(122, identity(122, 110, 122, 122)); | |
| 732 | + } | |
| 733 | + processes.delete(pid); | |
| 734 | + }, | |
| 735 | + }); | |
| 736 | + | |
| 737 | + yield* terminatePosixOwnedProcessTree({ | |
| 738 | + controller: fixture.controller, | |
| 739 | + grace: 0, | |
| 740 | + rootPid: 100, | |
| 741 | + }); | |
| 742 | + | |
| 743 | + expect(fixture.processes.has(100)).toBe(false); | |
| 744 | + expect(fixture.processes.has(122)).toBe(false); | |
| 745 | + expect(fixture.signals.indexOf("process:121:SIGTERM")).toBeLessThan( | |
| 746 | + fixture.signals.indexOf("process:120:SIGTERM"), | |
| 747 | + ); | |
| 748 | + expect(fixture.signals.indexOf("process:115:SIGTERM")).toBeLessThan( | |
| 749 | + fixture.signals.indexOf("process:110:SIGTERM"), | |
| 750 | + ); | |
| 751 | + }), | |
| 752 | + ); | |
| 753 | + | |
| 754 | + it.live("falls back to exact PIDs for unknown group members and a missing leader", () => | |
| 755 | + Effect.gen(function* () { | |
| 756 | + const unrelated = identity(999, 1, 110, 110, "unrelated"); | |
| 757 | + const fixture = makeController({ | |
| 758 | + processes: [ | |
| 759 | + server(), | |
| 760 | + identity(100, process.pid, 100, 100), | |
| 761 | + identity(110, 100, 110, 110), | |
| 762 | + identity(111, 110, 110, 110), | |
| 763 | + unrelated, | |
| 764 | + ], | |
| 765 | + onProcess: (processes, pid) => { | |
| 766 | + processes.delete(pid); | |
| 767 | + if (pid === 111) processes.delete(110); | |
| 768 | + }, | |
| 769 | + }); | |
| 770 | + | |
| 771 | + yield* terminatePosixOwnedProcessTree({ | |
| 772 | + controller: fixture.controller, | |
| 773 | + grace: 0, | |
| 774 | + rootPid: 100, | |
| 775 | + }); | |
| 776 | + | |
| 777 | + expect(fixture.signals).toContain("process:111:SIGTERM"); | |
| 778 | + expect(fixture.processes.get(999)).toEqual(unrelated); | |
| 779 | + }), | |
| 780 | + ); | |
| 781 | + | |
| 782 | + it.live("re-admits a still-owned child after PID reuse and never signals the T3 session", () => | |
| 783 | + Effect.gen(function* () { | |
| 784 | + const reused = identity(110, 100, 110, 110, "reused"); | |
| 785 | + const fixture = makeController({ | |
| 786 | + processes: [ | |
| 787 | + server(), | |
| 788 | + identity(100, process.pid, 100, 100), | |
| 789 | + identity(110, 100, 110, 110, "owned"), | |
| 790 | + identity(120, 100, 120, process.pid), | |
| 791 | + ], | |
| 792 | + onProcess: (processes, pid) => { | |
| 793 | + if (pid === 110) processes.set(110, reused); | |
| 794 | + else processes.delete(pid); | |
| 795 | + }, | |
| 796 | + }); | |
| 797 | + | |
| 798 | + const result = yield* Effect.exit( | |
| 799 | + terminatePosixOwnedProcessTree({ | |
| 800 | + controller: fixture.controller, | |
| 801 | + grace: 0, | |
| 802 | + rootPid: 100, | |
| 803 | + }), | |
| 804 | + ); | |
| 805 | + | |
| 806 | + // PID 110 morphs to a new identity under the owned root and never exits, so | |
| 807 | + // teardown fails closed after re-admitting and re-signalling the live child. | |
| 808 | + expect(Exit.isFailure(result)).toBe(true); | |
| 809 | + expect(fixture.processes.get(110)).toEqual(reused); | |
| 810 | + expect( | |
| 811 | + fixture.signals.filter((entry) => entry.startsWith("process:110:")).length, | |
| 812 | + ).toBeGreaterThan(0); | |
| 813 | + expect(fixture.signals.some((entry) => entry.includes(":120:"))).toBe(false); | |
| 814 | + }), | |
| 815 | + ); | |
| 816 | + | |
| 817 | + it.live("does not treat zombie residual entries as teardown survivors", () => | |
| 818 | + Effect.gen(function* () { | |
| 819 | + expect(posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "Z" })).toBe(true); | |
| 820 | + expect(posixProcessIsZombie({ ...identity(1, 0, 1, 1), state: "S" })).toBe(false); | |
| 821 | + const withoutState: AcpPosixProcessIdentity = { | |
| 822 | + executable: "/proc/1/exe", | |
| 823 | + pgid: 1, | |
| 824 | + pid: 1, | |
| 825 | + ppid: 0, | |
| 826 | + sid: 1, | |
| 827 | + startTime: "1", | |
| 828 | + }; | |
| 829 | + expect(posixProcessIsZombie(withoutState)).toBe(false); | |
| 830 | + | |
| 831 | + const root = identity(100, process.pid, 100, 100); | |
| 832 | + const zombieChild = identity(110, 100, 110, 110, "110", "Z"); | |
| 833 | + const fixture = makeController({ | |
| 834 | + processes: [server(), root, zombieChild], | |
| 835 | + onProcess: (processes, pid) => { | |
| 836 | + // Kill leaves a zombie with the same pid/starttime until reaped. | |
| 837 | + const current = processes.get(pid); | |
| 838 | + if (current === undefined) return; | |
| 839 | + processes.set(pid, { ...current, state: "Z" }); | |
| 840 | + }, | |
| 841 | + }); | |
| 842 | + yield* terminatePosixOwnedProcessTree({ | |
| 843 | + controller: fixture.controller, | |
| 844 | + grace: 0, | |
| 845 | + rootPid: 100, | |
| 846 | + }); | |
| 847 | + // Zombies remain visible but must not fail residual teardown. | |
| 848 | + expect(fixture.processes.get(100)?.state).toBe("Z"); | |
| 849 | + expect(fixture.processes.get(110)?.state).toBe("Z"); | |
| 850 | + }), | |
| 851 | + ); | |
| 852 | + | |
| 853 | + it("admits a live child after a free PID was previously reserved by a dead entry", () => { | |
| 854 | + const staleChild: AcpOwnedPosixProcess = { | |
| 855 | + ...identity(200, 100, 200, 200, "stale-child"), | |
| 856 | + parentExecutable: `/proc/100/exe`, | |
| 857 | + parentStartTime: "100", | |
| 858 | + }; | |
| 859 | + const liveChild = identity(200, 100, 200, 200, "live-child"); | |
| 860 | + const root = identity(100, process.pid, 100, 100); | |
| 861 | + const ownedRoot: AcpOwnedPosixProcess = { | |
| 862 | + ...root, | |
| 863 | + parentExecutable: `/proc/${process.pid}/exe`, | |
| 864 | + parentStartTime: "server", | |
| 865 | + }; | |
| 866 | + const ledger = new Map<string, AcpOwnedPosixProcess>([ | |
| 867 | + [`100:${root.startTime}`, ownedRoot], | |
| 868 | + [`200:${staleChild.startTime}`, staleChild], | |
| 869 | + ]); | |
| 870 | + const controller = makeController({ | |
| 871 | + processes: [server(), root], | |
| 872 | + }).controller; | |
| 873 | + | |
| 874 | + // First capture: PID 200 is free, so the dead reservation is dropped. | |
| 875 | + capturePosixOwnershipLedger({ | |
| 876 | + controller, | |
| 877 | + ledger, | |
| 878 | + rootPid: 100, | |
| 879 | + table: [root], | |
| 880 | + }); | |
| 881 | + expect([...ledger.values()].some((entry) => entry.pid === 200)).toBe(false); | |
| 882 | + | |
| 883 | + // Second capture: a new process reuses PID 200 under the owned parent. | |
| 884 | + capturePosixOwnershipLedger({ | |
| 885 | + controller: makeController({ processes: [server(), root, liveChild] }).controller, | |
| 886 | + ledger, | |
| 887 | + rootPid: 100, | |
| 888 | + table: [root, liveChild], | |
| 889 | + }); | |
| 890 | + | |
| 891 | + const admitted = [...ledger.values()].find((entry) => entry.pid === 200); | |
| 892 | + expect(admitted?.startTime).toBe("live-child"); | |
| 893 | + }); | |
| 894 | + | |
| 895 | + it("admits a live child when a stale ledger entry still occupies the reused PID", () => { | |
| 896 | + const staleChild: AcpOwnedPosixProcess = { | |
| 897 | + ...identity(200, 100, 200, 200, "stale-child"), | |
| 898 | + parentExecutable: `/proc/100/exe`, | |
| 899 | + parentStartTime: "100", | |
| 900 | + }; | |
| 901 | + const liveChild = identity(200, 100, 200, 200, "live-child"); | |
| 902 | + const root = identity(100, process.pid, 100, 100); | |
| 903 | + const ownedRoot: AcpOwnedPosixProcess = { | |
| 904 | + ...root, | |
| 905 | + parentExecutable: `/proc/${process.pid}/exe`, | |
| 906 | + parentStartTime: "server", | |
| 907 | + }; | |
| 908 | + const ledger = new Map<string, AcpOwnedPosixProcess>([ | |
| 909 | + [`100:${root.startTime}`, ownedRoot], | |
| 910 | + [`200:${staleChild.startTime}`, staleChild], | |
| 911 | + ]); | |
| 912 | + | |
| 913 | + capturePosixOwnershipLedger({ | |
| 914 | + controller: makeController({ processes: [server(), root, liveChild] }).controller, | |
| 915 | + ledger, | |
| 916 | + rootPid: 100, | |
| 917 | + table: [root, liveChild], | |
| 918 | + }); | |
| 919 | + | |
| 920 | + expect([...ledger.values()].some((entry) => entry.startTime === "stale-child")).toBe(false); | |
| 921 | + const admitted = [...ledger.values()].find((entry) => entry.pid === 200); | |
| 922 | + expect(admitted?.startTime).toBe("live-child"); | |
| 923 | + }); | |
| 924 | + | |
| 925 | + it.live("signals children forked under a still-live parent during the same pass", () => | |
| 926 | + Effect.gen(function* () { | |
| 927 | + let forked = false; | |
| 928 | + const fixture = makeController({ | |
| 929 | + processes: [ | |
| 930 | + server(), | |
| 931 | + identity(100, process.pid, 100, 100), | |
| 932 | + identity(110, 100, 110, 110), | |
| 933 | + identity(111, 110, 111, 111), | |
| 934 | + ], | |
| 935 | + onProcess: (processes, pid) => { | |
| 936 | + // Bottom-up: signal 111 first while 110 is still live, then fork 112 | |
| 937 | + // under 110 so the same pass must enqueue and signal 112. | |
| 938 | + if (pid === 111 && !forked) { | |
| 939 | + forked = true; | |
| 940 | + processes.set(112, identity(112, 110, 112, 112)); | |
| 941 | + } | |
| 942 | + processes.delete(pid); | |
| 943 | + }, | |
| 944 | + }); | |
| 945 | + | |
| 946 | + yield* terminatePosixOwnedProcessTree({ | |
| 947 | + controller: fixture.controller, | |
| 948 | + grace: 0, | |
| 949 | + rootPid: 100, | |
| 950 | + }); | |
| 951 | + | |
| 952 | + expect(forked).toBe(true); | |
| 953 | + expect(fixture.processes.has(112)).toBe(false); | |
| 954 | + expect(fixture.signals.some((entry) => entry.startsWith("process:112:"))).toBe(true); | |
| 955 | + }), | |
| 956 | + ); | |
| 957 | + | |
| 958 | + it.live("retains the ledger across root exit and an explicit failure for finalizer retry", () => | |
| 959 | + Effect.gen(function* () { | |
| 960 | + let ignoreSignals = true; | |
| 961 | + const ledger = new Map<string, AcpOwnedPosixProcess>(); | |
| 962 | + const frontier = new Map<number, AcpOwnedPosixProcess>(); | |
| 963 | + const childQueues = new Map<number, Array<number>>(); | |
| 964 | + const root: AcpPosixOwnershipRoot = { value: undefined }; | |
| 965 | + const fixture = makeController({ | |
| 966 | + processes: [ | |
| 967 | + server(), | |
| 968 | + identity(100, process.pid, 100, 100), | |
| 969 | + identity(110, 100, 110, 110), | |
| 970 | + identity(111, 110, 110, 110), | |
| 971 | + ], | |
| 972 | + onProcess: (processes, pid) => { | |
| 973 | + if (ignoreSignals && pid === 100) processes.delete(100); | |
| 974 | + if (!ignoreSignals) processes.delete(pid); | |
| 975 | + }, | |
| 976 | + }); | |
| 977 | + observePosixOwnershipLedger({ | |
| 978 | + childQueues, | |
| 979 | + controller: fixture.controller, | |
| 980 | + frontier, | |
| 981 | + ledger, | |
| 982 | + root, | |
| 983 | + rootPid: 100, | |
| 984 | + }); | |
| 985 | + | |
| 986 | + const explicit = yield* Effect.exit( | |
| 987 | + terminatePosixOwnedProcessTree({ | |
| 988 | + controller: fixture.controller, | |
| 989 | + grace: 0, | |
| 990 | + ledger, | |
| 991 | + rootPid: 100, | |
| 992 | + }), | |
| 993 | + ); | |
| 994 | + expect(Exit.isFailure(explicit)).toBe(true); | |
| 995 | + expect(fixture.processes.has(100)).toBe(false); | |
| 996 | + expect(fixture.processes.has(111)).toBe(true); | |
| 997 | + | |
| 998 | + fixture.processes.set(112, identity(112, 110, 112, 112)); | |
| 999 | + observePosixOwnershipLedger({ | |
| 1000 | + childQueues, | |
| 1001 | + controller: fixture.controller, | |
| 1002 | + frontier, | |
| 1003 | + ledger, | |
| 1004 | + root, | |
| 1005 | + rootPid: 100, | |
| 1006 | + }); | |
| 1007 | + ignoreSignals = false; | |
| 1008 | + yield* terminatePosixOwnedProcessTree({ | |
| 1009 | + controller: fixture.controller, | |
| 1010 | + grace: 0, | |
| 1011 | + ledger, | |
| 1012 | + rootPid: 100, | |
| 1013 | + }); | |
| 1014 | + expect(fixture.processes.has(110)).toBe(false); | |
| 1015 | + expect(fixture.processes.has(111)).toBe(false); | |
| 1016 | + expect(fixture.processes.has(112)).toBe(false); | |
| 1017 | + }), | |
| 1018 | + ); | |
| 1019 | + | |
| 1020 | + it("fails closed where a stable POSIX identity provider is unavailable", () => { | |
| 1021 | + try { | |
| 1022 | + makePosixProcessTreeController("darwin"); | |
| 1023 | + throw new Error("Expected Darwin process ownership to fail closed"); | |
| 1024 | + } catch (error) { | |
| 1025 | + expect(error).toMatchObject({ | |
| 1026 | + detail: "Detached ACP descendant ownership is unsupported on darwin", | |
| 1027 | + }); | |
| 1028 | + } | |
| 1029 | + }); | |
| 1030 | +}); | |
| @@ -1,3 +1,8 @@ | ||
| 1 | +// @effect-diagnostics nodeBuiltinImport:off | |
| 2 | +import * as NodeCrypto from "node:crypto"; | |
| 3 | +import * as NodeFS from "node:fs"; | |
| 4 | +import * as NodePath from "node:path"; | |
| 5 | + | |
| 1 | 6 | import * as Cause from "effect/Cause"; |
| 2 | 7 | import * as Clock from "effect/Clock"; |
| 3 | 8 | import * as Context from "effect/Context"; |
| @@ -5,11 +10,13 @@ import * as Crypto from "effect/Crypto"; · 1 unmodified line | ||
| 5 | 10 | import * as Deferred from "effect/Deferred"; |
| 6 | 11 | import * as Duration from "effect/Duration"; |
| 7 | 12 | import * as Effect from "effect/Effect"; |
| 13 | +import * as Exit from "effect/Exit"; | |
| 8 | 14 | import * as Fiber from "effect/Fiber"; |
| 9 | 15 | import * as Layer from "effect/Layer"; |
| 10 | 16 | import * as Option from "effect/Option"; |
| 11 | 17 | import * as Queue from "effect/Queue"; |
| 12 | 18 | import * as Ref from "effect/Ref"; |
| 19 | +import * as Schema from "effect/Schema"; | |
| 13 | 20 | import * as Scope from "effect/Scope"; |
| 14 | 21 | import * as Semaphore from "effect/Semaphore"; |
| 15 | 22 | import * as Stream from "effect/Stream"; |
| @@ -20,6 +27,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; · 4 unmodified lines | ||
| 20 | 27 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 21 | 28 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 22 | 29 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 30 | +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 23 | 31 | |
| 24 | 32 | import { |
| 25 | 33 | collectSessionConfigOptionValues, |
| @@ -28,6 +36,7 @@ import { · 2 unmodified lines | ||
| 28 | 36 | mergeToolCallState, |
| 29 | 37 | parseSessionModeState, |
| 30 | 38 | parseSessionUpdateEvent, |
| 39 | + sessionUpdateCountsAsLoadReplayActivity, | |
| 31 | 40 | sessionUpdateIsReplay, |
| 32 | 41 | waitForSessionLoadReplayIdle, |
| 33 | 42 | type SessionLoadGate, |
| @@ -63,6 +72,16 @@ export interface AcpSessionRuntimeOptions { · 29 unmodified lines | ||
| 63 | 72 | readonly resumeSessionId?: string; |
| 64 | 73 | readonly sessionLoadTimeout?: Duration.Input; |
| 65 | 74 | readonly sessionLoadReplayIdleGap?: Duration.Input; |
| 75 | + readonly interruptPromptOnCancel?: boolean; | |
| 76 | + readonly ownDetachedProcessGroup?: boolean; | |
| 77 | + readonly ownDescendantProcessGroups?: boolean; | |
| 78 | + readonly processGroupPlatform?: NodeJS.Platform; | |
| 79 | + readonly processGroupTerminationGrace?: Duration.Input; | |
| 80 | + readonly windowsProcessTreeTerminator?: ( | |
| 81 | + pid: number, | |
| 82 | + ) => Effect.Effect<void, AcpProcessGroupTerminationError>; | |
| 83 | + readonly posixProcessTreeController?: AcpPosixProcessTreeController; | |
| 84 | + readonly linuxCgroupController?: AcpLinuxCgroupController | null; | |
| 66 | 85 | readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; |
| 67 | 86 | readonly clientInfo: { |
| 68 | 87 | readonly name: string; |
| @@ -76,6 +95,10 @@ export interface AcpSessionRuntimeOptions { · 7 unmodified lines | ||
| 76 | 95 | readonly logOutgoing?: boolean; |
| 77 | 96 | readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>; |
| 78 | 97 | }; |
| 98 | + readonly onIncomingRequest?: EffectAcpClient.AcpClientOptions["onIncomingRequest"]; | |
| 99 | + readonly onTermination?: (error: EffectAcpErrors.AcpError) => Effect.Effect<void>; | |
| 100 | + readonly onOutgoingResponseFailure?: EffectAcpClient.AcpClientOptions["onOutgoingResponseFailure"]; | |
| 101 | + readonly onOutgoingResponse?: EffectAcpClient.AcpClientOptions["onOutgoingResponse"]; | |
| 79 | 102 | } |
| 80 | 103 | |
| 81 | 104 | export interface AcpSessionRequestLogEvent { |
| @@ -86,6 +109,925 @@ export interface AcpSessionRequestLogEvent { · 4 unmodified lines | ||
| 86 | 109 | readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>; |
| 87 | 110 | } |
| 88 | 111 | |
| 112 | +export class AcpProcessGroupTerminationError extends Schema.TaggedErrorClass<AcpProcessGroupTerminationError>()( | |
| 113 | + "AcpProcessGroupTerminationError", | |
| 114 | + { | |
| 115 | + detail: Schema.String, | |
| 116 | + pid: Schema.optionalKey(Schema.Int), | |
| 117 | + exitCode: Schema.optionalKey(Schema.Number), | |
| 118 | + cause: Schema.optional(Schema.Defect()), | |
| 119 | + }, | |
| 120 | +) { | |
| 121 | + override get message(): string { | |
| 122 | + return this.detail; | |
| 123 | + } | |
| 124 | +} | |
| 125 | + | |
| 126 | +const isAcpProcessGroupTerminationError = Schema.is(AcpProcessGroupTerminationError); | |
| 127 | + | |
| 128 | +export interface AcpPosixProcessIdentity { | |
| 129 | + readonly executable: string | undefined; | |
| 130 | + readonly pgid: number; | |
| 131 | + readonly pid: number; | |
| 132 | + readonly ppid: number; | |
| 133 | + readonly sid: number; | |
| 134 | + /** | |
| 135 | + * Linux `/proc/<pid>/stat` state when known (`R`/`S`/`D`/`Z`/…). Omitted on | |
| 136 | + * platforms or fixtures that do not surface it. Zombies (`Z`) are already | |
| 137 | + * dead and must not count as residual teardown survivors. | |
| 138 | + */ | |
| 139 | + readonly state?: string; | |
| 140 | + readonly startTime: string; | |
| 141 | +} | |
| 142 | + | |
| 143 | +export interface AcpPosixProcessTreeController { | |
| 144 | + readonly childPidsOf: (pid: number) => ReadonlyArray<number>; | |
| 145 | + readonly childrenOf: (pid: number) => ReadonlyArray<AcpPosixProcessIdentity>; | |
| 146 | + readonly identity: (pid: number) => AcpPosixProcessIdentity | undefined; | |
| 147 | + readonly snapshot: () => ReadonlyArray<AcpPosixProcessIdentity>; | |
| 148 | + readonly signalProcess: (pid: number, signal: NodeJS.Signals) => void; | |
| 149 | +} | |
| 150 | + | |
| 151 | +export interface AcpLinuxCgroupLease { | |
| 152 | + readonly contains: (pid: number) => boolean; | |
| 153 | + readonly exists: () => boolean; | |
| 154 | + readonly path: string; | |
| 155 | + readonly relativePath: string; | |
| 156 | + readonly kill: () => void; | |
| 157 | + readonly populated: () => boolean; | |
| 158 | + readonly remove: () => void; | |
| 159 | +} | |
| 160 | + | |
| 161 | +export interface AcpLinuxCgroupController { | |
| 162 | + readonly create: () => AcpLinuxCgroupLease | undefined; | |
| 163 | +} | |
| 164 | + | |
| 165 | +export interface AcpPosixOwnershipRoot { | |
| 166 | + captureAttempted?: boolean; | |
| 167 | + value: AcpOwnedPosixProcess | undefined; | |
| 168 | +} | |
| 169 | + | |
| 170 | +function decodeMountInfoPath(value: string): string { | |
| 171 | + return value.replace(/\\([0-7]{3})/g, (_match, octal: string) => | |
| 172 | + String.fromCharCode(Number.parseInt(octal, 8)), | |
| 173 | + ); | |
| 174 | +} | |
| 175 | + | |
| 176 | +export function parseUnifiedCgroupPath(contents: string): string | undefined { | |
| 177 | + const matches = [...contents.matchAll(/^0::(\/.*)$/gm)]; | |
| 178 | + return matches.length === 1 ? matches[0]![1] : undefined; | |
| 179 | +} | |
| 180 | + | |
| 181 | +export function isSafeCgroupPath(value: string): boolean { | |
| 182 | + return ( | |
| 183 | + value.startsWith("/") && | |
| 184 | + !value.includes("(deleted)") && | |
| 185 | + NodePath.posix.normalize(value) === value | |
| 186 | + ); | |
| 187 | +} | |
| 188 | + | |
| 189 | +export function parseCgroup2Mounts( | |
| 190 | + contents: string, | |
| 191 | +): ReadonlyArray<{ readonly mountPoint: string; readonly root: string }> { | |
| 192 | + const mounts: Array<{ readonly mountPoint: string; readonly root: string }> = []; | |
| 193 | + for (const line of contents.split("\n")) { | |
| 194 | + const [mount, filesystem] = line.split(" - "); | |
| 195 | + if (mount === undefined || filesystem?.split(" ")[0] !== "cgroup2") continue; | |
| 196 | + const fields = mount.split(" "); | |
| 197 | + if (fields.length < 5) continue; | |
| 198 | + mounts.push({ | |
| 199 | + mountPoint: decodeMountInfoPath(fields[4]!), | |
| 200 | + root: decodeMountInfoPath(fields[3]!), | |
| 201 | + }); | |
| 202 | + } | |
| 203 | + return mounts; | |
| 204 | +} | |
| 205 | + | |
| 206 | +export function parseCgroup2Mount( | |
| 207 | + contents: string, | |
| 208 | +): { readonly mountPoint: string; readonly root: string } | undefined { | |
| 209 | + return parseCgroup2Mounts(contents)[0]; | |
| 210 | +} | |
| 211 | + | |
| 212 | +export function wrapCommandForLinuxCgroup( | |
| 213 | + lease: AcpLinuxCgroupLease, | |
| 214 | + command: string, | |
| 215 | + args: ReadonlyArray<string>, | |
| 216 | +): { readonly command: string; readonly args: ReadonlyArray<string> } { | |
| 217 | + return { | |
| 218 | + command: process.execPath, | |
| 219 | + args: [ | |
| 220 | + "-e", | |
| 221 | + [ | |
| 222 | + 'const fs = require("node:fs");', | |
| 223 | + "try {", | |
| 224 | + ' fs.writeFileSync(process.argv[1] + "/cgroup.procs", String(process.pid) + "\\n");', | |
| 225 | + ' const actual = fs.readFileSync("/proc/self/cgroup", "utf8").split("\\n").find((line) => line.startsWith("0::"))?.slice(3);', | |
| 226 | + " if (actual !== process.argv[2]) process.exit(126);", | |
| 227 | + " const env = { ...process.env };", | |
| 228 | + " delete env.ELECTRON_RUN_AS_NODE;", | |
| 229 | + " delete env.T3_ACP_CGROUP_WRAPPER;", | |
| 230 | + " process.execve(process.argv[3], process.argv.slice(3), env);", | |
| 231 | + "} catch { process.exit(125); }", | |
| 232 | + ].join("\n"), | |
| 233 | + lease.path, | |
| 234 | + lease.relativePath, | |
| 235 | + command, | |
| 236 | + ...args, | |
| 237 | + ], | |
| 238 | + }; | |
| 239 | +} | |
| 240 | + | |
| 241 | +export function resolveLinuxCgroupTargetCommand( | |
| 242 | + command: string, | |
| 243 | + cwd: string, | |
| 244 | + environment: NodeJS.ProcessEnv, | |
| 245 | +): string | undefined { | |
| 246 | + // Match Node spawn PATH fallback when PATH is undefined. An explicitly empty | |
| 247 | + // PATH stays empty (lookup only via empty-segment → cwd). | |
| 248 | + const pathEnv = environment.PATH === undefined ? "/usr/bin:/bin" : environment.PATH; | |
| 249 | + const candidates = command.includes(NodePath.sep) | |
| 250 | + ? [NodePath.resolve(cwd, command)] | |
| 251 | + : pathEnv | |
| 252 | + .split(NodePath.delimiter) | |
| 253 | + .map((entry) => | |
| 254 | + NodePath.join( | |
| 255 | + entry.length === 0 | |
| 256 | + ? cwd | |
| 257 | + : NodePath.isAbsolute(entry) | |
| 258 | + ? entry | |
| 259 | + : NodePath.resolve(cwd, entry), | |
| 260 | + command, | |
| 261 | + ), | |
| 262 | + ); | |
| 263 | + for (const candidate of candidates) { | |
| 264 | + try { | |
| 265 | + if (!NodeFS.statSync(candidate).isFile()) continue; | |
| 266 | + NodeFS.accessSync(candidate, NodeFS.constants.X_OK); | |
| 267 | + return candidate; | |
| 268 | + } catch { | |
| 269 | + // Try the next PATH entry. | |
| 270 | + } | |
| 271 | + } | |
| 272 | + return undefined; | |
| 273 | +} | |
| 274 | + | |
| 275 | +const STALE_ACP_CGROUP_SIBLING = /^t3-acp-(\d+)-/; | |
| 276 | + | |
| 277 | +export interface SweepStaleLinuxCgroupSiblingsOptions { | |
| 278 | + readonly currentPid?: number; | |
| 279 | + readonly isProcessAlive?: (pid: number) => boolean; | |
| 280 | + readonly readPopulated?: (siblingPath: string) => "0" | "1" | undefined; | |
| 281 | + readonly remove?: (siblingPath: string) => void; | |
| 282 | +} | |
| 283 | + | |
| 284 | +function defaultCgroupOwnerIsAlive(pid: number): boolean { | |
| 285 | + try { | |
| 286 | + process.kill(pid, 0); | |
| 287 | + return true; | |
| 288 | + } catch (cause) { | |
| 289 | + return (cause as NodeJS.ErrnoException | undefined)?.code !== "ESRCH"; | |
| 290 | + } | |
| 291 | +} | |
| 292 | + | |
| 293 | +function defaultReadCgroupPopulated(siblingPath: string): "0" | "1" | undefined { | |
| 294 | + const state = /(?:^|\n)populated ([01])(?:\n|$)/.exec( | |
| 295 | + NodeFS.readFileSync(NodePath.join(siblingPath, "cgroup.events"), "utf8"), | |
| 296 | + )?.[1]; | |
| 297 | + return state === "0" || state === "1" ? state : undefined; | |
| 298 | +} | |
| 299 | + | |
| 300 | +/** Best-effort removal of empty `t3-acp-<dead-pid>-*` sibling leases under a parent cgroup. */ | |
| 301 | +export function sweepStaleLinuxCgroupSiblings( | |
| 302 | + parentPath: string, | |
| 303 | + options: SweepStaleLinuxCgroupSiblingsOptions = {}, | |
| 304 | +): void { | |
| 305 | + const currentPid = options.currentPid ?? process.pid; | |
| 306 | + const isProcessAlive = options.isProcessAlive ?? defaultCgroupOwnerIsAlive; | |
| 307 | + const readPopulated = options.readPopulated ?? defaultReadCgroupPopulated; | |
| 308 | + const remove = options.remove ?? ((siblingPath: string) => NodeFS.rmdirSync(siblingPath)); | |
| 309 | + try { | |
| 310 | + for (const entry of NodeFS.readdirSync(parentPath, { withFileTypes: true })) { | |
| 311 | + try { | |
| 312 | + if (!entry.isDirectory()) continue; | |
| 313 | + const match = STALE_ACP_CGROUP_SIBLING.exec(entry.name); | |
| 314 | + if (match === null) continue; | |
| 315 | + const ownerPid = Number(match[1]); | |
| 316 | + if (!Number.isSafeInteger(ownerPid) || ownerPid <= 1 || ownerPid === currentPid) continue; | |
| 317 | + if (isProcessAlive(ownerPid)) continue; | |
| 318 | + const siblingPath = NodePath.join(parentPath, entry.name); | |
| 319 | + if (NodePath.dirname(siblingPath) !== parentPath) continue; | |
| 320 | + if (readPopulated(siblingPath) !== "0") continue; | |
| 321 | + remove(siblingPath); | |
| 322 | + } catch { | |
| 323 | + // Ignore unreadable or busy siblings; never fail lease creation. | |
| 324 | + } | |
| 325 | + } | |
| 326 | + } catch { | |
| 327 | + // Ignore readdir failures on the parent path. | |
| 328 | + } | |
| 329 | +} | |
| 330 | + | |
| 331 | +function tryCreateLinuxCgroupLease( | |
| 332 | + currentRelative: string, | |
| 333 | + mount: { readonly mountPoint: string; readonly root: string }, | |
| 334 | +): AcpLinuxCgroupLease | undefined { | |
| 335 | + if ( | |
| 336 | + !isSafeCgroupPath(mount.root) || | |
| 337 | + !isSafeCgroupPath(mount.mountPoint) || | |
| 338 | + !( | |
| 339 | + mount.root === "/" || | |
| 340 | + currentRelative === mount.root || | |
| 341 | + currentRelative.startsWith(`${mount.root}/`) | |
| 342 | + ) | |
| 343 | + ) { | |
| 344 | + return undefined; | |
| 345 | + } | |
| 346 | + const mountRelative = | |
| 347 | + mount.root === "/" ? currentRelative : currentRelative.slice(mount.root.length); | |
| 348 | + const currentPath = NodePath.resolve(mount.mountPoint, `.${mountRelative}`); | |
| 349 | + const resolvedMount = NodePath.resolve(mount.mountPoint); | |
| 350 | + if (currentPath !== resolvedMount && !currentPath.startsWith(`${resolvedMount}${NodePath.sep}`)) { | |
| 351 | + return undefined; | |
| 352 | + } | |
| 353 | + sweepStaleLinuxCgroupSiblings(currentPath); | |
| 354 | + const childName = `t3-acp-${process.pid}-${NodeCrypto.randomUUID().replaceAll("-", "")}`; | |
| 355 | + const childPath = NodePath.join(currentPath, childName); | |
| 356 | + const childRelative = NodePath.posix.join(currentRelative, childName); | |
| 357 | + if (NodePath.dirname(childPath) !== currentPath) return undefined; | |
| 358 | + try { | |
| 359 | + NodeFS.mkdirSync(childPath, { mode: 0o700 }); | |
| 360 | + NodeFS.accessSync(NodePath.join(childPath, "cgroup.procs"), NodeFS.constants.W_OK); | |
| 361 | + NodeFS.accessSync(NodePath.join(childPath, "cgroup.kill"), NodeFS.constants.W_OK); | |
| 362 | + NodeFS.accessSync(NodePath.join(childPath, "cgroup.events"), NodeFS.constants.R_OK); | |
| 363 | + if (NodeFS.readFileSync(NodePath.join(childPath, "cgroup.type"), "utf8").trim() !== "domain") { | |
| 364 | + throw new Error("ACP cgroup is not a domain cgroup"); | |
| 365 | + } | |
| 366 | + if (typeof process.execve !== "function") throw new Error("process.execve is unavailable"); | |
| 367 | + } catch { | |
| 368 | + try { | |
| 369 | + NodeFS.rmdirSync(childPath); | |
| 370 | + } catch { | |
| 371 | + // The unavailable cgroup was never used for a child process. | |
| 372 | + } | |
| 373 | + return undefined; | |
| 374 | + } | |
| 375 | + return { | |
| 376 | + contains: (pid) => | |
| 377 | + parseUnifiedCgroupPath(NodeFS.readFileSync(`/proc/${pid}/cgroup`, "utf8")) === childRelative, | |
| 378 | + exists: () => NodeFS.existsSync(childPath), | |
| 379 | + path: childPath, | |
| 380 | + relativePath: childRelative, | |
| 381 | + kill: () => NodeFS.writeFileSync(NodePath.join(childPath, "cgroup.kill"), "1\n"), | |
| 382 | + populated: () => { | |
| 383 | + const state = /(?:^|\n)populated ([01])(?:\n|$)/.exec( | |
| 384 | + NodeFS.readFileSync(NodePath.join(childPath, "cgroup.events"), "utf8"), | |
| 385 | + )?.[1]; | |
| 386 | + if (state === undefined) throw new Error("ACP cgroup.events has no populated state"); | |
| 387 | + return state === "1"; | |
| 388 | + }, | |
| 389 | + remove: () => { | |
| 390 | + const directories = (path: string): ReadonlyArray<string> => | |
| 391 | + NodeFS.readdirSync(path, { withFileTypes: true }).flatMap((entry) => | |
| 392 | + entry.isDirectory() | |
| 393 | + ? [...directories(NodePath.join(path, entry.name)), NodePath.join(path, entry.name)] | |
| 394 | + : [], | |
| 395 | + ); | |
| 396 | + for (const directory of directories(childPath)) NodeFS.rmdirSync(directory); | |
| 397 | + NodeFS.rmdirSync(childPath); | |
| 398 | + }, | |
| 399 | + }; | |
| 400 | +} | |
| 401 | + | |
| 402 | +export function makeLinuxCgroupController(): AcpLinuxCgroupController { | |
| 403 | + return { | |
| 404 | + create: () => { | |
| 405 | + let currentRelative: string | undefined; | |
| 406 | + let mounts: ReadonlyArray<{ readonly mountPoint: string; readonly root: string }> = []; | |
| 407 | + try { | |
| 408 | + currentRelative = parseUnifiedCgroupPath(NodeFS.readFileSync("/proc/self/cgroup", "utf8")); | |
| 409 | + mounts = parseCgroup2Mounts(NodeFS.readFileSync("/proc/self/mountinfo", "utf8")); | |
| 410 | + } catch { | |
| 411 | + return undefined; | |
| 412 | + } | |
| 413 | + if ( | |
| 414 | + currentRelative === undefined || | |
| 415 | + !currentRelative.startsWith("/") || | |
| 416 | + mounts.length === 0 | |
| 417 | + ) { | |
| 418 | + return undefined; | |
| 419 | + } | |
| 420 | + if (!isSafeCgroupPath(currentRelative)) return undefined; | |
| 421 | + // Prefer the first mount that successfully creates a writable child cgroup. | |
| 422 | + // Multi-mount hosts often list a read-only view before the delegated one. | |
| 423 | + for (const mount of mounts) { | |
| 424 | + const lease = tryCreateLinuxCgroupLease(currentRelative, mount); | |
| 425 | + if (lease !== undefined) return lease; | |
| 426 | + } | |
| 427 | + return undefined; | |
| 428 | + }, | |
| 429 | + }; | |
| 430 | +} | |
| 431 | + | |
| 432 | +// OS-state polls (cgroup emptiness, process trees) must use wall time. Under | |
| 433 | +// TestClock, Effect.sleep never resolves unless the test advances the clock. | |
| 434 | +const wallClock = Clock.Clock.defaultValue(); | |
| 435 | +const withWallClock = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> => | |
| 436 | + Effect.provideService(effect, Clock.Clock, wallClock); | |
| 437 | + | |
| 438 | +export function terminateLinuxCgroupLease( | |
| 439 | + lease: AcpLinuxCgroupLease, | |
| 440 | +): Effect.Effect<void, AcpProcessGroupTerminationError> { | |
| 441 | + const failure = (name: string, cause: unknown) => | |
| 442 | + new AcpProcessGroupTerminationError({ | |
| 443 | + cause, | |
| 444 | + detail: `Failed to ${name} ACP cgroup ${lease.path}`, | |
| 445 | + }); | |
| 446 | + return Effect.gen(function* () { | |
| 447 | + for (let attempt = 0; attempt < 200; attempt += 1) { | |
| 448 | + if (!lease.exists()) return; | |
| 449 | + try { | |
| 450 | + lease.kill(); | |
| 451 | + } catch (cause) { | |
| 452 | + const code = (cause as NodeJS.ErrnoException | undefined)?.code; | |
| 453 | + if (code !== "ENOENT") return yield* failure("kill", cause); | |
| 454 | + if (!lease.exists()) return; | |
| 455 | + yield* Effect.sleep("10 millis"); | |
| 456 | + continue; | |
| 457 | + } | |
| 458 | + let populated: boolean; | |
| 459 | + try { | |
| 460 | + populated = lease.populated(); | |
| 461 | + } catch (cause) { | |
| 462 | + const code = (cause as NodeJS.ErrnoException | undefined)?.code; | |
| 463 | + if (code === "ENOENT" && !lease.exists()) return; | |
| 464 | + if (code === "ENOENT") { | |
| 465 | + yield* Effect.sleep("10 millis"); | |
| 466 | + continue; | |
| 467 | + } | |
| 468 | + return yield* failure("read state for", cause); | |
| 469 | + } | |
| 470 | + if (populated) { | |
| 471 | + yield* Effect.sleep("10 millis"); | |
| 472 | + continue; | |
| 473 | + } | |
| 474 | + try { | |
| 475 | + lease.remove(); | |
| 476 | + } catch (cause) { | |
| 477 | + const code = (cause as NodeJS.ErrnoException | undefined)?.code; | |
| 478 | + if (code === "ENOENT" && !lease.exists()) return; | |
| 479 | + if (code !== "EBUSY" && code !== "ENOTEMPTY" && code !== "ENOENT") { | |
| 480 | + return yield* failure("remove", cause); | |
| 481 | + } | |
| 482 | + } | |
| 483 | + if (!lease.exists()) return; | |
| 484 | + yield* Effect.sleep("10 millis"); | |
| 485 | + } | |
| 486 | + return yield* new AcpProcessGroupTerminationError({ | |
| 487 | + detail: `ACP cgroup ${lease.path} remained populated after cgroup.kill`, | |
| 488 | + }); | |
| 489 | + }).pipe(withWallClock); | |
| 490 | +} | |
| 491 | + | |
| 492 | +export function signalLinuxCgroupRootTerm(input: { | |
| 493 | + readonly controller: AcpPosixProcessTreeController; | |
| 494 | + readonly lease: AcpLinuxCgroupLease; | |
| 495 | + readonly root: AcpPosixOwnershipRoot; | |
| 496 | +}): void { | |
| 497 | + const root = input.root.value; | |
| 498 | + if (root === undefined) return; | |
| 499 | + try { | |
| 500 | + const observed = input.controller.identity(root.pid); | |
| 501 | + if (!samePosixProcessIdentity(root, observed) || !input.lease.contains(root.pid)) return; | |
| 502 | + input.controller.signalProcess(root.pid, "SIGTERM"); | |
| 503 | + } catch { | |
| 504 | + // TERM is optional. cgroup.kill remains the authoritative teardown. | |
| 505 | + } | |
| 506 | +} | |
| 507 | + | |
| 508 | +function readLinuxProcessIdentity(pid: number): AcpPosixProcessIdentity | undefined { | |
| 509 | + try { | |
| 510 | + const stat = NodeFS.readFileSync(`/proc/${pid}/stat`, "utf8"); | |
| 511 | + const commandEnd = stat.lastIndexOf(")"); | |
| 512 | + if (commandEnd < 0) return undefined; | |
| 513 | + const fields = stat | |
| 514 | + .slice(commandEnd + 2) | |
| 515 | + .trim() | |
| 516 | + .split(/\s+/); | |
| 517 | + // After comm: state, ppid, pgrp, session, … starttime (field 22 / index 19). | |
| 518 | + const state = fields[0]; | |
| 519 | + const ppid = Number(fields[1]); | |
| 520 | + const pgid = Number(fields[2]); | |
| 521 | + const sid = Number(fields[3]); | |
| 522 | + const startTime = fields[19]; | |
| 523 | + if ( | |
| 524 | + state === undefined || | |
| 525 | + !Number.isSafeInteger(ppid) || | |
| 526 | + !Number.isSafeInteger(pgid) || | |
| 527 | + !Number.isSafeInteger(sid) || | |
| 528 | + startTime === undefined | |
| 529 | + ) { | |
| 530 | + return undefined; | |
| 531 | + } | |
| 532 | + let executable: string | undefined; | |
| 533 | + try { | |
| 534 | + executable = NodeFS.readlinkSync(`/proc/${pid}/exe`); | |
| 535 | + } catch { | |
| 536 | + executable = undefined; | |
| 537 | + } | |
| 538 | + return { executable, pgid, pid, ppid, sid, startTime, state }; | |
| 539 | + } catch { | |
| 540 | + return undefined; | |
| 541 | + } | |
| 542 | +} | |
| 543 | + | |
| 544 | +/** Linux zombies keep pid/starttime until reaped; they are not live survivors. */ | |
| 545 | +export function posixProcessIsZombie(process: AcpPosixProcessIdentity): boolean { | |
| 546 | + return process.state === "Z"; | |
| 547 | +} | |
| 548 | + | |
| 549 | +function readLinuxChildPids(pid: number): ReadonlyArray<number> { | |
| 550 | + const childPids = new Set<number>(); | |
| 551 | + let taskIds: ReadonlyArray<string> = []; | |
| 552 | + try { | |
| 553 | + taskIds = NodeFS.readdirSync(`/proc/${pid}/task`).filter((entry) => | |
| 554 | + /^[1-9][0-9]*$/.test(entry), | |
| 555 | + ); | |
| 556 | + } catch { | |
| 557 | + return []; | |
| 558 | + } | |
| 559 | + for (const taskId of taskIds) { | |
| 560 | + try { | |
| 561 | + for (const childPid of NodeFS.readFileSync(`/proc/${pid}/task/${taskId}/children`, "utf8") | |
| 562 | + .trim() | |
| 563 | + .split(/\s+/) | |
| 564 | + .filter(Boolean) | |
| 565 | + .map(Number)) { | |
| 566 | + childPids.add(childPid); | |
| 567 | + } | |
| 568 | + } catch { | |
| 569 | + // The task exited while its children were being read. | |
| 570 | + } | |
| 571 | + } | |
| 572 | + return [...childPids]; | |
| 573 | +} | |
| 574 | + | |
| 575 | +function makeLinuxProcessTreeController(): AcpPosixProcessTreeController { | |
| 576 | + return { | |
| 577 | + childPidsOf: readLinuxChildPids, | |
| 578 | + childrenOf: (pid) => | |
| 579 | + readLinuxChildPids(pid).flatMap((childPid) => { | |
| 580 | + const identity = readLinuxProcessIdentity(childPid); | |
| 581 | + return identity === undefined ? [] : [identity]; | |
| 582 | + }), | |
| 583 | + identity: readLinuxProcessIdentity, | |
| 584 | + snapshot: () => | |
| 585 | + NodeFS.readdirSync("/proc", { withFileTypes: true }).flatMap((entry) => { | |
| 586 | + if (!entry.isDirectory() || !/^[1-9][0-9]*$/.test(entry.name)) return []; | |
| 587 | + const identity = readLinuxProcessIdentity(Number(entry.name)); | |
| 588 | + return identity === undefined ? [] : [identity]; | |
| 589 | + }), | |
| 590 | + signalProcess: (pid, signal) => process.kill(pid, signal), | |
| 591 | + }; | |
| 592 | +} | |
| 593 | + | |
| 594 | +export function observePosixOwnershipLedger(input: { | |
| 595 | + readonly childQueues: Map<number, Array<number>>; | |
| 596 | + readonly controller: AcpPosixProcessTreeController; | |
| 597 | + readonly frontier: Map<number, AcpOwnedPosixProcess>; | |
| 598 | + readonly ledger: Map<string, AcpOwnedPosixProcess>; | |
| 599 | + readonly maxProcesses?: number; | |
| 600 | + readonly root: AcpPosixOwnershipRoot; | |
| 601 | + readonly rootPid: number; | |
| 602 | +}): void { | |
| 603 | + let rootIdentity: AcpPosixProcessIdentity | undefined = input.root.value; | |
| 604 | + if (rootIdentity === undefined) { | |
| 605 | + if (input.root.captureAttempted === true) { | |
| 606 | + throw new AcpProcessGroupTerminationError({ | |
| 607 | + detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`, | |
| 608 | + }); | |
| 609 | + } | |
| 610 | + input.root.captureAttempted = true; | |
| 611 | + rootIdentity = input.controller.identity(input.rootPid); | |
| 612 | + if (rootIdentity === undefined) { | |
| 613 | + throw new AcpProcessGroupTerminationError({ | |
| 614 | + detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`, | |
| 615 | + }); | |
| 616 | + } | |
| 617 | + const parent = input.controller.identity(rootIdentity.ppid); | |
| 618 | + const ownedRoot = { | |
| 619 | + ...rootIdentity, | |
| 620 | + parentExecutable: parent?.executable, | |
| 621 | + parentStartTime: parent?.startTime ?? "", | |
| 622 | + }; | |
| 623 | + input.ledger.set(processIdentityKey(rootIdentity), ownedRoot); | |
| 624 | + input.frontier.set(rootIdentity.pid, ownedRoot); | |
| 625 | + input.root.value = ownedRoot; | |
| 626 | + } | |
| 627 | + const maxProcesses = Math.max(1, input.maxProcesses ?? Number.POSITIVE_INFINITY); | |
| 628 | + let remainingBudget = maxProcesses; | |
| 629 | + const pending = [...input.frontier.entries()]; | |
| 630 | + const visited = new Set<number>(); | |
| 631 | + while (pending.length > 0 && remainingBudget > 0) { | |
| 632 | + const entry = pending.shift(); | |
| 633 | + if (entry === undefined) continue; | |
| 634 | + const [parentPid, parent] = entry; | |
| 635 | + input.frontier.delete(parentPid); | |
| 636 | + if (visited.has(parent.pid)) continue; | |
| 637 | + visited.add(parent.pid); | |
| 638 | + remainingBudget -= 1; | |
| 639 | + const observedParent = input.controller.identity(parent.pid); | |
| 640 | + if (!samePosixProcessIdentity(parent, observedParent)) { | |
| 641 | + input.childQueues.delete(parent.pid); | |
| 642 | + continue; | |
| 643 | + } | |
| 644 | + const refreshedParent = { ...parent, ...observedParent }; | |
| 645 | + input.ledger.set(processIdentityKey(refreshedParent), refreshedParent); | |
| 646 | + input.frontier.set(refreshedParent.pid, refreshedParent); | |
| 647 | + let queuedChildren = input.childQueues.get(refreshedParent.pid); | |
| 648 | + if (queuedChildren === undefined) { | |
| 649 | + if (remainingBudget === 0) continue; | |
| 650 | + remainingBudget -= 1; | |
| 651 | + queuedChildren = [...input.controller.childPidsOf(refreshedParent.pid)]; | |
| 652 | + } | |
| 653 | + while (queuedChildren.length > 0 && remainingBudget > 0) { | |
| 654 | + const childPid = queuedChildren.shift(); | |
| 655 | + if (childPid === undefined) continue; | |
| 656 | + remainingBudget -= 1; | |
| 657 | + const child = input.controller.identity(childPid); | |
| 658 | + if (child === undefined) continue; | |
| 659 | + if (child.ppid !== parent.pid) continue; | |
| 660 | + let reservedPid = input.frontier.get(child.pid); | |
| 661 | + if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, child)) { | |
| 662 | + // PID reused under an owned parent; drop the stale reservation. | |
| 663 | + input.frontier.delete(child.pid); | |
| 664 | + for (const [key, owned] of [...input.ledger.entries()]) { | |
| 665 | + if (owned.pid === child.pid) input.ledger.delete(key); | |
| 666 | + } | |
| 667 | + reservedPid = undefined; | |
| 668 | + } | |
| 669 | + const childKey = processIdentityKey(child); | |
| 670 | + const owned = | |
| 671 | + reservedPid === undefined | |
| 672 | + ? { | |
| 673 | + ...child, | |
| 674 | + parentExecutable: refreshedParent.executable, | |
| 675 | + parentStartTime: refreshedParent.startTime, | |
| 676 | + } | |
| 677 | + : { ...reservedPid, ...child }; | |
| 678 | + input.ledger.set(childKey, owned); | |
| 679 | + input.frontier.set(child.pid, owned); | |
| 680 | + } | |
| 681 | + if (queuedChildren.length === 0) input.childQueues.delete(refreshedParent.pid); | |
| 682 | + else input.childQueues.set(refreshedParent.pid, queuedChildren); | |
| 683 | + } | |
| 684 | +} | |
| 685 | + | |
| 686 | +export function observePosixOwnershipLedgerContinuously(input: { | |
| 687 | + readonly childQueues: Map<number, Array<number>>; | |
| 688 | + readonly controller: AcpPosixProcessTreeController; | |
| 689 | + readonly frontier: Map<number, AcpOwnedPosixProcess>; | |
| 690 | + readonly ledger: Map<string, AcpOwnedPosixProcess>; | |
| 691 | + readonly root: AcpPosixOwnershipRoot; | |
| 692 | + readonly rootPid: number; | |
| 693 | +}): Effect.Effect<never> { | |
| 694 | + return Effect.forever( | |
| 695 | + Effect.gen(function* () { | |
| 696 | + const startedAt = yield* Clock.currentTimeMillis; | |
| 697 | + yield* Effect.sync(() => observePosixOwnershipLedger({ ...input, maxProcesses: 64 })).pipe( | |
| 698 | + Effect.exit, | |
| 699 | + ); | |
| 700 | + const targetInterval = input.frontier.size <= 32 ? 25 : input.frontier.size <= 128 ? 50 : 100; | |
| 701 | + const completedAt = yield* Clock.currentTimeMillis; | |
| 702 | + yield* Effect.sleep(`${Math.max(5, targetInterval - (completedAt - startedAt))} millis`); | |
| 703 | + }), | |
| 704 | + ).pipe(withWallClock); | |
| 705 | +} | |
| 706 | + | |
| 707 | +export function makePosixProcessTreeController( | |
| 708 | + platform: NodeJS.Platform, | |
| 709 | +): AcpPosixProcessTreeController { | |
| 710 | + if (platform === "linux") return makeLinuxProcessTreeController(); | |
| 711 | + throw new AcpProcessGroupTerminationError({ | |
| 712 | + detail: `Detached ACP descendant ownership is unsupported on ${platform}`, | |
| 713 | + }); | |
| 714 | +} | |
| 715 | + | |
| 716 | +function samePosixProcessIdentity( | |
| 717 | + expected: AcpPosixProcessIdentity, | |
| 718 | + observed: AcpPosixProcessIdentity | undefined, | |
| 719 | +): boolean { | |
| 720 | + return ( | |
| 721 | + observed !== undefined && | |
| 722 | + observed.pid === expected.pid && | |
| 723 | + observed.startTime === expected.startTime | |
| 724 | + ); | |
| 725 | +} | |
| 726 | + | |
| 727 | +export interface AcpOwnedPosixProcess extends AcpPosixProcessIdentity { | |
| 728 | + readonly parentExecutable: string | undefined; | |
| 729 | + readonly parentStartTime: string; | |
| 730 | +} | |
| 731 | + | |
| 732 | +function processIdentityKey(process: AcpPosixProcessIdentity): string { | |
| 733 | + return `${process.pid}:${process.startTime}`; | |
| 734 | +} | |
| 735 | + | |
| 736 | +function processDepth( | |
| 737 | + process: AcpPosixProcessIdentity, | |
| 738 | + ledgerByPid: ReadonlyMap<number, AcpOwnedPosixProcess>, | |
| 739 | +): number { | |
| 740 | + let depth = 0; | |
| 741 | + let current = process; | |
| 742 | + const seen = new Set<number>(); | |
| 743 | + while (!seen.has(current.pid)) { | |
| 744 | + seen.add(current.pid); | |
| 745 | + const parent = ledgerByPid.get(current.ppid); | |
| 746 | + if (parent === undefined) break; | |
| 747 | + depth += 1; | |
| 748 | + current = parent; | |
| 749 | + } | |
| 750 | + return depth; | |
| 751 | +} | |
| 752 | + | |
| 753 | +export function capturePosixOwnershipLedger(input: { | |
| 754 | + readonly controller: AcpPosixProcessTreeController; | |
| 755 | + readonly ledger: Map<string, AcpOwnedPosixProcess>; | |
| 756 | + readonly root?: AcpPosixOwnershipRoot; | |
| 757 | + readonly rootPid: number; | |
| 758 | + readonly table?: ReadonlyArray<AcpPosixProcessIdentity>; | |
| 759 | +}): ReadonlyArray<AcpPosixProcessIdentity> { | |
| 760 | + let table: ReadonlyArray<AcpPosixProcessIdentity>; | |
| 761 | + if (input.table === undefined) { | |
| 762 | + try { | |
| 763 | + table = input.controller.snapshot(); | |
| 764 | + } catch (cause) { | |
| 765 | + throw new AcpProcessGroupTerminationError({ | |
| 766 | + cause, | |
| 767 | + detail: `Failed to snapshot ACP process tree ${input.rootPid}`, | |
| 768 | + }); | |
| 769 | + } | |
| 770 | + } else { | |
| 771 | + table = input.table; | |
| 772 | + } | |
| 773 | + const byPid = new Map(table.map((process) => [process.pid, process])); | |
| 774 | + let rootIdentity: AcpPosixProcessIdentity | undefined = | |
| 775 | + input.root?.value ?? | |
| 776 | + [...input.ledger.values()].find((process) => process.pid === input.rootPid); | |
| 777 | + if (rootIdentity === undefined) { | |
| 778 | + rootIdentity = byPid.get(input.rootPid); | |
| 779 | + if (rootIdentity === undefined) { | |
| 780 | + throw new AcpProcessGroupTerminationError({ | |
| 781 | + detail: `ACP process ${input.rootPid} exited before its ownership ledger was captured`, | |
| 782 | + }); | |
| 783 | + } | |
| 784 | + const ownedRoot = { | |
| 785 | + ...rootIdentity, | |
| 786 | + parentExecutable: byPid.get(rootIdentity.ppid)?.executable, | |
| 787 | + parentStartTime: byPid.get(rootIdentity.ppid)?.startTime ?? "", | |
| 788 | + }; | |
| 789 | + input.ledger.set(processIdentityKey(rootIdentity), ownedRoot); | |
| 790 | + if (input.root !== undefined) input.root.value = ownedRoot; | |
| 791 | + } | |
| 792 | + // Drop non-root ledger entries whose PID is free or whose identity no longer | |
| 793 | + // matches the live process at that PID. Either case can block admission of a | |
| 794 | + // legitimate child after PID reuse. Keep the root reservation even if the | |
| 795 | + // root briefly disappears mid-teardown (finalizer retry still needs it). | |
| 796 | + for (const [key, owned] of [...input.ledger.entries()]) { | |
| 797 | + if (owned.pid === input.rootPid) continue; | |
| 798 | + const live = byPid.get(owned.pid); | |
| 799 | + if (live === undefined || !samePosixProcessIdentity(owned, live)) { | |
| 800 | + input.ledger.delete(key); | |
| 801 | + } | |
| 802 | + } | |
| 803 | + const children = new Map<number, Array<AcpPosixProcessIdentity>>(); | |
| 804 | + const retainedByPid = new Map( | |
| 805 | + [...input.ledger.values()] | |
| 806 | + .filter((owned) => samePosixProcessIdentity(owned, byPid.get(owned.pid))) | |
| 807 | + .map((owned) => [owned.pid, owned]), | |
| 808 | + ); | |
| 809 | + for (const process of table) { | |
| 810 | + const siblings = children.get(process.ppid) ?? []; | |
| 811 | + siblings.push(process); | |
| 812 | + children.set(process.ppid, siblings); | |
| 813 | + } | |
| 814 | + const pending = [...input.ledger.values()].filter((owned) => | |
| 815 | + samePosixProcessIdentity(owned, byPid.get(owned.pid)), | |
| 816 | + ); | |
| 817 | + const visited = new Set<number>(); | |
| 818 | + while (pending.length > 0) { | |
| 819 | + const parent = pending.shift(); | |
| 820 | + if (parent === undefined || visited.has(parent.pid)) continue; | |
| 821 | + visited.add(parent.pid); | |
| 822 | + const observedParent = byPid.get(parent.pid); | |
| 823 | + if (!samePosixProcessIdentity(parent, observedParent)) continue; | |
| 824 | + const refreshedParent = { ...parent, ...observedParent }; | |
| 825 | + input.ledger.set(processIdentityKey(parent), refreshedParent); | |
| 826 | + for (const process of children.get(refreshedParent.pid) ?? []) { | |
| 827 | + const reservedPid = retainedByPid.get(process.pid); | |
| 828 | + if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, process)) continue; | |
| 829 | + const owned = | |
| 830 | + reservedPid === undefined | |
| 831 | + ? { | |
| 832 | + ...process, | |
| 833 | + parentExecutable: refreshedParent.executable, | |
| 834 | + parentStartTime: refreshedParent.startTime, | |
| 835 | + } | |
| 836 | + : { ...reservedPid, ...process }; | |
| 837 | + input.ledger.set(processIdentityKey(process), owned); | |
| 838 | + retainedByPid.set(process.pid, owned); | |
| 839 | + pending.push(owned); | |
| 840 | + } | |
| 841 | + } | |
| 842 | + return table; | |
| 843 | +} | |
| 844 | + | |
| 845 | +export function terminatePosixOwnedProcessTree(input: { | |
| 846 | + readonly controller: AcpPosixProcessTreeController; | |
| 847 | + readonly discoveryPasses?: number; | |
| 848 | + readonly grace?: Duration.Input; | |
| 849 | + readonly ledger?: Map<string, AcpOwnedPosixProcess>; | |
| 850 | + readonly root?: AcpPosixOwnershipRoot; | |
| 851 | + readonly rootPid: number; | |
| 852 | +}): Effect.Effect<void, AcpProcessGroupTerminationError> { | |
| 853 | + // This portable fallback can only admit processes whose exact parent chain is | |
| 854 | + // visible before it breaks. A descendant that double-forks before discovery | |
| 855 | + // requires pre-exec cgroup containment for a hard ownership guarantee. | |
| 856 | + const ledger = input.ledger ?? new Map<string, AcpOwnedPosixProcess>(); | |
| 857 | + | |
| 858 | + const fail = (detail: string, cause?: unknown) => | |
| 859 | + new AcpProcessGroupTerminationError({ detail, ...(cause === undefined ? {} : { cause }) }); | |
| 860 | + const snapshot = () => { | |
| 861 | + try { | |
| 862 | + return input.controller.snapshot(); | |
| 863 | + } catch (cause) { | |
| 864 | + throw fail(`Failed to snapshot ACP process tree ${input.rootPid}`, cause); | |
| 865 | + } | |
| 866 | + }; | |
| 867 | + const discover = (table: ReadonlyArray<AcpPosixProcessIdentity>) => | |
| 868 | + capturePosixOwnershipLedger({ | |
| 869 | + controller: input.controller, | |
| 870 | + ledger, | |
| 871 | + ...(input.root === undefined ? {} : { root: input.root }), | |
| 872 | + rootPid: input.rootPid, | |
| 873 | + table, | |
| 874 | + }); | |
| 875 | + const signalPhase = (signal: NodeJS.Signals, includeRoot: boolean) => { | |
| 876 | + const table = snapshot(); | |
| 877 | + discover(table); | |
| 878 | + const byPid = new Map(table.map((entry) => [entry.pid, entry])); | |
| 879 | + const current = input.controller.identity(process.pid); | |
| 880 | + if (current === undefined) throw fail("Cannot identify the current T3 process group"); | |
| 881 | + const ledgerByPid = new Map( | |
| 882 | + [...ledger.values()].map((process) => [process.pid, process] as const), | |
| 883 | + ); | |
| 884 | + const pending = [...ledger.values()].filter( | |
| 885 | + (candidate) => | |
| 886 | + (includeRoot || candidate.pid !== input.rootPid) && | |
| 887 | + samePosixProcessIdentity(candidate, byPid.get(candidate.pid)), | |
| 888 | + ); | |
| 889 | + const retainedByPid = new Map( | |
| 890 | + [...ledger.values()] | |
| 891 | + .filter((owned) => samePosixProcessIdentity(owned, byPid.get(owned.pid))) | |
| 892 | + .map((owned) => [owned.pid, owned]), | |
| 893 | + ); | |
| 894 | + const signalled = new Set<number>(); | |
| 895 | + while (pending.length > 0) { | |
| 896 | + pending.sort( | |
| 897 | + (left, right) => processDepth(right, ledgerByPid) - processDepth(left, ledgerByPid), | |
| 898 | + ); | |
| 899 | + const candidate = pending.shift(); | |
| 900 | + if (candidate === undefined || signalled.has(candidate.pid)) continue; | |
| 901 | + const observed = input.controller.identity(candidate.pid); | |
| 902 | + if ( | |
| 903 | + candidate.pid <= 1 || | |
| 904 | + candidate.pid === process.pid || | |
| 905 | + observed === undefined || | |
| 906 | + observed.pgid === current.pgid || | |
| 907 | + observed.sid === current.sid || | |
| 908 | + !samePosixProcessIdentity(candidate, observed) | |
| 909 | + ) { | |
| 910 | + continue; | |
| 911 | + } | |
| 912 | + for (const child of input.controller.childrenOf(candidate.pid)) { | |
| 913 | + if (child.ppid !== candidate.pid) continue; | |
| 914 | + const reservedPid = retainedByPid.get(child.pid); | |
| 915 | + if (reservedPid !== undefined && !samePosixProcessIdentity(reservedPid, child)) continue; | |
| 916 | + const owned = | |
| 917 | + reservedPid === undefined | |
| 918 | + ? { | |
| 919 | + ...child, | |
| 920 | + parentExecutable: observed.executable, | |
| 921 | + parentStartTime: observed.startTime, | |
| 922 | + } | |
| 923 | + : { ...reservedPid, ...child }; | |
| 924 | + ledger.set(processIdentityKey(child), owned); | |
| 925 | + ledgerByPid.set(child.pid, owned); | |
| 926 | + retainedByPid.set(child.pid, owned); | |
| 927 | + // Signal newly discovered children in this same pass (fork-during-kill). | |
| 928 | + if ( | |
| 929 | + (includeRoot || child.pid !== input.rootPid) && | |
| 930 | + !signalled.has(child.pid) && | |
| 931 | + samePosixProcessIdentity(owned, input.controller.identity(child.pid)) | |
| 932 | + ) { | |
| 933 | + pending.push(owned); | |
| 934 | + } | |
| 935 | + } | |
| 936 | + try { | |
| 937 | + // Linux starttime prevents PID reuse across observations, but Node does | |
| 938 | + // not expose pidfd_send_signal. A final read-to-kill race remains. | |
| 939 | + input.controller.signalProcess(candidate.pid, signal); | |
| 940 | + signalled.add(candidate.pid); | |
| 941 | + } catch (cause) { | |
| 942 | + if (samePosixProcessIdentity(candidate, input.controller.identity(candidate.pid))) { | |
| 943 | + throw fail(`Failed to signal ACP process ${candidate.pid}`, cause); | |
| 944 | + } | |
| 945 | + } | |
| 946 | + } | |
| 947 | + }; | |
| 948 | + | |
| 949 | + return Effect.gen(function* () { | |
| 950 | + const passes = Math.max(2, Math.min(input.discoveryPasses ?? 4, 8)); | |
| 951 | + for (let pass = 0; pass < passes; pass += 1) { | |
| 952 | + yield* Effect.try({ | |
| 953 | + try: () => signalPhase("SIGTERM", false), | |
| 954 | + catch: (cause) => cause as AcpProcessGroupTerminationError, | |
| 955 | + }); | |
| 956 | + yield* Effect.sleep("10 millis"); | |
| 957 | + } | |
| 958 | + yield* Effect.try({ | |
| 959 | + try: () => signalPhase("SIGTERM", true), | |
| 960 | + catch: (cause) => cause as AcpProcessGroupTerminationError, | |
| 961 | + }); | |
| 962 | + const grace = input.grace ?? "1 second"; | |
| 963 | + if (grace !== 0) yield* Effect.sleep(grace); | |
| 964 | + for (let pass = 0; pass < passes; pass += 1) { | |
| 965 | + yield* Effect.try({ | |
| 966 | + try: () => signalPhase("SIGKILL", true), | |
| 967 | + catch: (cause) => cause as AcpProcessGroupTerminationError, | |
| 968 | + }); | |
| 969 | + yield* Effect.sleep("10 millis"); | |
| 970 | + } | |
| 971 | + const survivors = new Map(snapshot().map((entry) => [entry.pid, entry])); | |
| 972 | + const residual = [...ledger.values()].filter((owned) => { | |
| 973 | + const observed = survivors.get(owned.pid); | |
| 974 | + if (!samePosixProcessIdentity(owned, observed) || observed === undefined) { | |
| 975 | + return false; | |
| 976 | + } | |
| 977 | + // Already-killed zombies still appear in /proc until reaped; not live. | |
| 978 | + return !posixProcessIsZombie(observed); | |
| 979 | + }); | |
| 980 | + if (residual.length > 0) { | |
| 981 | + return yield* fail( | |
| 982 | + `ACP process tree ${input.rootPid} retained owned processes: ${residual.map((entry) => entry.pid).join(", ")}`, | |
| 983 | + ); | |
| 984 | + } | |
| 985 | + }).pipe(withWallClock); | |
| 986 | +} | |
| 987 | + | |
| 988 | +export function windowsTaskkillResultIsSuccess(exitCode: number, _output?: string): boolean { | |
| 989 | + return exitCode === 0; | |
| 990 | +} | |
| 991 | + | |
| 992 | +export const terminateWindowsProcessTreeWithTaskkill = ( | |
| 993 | + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], | |
| 994 | + pid: number, | |
| 995 | +): Effect.Effect<void, AcpProcessGroupTerminationError> => | |
| 996 | + Effect.scoped( | |
| 997 | + Effect.gen(function* () { | |
| 998 | + const taskkill = yield* spawner.spawn( | |
| 999 | + ChildProcess.make("taskkill", ["/PID", String(pid), "/T", "/F"]), | |
| 1000 | + ); | |
| 1001 | + const outputFiber = yield* Stream.decodeText(taskkill.all).pipe( | |
| 1002 | + Stream.mkString, | |
| 1003 | + Effect.forkScoped, | |
| 1004 | + ); | |
| 1005 | + const exitCode = Number(yield* taskkill.exitCode); | |
| 1006 | + const output = yield* Fiber.join(outputFiber); | |
| 1007 | + // A missing leader cannot prove that inherited descendants exited. | |
| 1008 | + if (windowsTaskkillResultIsSuccess(exitCode, output)) return; | |
| 1009 | + const trimmedOutput = output.trim(); | |
| 1010 | + return yield* new AcpProcessGroupTerminationError({ | |
| 1011 | + detail: `taskkill exited ${exitCode} for ACP process tree ${pid}`, | |
| 1012 | + pid, | |
| 1013 | + exitCode, | |
| 1014 | + ...(trimmedOutput.length === 0 | |
| 1015 | + ? {} | |
| 1016 | + : { cause: trimmedOutput.length > 500 ? trimmedOutput.slice(0, 500) : trimmedOutput }), | |
| 1017 | + }); | |
| 1018 | + }), | |
| 1019 | + ).pipe( | |
| 1020 | + Effect.mapError((cause) => | |
| 1021 | + isAcpProcessGroupTerminationError(cause) | |
| 1022 | + ? cause | |
| 1023 | + : new AcpProcessGroupTerminationError({ | |
| 1024 | + cause, | |
| 1025 | + detail: `Failed to run taskkill for ACP process tree ${pid}`, | |
| 1026 | + pid, | |
| 1027 | + }), | |
| 1028 | + ), | |
| 1029 | + ); | |
| 1030 | + | |
| 89 | 1031 | export function selectAcpAgentAuthMethod( |
| 90 | 1032 | authMethods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined, |
| 91 | 1033 | preferredMethodId?: string, |
| @@ -237,6 +1179,13 @@ export class AcpSessionRuntime extends Context.Service< · 145 unmodified lines | ||
| 237 | 1179 | * @see https://agentclientprotocol.com/protocol/schema#session/cancel |
| 238 | 1180 | */ |
| 239 | 1181 | readonly cancel: Effect.Effect<void, EffectAcpErrors.AcpError>; |
| 1182 | + readonly processContainment: | |
| 1183 | + | "cgroup-v2" | |
| 1184 | + | "process-ledger-reduced-guarantee" | |
| 1185 | + | "process-group" | |
| 1186 | + | "none"; | |
| 1187 | + /** Terminates the opt-in detached ACP process group and all inherited work. */ | |
| 1188 | + readonly terminateProcessGroup?: Effect.Effect<void, AcpProcessGroupTerminationError>; | |
| 240 | 1189 | /** |
| 241 | 1190 | * Selects the active mode through the negotiated `mode` configuration option. |
| 242 | 1191 | * This is a no-op when the requested mode is already active. |
| @@ -341,6 +1290,7 @@ export const make = ( · 98 unmodified lines | ||
| 341 | 1290 | const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); |
| 342 | 1291 | const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" }); |
| 343 | 1292 | const promptSerializationSemaphore = yield* Semaphore.make(1); |
| 1293 | + const sessionLoadSemaphore = yield* Semaphore.make(1); | |
| 344 | 1294 | const activePromptFiberRef = yield* Ref.make< |
| 345 | 1295 | Option.Option<Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>> |
| 346 | 1296 | >(Option.none()); |
| @@ -382,12 +1332,70 @@ export const make = ( · 35 unmodified lines | ||
| 382 | 1332 | options.spawn.args, |
| 383 | 1333 | options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}, |
| 384 | 1334 | ); |
| 1335 | + const linuxCgroupLease = | |
| 1336 | + options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux" | |
| 1337 | + ? yield* Effect.sync(() => { | |
| 1338 | + try { | |
| 1339 | + if (options.linuxCgroupController === null) return undefined; | |
| 1340 | + return (options.linuxCgroupController ?? makeLinuxCgroupController()).create(); | |
| 1341 | + } catch { | |
| 1342 | + return undefined; | |
| 1343 | + } | |
| 1344 | + }) | |
| 1345 | + : undefined; | |
| 1346 | + // This is lifecycle containment for inherited provider work. It is not a | |
| 1347 | + // security boundary against a provider that can modify its delegated cgroup. | |
| 1348 | + if (linuxCgroupLease !== undefined) { | |
| 1349 | + yield* Scope.addFinalizer( | |
| 1350 | + runtimeScope, | |
| 1351 | + Effect.uninterruptible(terminateLinuxCgroupLease(linuxCgroupLease)).pipe( | |
| 1352 | + Effect.ignoreCause({ log: true }), | |
| 1353 | + ), | |
| 1354 | + ); | |
| 1355 | + } | |
| 1356 | + const containedTargetCommand = | |
| 1357 | + linuxCgroupLease === undefined || NodePath.isAbsolute(spawnCommand.command) | |
| 1358 | + ? spawnCommand.command | |
| 1359 | + : yield* Effect.gen(function* () { | |
| 1360 | + const resolved = resolveLinuxCgroupTargetCommand( | |
| 1361 | + spawnCommand.command, | |
| 1362 | + options.spawn.cwd ?? options.cwd, | |
| 1363 | + { ...process.env, ...options.spawn.env }, | |
| 1364 | + ); | |
| 1365 | + if (resolved !== undefined) return resolved; | |
| 1366 | + return yield* new EffectAcpErrors.AcpSpawnError({ | |
| 1367 | + command: options.spawn.command, | |
| 1368 | + cause: new Error("Contained ACP command was not found on PATH"), | |
| 1369 | + }); | |
| 1370 | + }); | |
| 1371 | + const containedSpawnCommand = | |
| 1372 | + linuxCgroupLease === undefined | |
| 1373 | + ? spawnCommand | |
| 1374 | + : { | |
| 1375 | + ...wrapCommandForLinuxCgroup( | |
| 1376 | + linuxCgroupLease, | |
| 1377 | + containedTargetCommand, | |
| 1378 | + spawnCommand.args, | |
| 1379 | + ), | |
| 1380 | + shell: false, | |
| 1381 | + }; | |
| 1382 | + const spawnEnvironment = | |
| 1383 | + linuxCgroupLease === undefined | |
| 1384 | + ? options.spawn.env | |
| 1385 | + : { | |
| 1386 | + ...options.spawn.env, | |
| 1387 | + ELECTRON_RUN_AS_NODE: "1", | |
| 1388 | + T3_ACP_CGROUP_WRAPPER: "1", | |
| 1389 | + }; | |
| 385 | 1390 | const child = yield* spawner |
| 386 | 1391 | .spawn( |
| 387 | − ChildProcess.make(spawnCommand.command, spawnCommand.args, { | |
| 1392 | + ChildProcess.make(containedSpawnCommand.command, containedSpawnCommand.args, { | |
| 388 | 1393 | ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), |
| 389 | − ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), | |
| 390 | − shell: spawnCommand.shell, | |
| 1394 | + ...(spawnEnvironment ? { env: spawnEnvironment, extendEnv: true } : {}), | |
| 1395 | + ...(options.ownDetachedProcessGroup === undefined | |
| 1396 | + ? {} | |
| 1397 | + : { detached: options.ownDetachedProcessGroup }), | |
| 1398 | + shell: containedSpawnCommand.shell, | |
| 391 | 1399 | }), |
| 392 | 1400 | ) |
| 393 | 1401 | .pipe( |
| @@ -401,6 +1409,155 @@ export const make = ( · 7 unmodified lines | ||
| 401 | 1409 | ), |
| 402 | 1410 | ); |
| 403 | 1411 | |
| 1412 | + const posixOwnershipLedger = new Map<string, AcpOwnedPosixProcess>(); | |
| 1413 | + const posixOwnershipFrontier = new Map<number, AcpOwnedPosixProcess>(); | |
| 1414 | + const posixOwnershipChildQueues = new Map<number, Array<number>>(); | |
| 1415 | + const posixOwnershipRoot: AcpPosixOwnershipRoot = { value: undefined }; | |
| 1416 | + const posixController = | |
| 1417 | + options.ownDescendantProcessGroups === true | |
| 1418 | + ? yield* Effect.try({ | |
| 1419 | + try: () => { | |
| 1420 | + if (options.posixProcessTreeController !== undefined) { | |
| 1421 | + return options.posixProcessTreeController; | |
| 1422 | + } | |
| 1423 | + if (options.processGroupPlatform === undefined) { | |
| 1424 | + throw new AcpProcessGroupTerminationError({ | |
| 1425 | + detail: "POSIX ACP descendant ownership requires a supported host platform", | |
| 1426 | + }); | |
| 1427 | + } | |
| 1428 | + return makePosixProcessTreeController(options.processGroupPlatform); | |
| 1429 | + }, | |
| 1430 | + catch: (cause) => | |
| 1431 | + new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), | |
| 1432 | + }) | |
| 1433 | + : undefined; | |
| 1434 | + if (posixController !== undefined) { | |
| 1435 | + const observeOwnership = () => | |
| 1436 | + observePosixOwnershipLedger({ | |
| 1437 | + childQueues: posixOwnershipChildQueues, | |
| 1438 | + controller: posixController, | |
| 1439 | + frontier: posixOwnershipFrontier, | |
| 1440 | + ledger: posixOwnershipLedger, | |
| 1441 | + root: posixOwnershipRoot, | |
| 1442 | + rootPid: Number(child.pid), | |
| 1443 | + }); | |
| 1444 | + yield* Effect.try({ | |
| 1445 | + try: observeOwnership, | |
| 1446 | + catch: (cause) => | |
| 1447 | + new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), | |
| 1448 | + }); | |
| 1449 | + yield* observePosixOwnershipLedgerContinuously({ | |
| 1450 | + childQueues: posixOwnershipChildQueues, | |
| 1451 | + controller: posixController, | |
| 1452 | + frontier: posixOwnershipFrontier, | |
| 1453 | + ledger: posixOwnershipLedger, | |
| 1454 | + root: posixOwnershipRoot, | |
| 1455 | + rootPid: Number(child.pid), | |
| 1456 | + }).pipe(Effect.forkIn(runtimeScope)); | |
| 1457 | + } | |
| 1458 | + | |
| 1459 | + const signalOwnedProcessGroup = (signal: NodeJS.Signals) => | |
| 1460 | + Effect.try({ | |
| 1461 | + try: () => { | |
| 1462 | + process.kill(-Number(child.pid), signal); | |
| 1463 | + return true; | |
| 1464 | + }, | |
| 1465 | + catch: (cause) => | |
| 1466 | + new AcpProcessGroupTerminationError({ | |
| 1467 | + cause, | |
| 1468 | + detail: `Failed to signal ACP process group ${child.pid} with ${signal}`, | |
| 1469 | + }), | |
| 1470 | + }).pipe( | |
| 1471 | + Effect.catch((error) => { | |
| 1472 | + const cause = error.cause as NodeJS.ErrnoException | undefined; | |
| 1473 | + return cause?.code === "ESRCH" ? Effect.succeed(false) : Effect.fail(error); | |
| 1474 | + }), | |
| 1475 | + ); | |
| 1476 | + const terminateWindowsProcessTree = | |
| 1477 | + options.windowsProcessTreeTerminator ?? | |
| 1478 | + ((pid: number) => terminateWindowsProcessTreeWithTaskkill(spawner, pid)); | |
| 1479 | + const terminatePosixProcessTree = (grace: Duration.Input, platformOverride?: NodeJS.Platform) => | |
| 1480 | + Effect.gen(function* () { | |
| 1481 | + const platform = platformOverride ?? options.processGroupPlatform; | |
| 1482 | + if (platform === undefined || platform === "win32") { | |
| 1483 | + return yield* new AcpProcessGroupTerminationError({ | |
| 1484 | + detail: "POSIX ACP descendant ownership requires a supported host platform", | |
| 1485 | + }); | |
| 1486 | + } | |
| 1487 | + const controller = yield* Effect.try({ | |
| 1488 | + try: () => posixController ?? makePosixProcessTreeController(platform), | |
| 1489 | + catch: (cause) => | |
| 1490 | + isAcpProcessGroupTerminationError(cause) | |
| 1491 | + ? cause | |
| 1492 | + : new AcpProcessGroupTerminationError({ | |
| 1493 | + cause, | |
| 1494 | + detail: `Failed to initialize ACP descendant ownership on ${platform}`, | |
| 1495 | + }), | |
| 1496 | + }); | |
| 1497 | + yield* terminatePosixOwnedProcessTree({ | |
| 1498 | + controller, | |
| 1499 | + grace, | |
| 1500 | + ledger: posixOwnershipLedger, | |
| 1501 | + root: posixOwnershipRoot, | |
| 1502 | + rootPid: Number(child.pid), | |
| 1503 | + }); | |
| 1504 | + }); | |
| 1505 | + const terminateOwnedProcessGroupImpl = Effect.gen(function* () { | |
| 1506 | + if (options.ownDetachedProcessGroup !== true) return; | |
| 1507 | + if (options.processGroupPlatform === undefined) { | |
| 1508 | + return yield* new AcpProcessGroupTerminationError({ | |
| 1509 | + detail: "Detached ACP process-group ownership requires a host platform", | |
| 1510 | + }); | |
| 1511 | + } | |
| 1512 | + if (options.processGroupPlatform === "win32") { | |
| 1513 | + return yield* terminateWindowsProcessTree(Number(child.pid)); | |
| 1514 | + } | |
| 1515 | + if (linuxCgroupLease !== undefined) { | |
| 1516 | + if (posixController !== undefined) { | |
| 1517 | + yield* Effect.sync(() => | |
| 1518 | + signalLinuxCgroupRootTerm({ | |
| 1519 | + controller: posixController, | |
| 1520 | + lease: linuxCgroupLease, | |
| 1521 | + root: posixOwnershipRoot, | |
| 1522 | + }), | |
| 1523 | + ); | |
| 1524 | + } | |
| 1525 | + const grace = options.processGroupTerminationGrace ?? "1 second"; | |
| 1526 | + if (grace !== 0) yield* Effect.sleep(grace); | |
| 1527 | + return yield* terminateLinuxCgroupLease(linuxCgroupLease); | |
| 1528 | + } | |
| 1529 | + if (options.ownDescendantProcessGroups === true) { | |
| 1530 | + return yield* terminatePosixProcessTree(options.processGroupTerminationGrace ?? "1 second"); | |
| 1531 | + } | |
| 1532 | + const groupExisted = yield* signalOwnedProcessGroup("SIGTERM"); | |
| 1533 | + if (!groupExisted) return; | |
| 1534 | + const grace = options.processGroupTerminationGrace ?? "1 second"; | |
| 1535 | + if (grace !== 0) { | |
| 1536 | + yield* Effect.sleep(grace); | |
| 1537 | + } | |
| 1538 | + yield* signalOwnedProcessGroup("SIGKILL"); | |
| 1539 | + }).pipe(withWallClock); | |
| 1540 | + const terminateProcessGroup = yield* Effect.cached( | |
| 1541 | + Effect.uninterruptible(terminateOwnedProcessGroupImpl), | |
| 1542 | + ); | |
| 1543 | + if (options.ownDetachedProcessGroup === true) { | |
| 1544 | + const hostPlatform = yield* HostProcessPlatform; | |
| 1545 | + const forceTerminateOwnedProcessGroup = | |
| 1546 | + hostPlatform === "win32" | |
| 1547 | + ? terminateWindowsProcessTreeWithTaskkill(spawner, Number(child.pid)) | |
| 1548 | + : linuxCgroupLease !== undefined | |
| 1549 | + ? terminateLinuxCgroupLease(linuxCgroupLease) | |
| 1550 | + : options.ownDescendantProcessGroups === true | |
| 1551 | + ? terminatePosixProcessTree(0, hostPlatform) | |
| 1552 | + : signalOwnedProcessGroup("SIGKILL").pipe(Effect.asVoid); | |
| 1553 | + yield* Scope.addFinalizer( | |
| 1554 | + runtimeScope, | |
| 1555 | + Effect.uninterruptible(forceTerminateOwnedProcessGroup).pipe( | |
| 1556 | + Effect.ignoreCause({ log: true }), | |
| 1557 | + ), | |
| 1558 | + ); | |
| 1559 | + } | |
| 1560 | + | |
| 404 | 1561 | const acpContext = yield* Layer.build( |
| 405 | 1562 | EffectAcpClient.layerChildProcess(child, { |
| 406 | 1563 | ...(options.protocolLogging?.logIncoming !== undefined |
| @@ -410,6 +1567,17 @@ export const make = ( · 3 unmodified lines | ||
| 410 | 1567 | ? { logOutgoing: options.protocolLogging.logOutgoing } |
| 411 | 1568 | : {}), |
| 412 | 1569 | ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}), |
| 1570 | + ...(options.onIncomingRequest ? { onIncomingRequest: options.onIncomingRequest } : {}), | |
| 1571 | + onTermination: (error) => | |
| 1572 | + (options.onTermination?.(error) ?? Effect.void).pipe( | |
| 1573 | + Effect.ensuring( | |
| 1574 | + Scope.close(runtimeScope, Exit.fail(error)).pipe(Effect.forkDetach, Effect.asVoid), | |
| 1575 | + ), | |
| 1576 | + ), | |
| 1577 | + ...(options.onOutgoingResponseFailure | |
| 1578 | + ? { onOutgoingResponseFailure: options.onOutgoingResponseFailure } | |
| 1579 | + : {}), | |
| 1580 | + ...(options.onOutgoingResponse ? { onOutgoingResponse: options.onOutgoingResponse } : {}), | |
| 413 | 1581 | }), |
| 414 | 1582 | ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); |
| 415 | 1583 | |
| @@ -419,14 +1587,16 @@ export const make = ( · 3 unmodified lines | ||
| 419 | 1587 | Effect.gen(function* () { |
| 420 | 1588 | const gate = yield* Ref.get(sessionLoadGateRef); |
| 421 | 1589 | if (Option.isSome(gate) && gate.value.active) { |
| 422 | − const lastActivityAtMillis = yield* Clock.currentTimeMillis; | |
| 423 | − yield* Ref.set( | |
| 424 | − sessionLoadGateRef, | |
| 425 | − Option.some({ | |
| 426 | − ...gate.value, | |
| 427 | − lastActivityAtMillis, | |
| 428 | − }), | |
| 429 | − ); | |
| 1590 | + if (sessionUpdateCountsAsLoadReplayActivity(notification, gate.value.sessionId)) { | |
| 1591 | + const lastActivityAtMillis = yield* Clock.currentTimeMillis; | |
| 1592 | + yield* Ref.set( | |
| 1593 | + sessionLoadGateRef, | |
| 1594 | + Option.some({ | |
| 1595 | + ...gate.value, | |
| 1596 | + lastActivityAtMillis, | |
| 1597 | + }), | |
| 1598 | + ); | |
| 1599 | + } | |
| 430 | 1600 | return; |
| 431 | 1601 | } |
| 432 | 1602 | if (sessionUpdateIsReplay(notification)) { |
| @@ -550,20 +1720,121 @@ export const make = ( · 117 unmodified lines | ||
| 550 | 1720 | ): Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError> => |
| 551 | 1721 | Effect.gen(function* () { |
| 552 | 1722 | const current = yield* getStartedState; |
| 1723 | + const meta = sessionSetupResult._meta; | |
| 1724 | + const syntheticReplayIdle = | |
| 1725 | + meta !== null && | |
| 1726 | + typeof meta === "object" && | |
| 1727 | + !Array.isArray(meta) && | |
| 1728 | + (meta as { readonly t3SessionLoadReady?: unknown }).t3SessionLoadReady === "replay_idle"; | |
| 1729 | + const extractedModelConfigId = extractModelConfigId(sessionSetupResult); | |
| 1730 | + const nextModelConfigId = | |
| 1731 | + extractedModelConfigId ?? (syntheticReplayIdle ? current.modelConfigId : undefined); | |
| 1732 | + const nextModeState = parseSessionModeState(sessionSetupResult); | |
| 1733 | + if (nextModeState !== undefined) { | |
| 1734 | + yield* Ref.set(modeStateRef, nextModeState); | |
| 1735 | + } else if (!syntheticReplayIdle) { | |
| 1736 | + yield* Ref.set(modeStateRef, undefined); | |
| 1737 | + } | |
| 1738 | + // Synthetic replay-idle load responses only carry initialize model/mode | |
| 1739 | + // meta; preserve live configOptions so setConfigOption still validates. | |
| 1740 | + if ( | |
| 1741 | + sessionSetupResult.configOptions !== undefined && | |
| 1742 | + sessionSetupResult.configOptions !== null | |
| 1743 | + ) { | |
| 1744 | + yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); | |
| 1745 | + } else if (!syntheticReplayIdle) { | |
| 1746 | + yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); | |
| 1747 | + } | |
| 553 | 1748 | const nextState = { |
| 554 | 1749 | sessionId, |
| 555 | 1750 | initializeResult: current.initializeResult, |
| 556 | 1751 | sessionSetupResult, |
| 557 | − modelConfigId: extractModelConfigId(sessionSetupResult), | |
| 1752 | + modelConfigId: nextModelConfigId, | |
| 558 | 1753 | } satisfies AcpStartedState; |
| 559 | − yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); | |
| 560 | − yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); | |
| 561 | 1754 | yield* Ref.set(toolCallsRef, new Map()); |
| 562 | 1755 | yield* Ref.set(assistantSegmentRef, { nextSegmentIndex: 0 }); |
| 563 | 1756 | yield* Ref.set(startStateRef, { _tag: "Started", result: nextState }); |
| 564 | 1757 | return nextState; |
| 565 | 1758 | }); |
| 566 | 1759 | |
| 1760 | + const runLoadSessionWithReplayIdle = ( | |
| 1761 | + loadPayload: EffectAcpSchema.LoadSessionRequest, | |
| 1762 | + initializeResult: EffectAcpSchema.InitializeResponse, | |
| 1763 | + ): Effect.Effect<EffectAcpSchema.LoadSessionResponse, EffectAcpErrors.AcpError> => | |
| 1764 | + sessionLoadSemaphore.withPermits(1)( | |
| 1765 | + Effect.gen(function* () { | |
| 1766 | + const sessionLoadTimeout = Duration.fromInputUnsafe( | |
| 1767 | + options.sessionLoadTimeout ?? defaultSessionLoadTimeout, | |
| 1768 | + ); | |
| 1769 | + const sessionLoadReplayIdleGap = Duration.fromInputUnsafe( | |
| 1770 | + options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap, | |
| 1771 | + ); | |
| 1772 | + | |
| 1773 | + yield* Ref.set( | |
| 1774 | + sessionLoadGateRef, | |
| 1775 | + Option.some({ | |
| 1776 | + active: true, | |
| 1777 | + sessionId: loadPayload.sessionId, | |
| 1778 | + lastActivityAtMillis: undefined, | |
| 1779 | + idleGap: sessionLoadReplayIdleGap, | |
| 1780 | + initializeResult, | |
| 1781 | + }), | |
| 1782 | + ); | |
| 1783 | + | |
| 1784 | + return yield* Effect.gen(function* () { | |
| 1785 | + yield* logRequest({ | |
| 1786 | + method: "session/load", | |
| 1787 | + payload: loadPayload, | |
| 1788 | + status: "started", | |
| 1789 | + }); | |
| 1790 | + | |
| 1791 | + const idleFiber = yield* waitForSessionLoadReplayIdle({ | |
| 1792 | + gateRef: sessionLoadGateRef, | |
| 1793 | + }).pipe(Effect.forkIn(runtimeScope)); | |
| 1794 | + const loaded = yield* Effect.raceFirst( | |
| 1795 | + acp.agent.loadSession(loadPayload), | |
| 1796 | + Fiber.join(idleFiber), | |
| 1797 | + ).pipe( | |
| 1798 | + Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), | |
| 1799 | + Effect.timeoutOption(sessionLoadTimeout), | |
| 1800 | + Effect.flatMap((result) => | |
| 1801 | + Option.match(result, { | |
| 1802 | + onNone: () => | |
| 1803 | + Effect.fail( | |
| 1804 | + new EffectAcpErrors.AcpTransportError({ | |
| 1805 | + operation: "call-rpc", | |
| 1806 | + method: "session/load", | |
| 1807 | + detail: | |
| 1808 | + "session/load timed out waiting for RPC response or replay idle gap", | |
| 1809 | + cause: undefined, | |
| 1810 | + }), | |
| 1811 | + ), | |
| 1812 | + onSome: Effect.succeed, | |
| 1813 | + }), | |
| 1814 | + ), | |
| 1815 | + Effect.tap((result) => | |
| 1816 | + logRequest({ | |
| 1817 | + method: "session/load", | |
| 1818 | + payload: loadPayload, | |
| 1819 | + status: "succeeded", | |
| 1820 | + result, | |
| 1821 | + }), | |
| 1822 | + ), | |
| 1823 | + Effect.onError((cause) => | |
| 1824 | + logRequest({ | |
| 1825 | + method: "session/load", | |
| 1826 | + payload: loadPayload, | |
| 1827 | + status: "failed", | |
| 1828 | + cause, | |
| 1829 | + }), | |
| 1830 | + ), | |
| 1831 | + ); | |
| 1832 | + | |
| 1833 | + return loaded; | |
| 1834 | + }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none()))); | |
| 1835 | + }), | |
| 1836 | + ); | |
| 1837 | + | |
| 567 | 1838 | const setConfigOption = ( |
| 568 | 1839 | configId: string, |
| 569 | 1840 | value: string | boolean, |
| @@ -669,27 +1940,9 @@ export const make = ( · 99 unmodified lines | ||
| 669 | 1940 | cwd: options.cwd, |
| 670 | 1941 | mcpServers: options.mcpServers ?? [], |
| 671 | 1942 | } satisfies EffectAcpSchema.LoadSessionRequest; |
| 672 | − const resumed = yield* runLoggedRequest( | |
| 673 | − "session/load", | |
| 674 | − loadPayload, | |
| 675 | − acp.agent.loadSession(loadPayload), | |
| 676 | − ).pipe(Effect.exit); | |
| 677 | − if (Exit.isSuccess(resumed)) { | |
| 678 | − sessionId = options.resumeSessionId; | |
| 679 | − sessionSetupResult = resumed.value; | |
| 680 | − } else { | |
| 681 | − const createPayload = { | |
| 682 | − cwd: options.cwd, | |
| 683 | − mcpServers: options.mcpServers ?? [], | |
| 684 | − } satisfies EffectAcpSchema.NewSessionRequest; | |
| 685 | − const created = yield* runLoggedRequest( | |
| 686 | − "session/new", | |
| 687 | − createPayload, | |
| 688 | − acp.agent.createSession(createPayload), | |
| 689 | − ); | |
| 690 | − sessionId = created.sessionId; | |
| 691 | − sessionSetupResult = created; | |
| 692 | − } | |
| 1943 | + | |
| 1944 | + sessionId = options.resumeSessionId; | |
| 1945 | + sessionSetupResult = yield* runLoadSessionWithReplayIdle(loadPayload, initializeResult); | |
| 693 | 1946 | } else { |
| 694 | 1947 | const createPayload = { |
| 695 | 1948 | cwd: options.cwd, |
| @@ -760,6 +2013,14 @@ export const make = ( · 64 unmodified lines | ||
| 760 | 2013 | }); |
| 761 | 2014 | |
| 762 | 2015 | return { |
| 2016 | + processContainment: | |
| 2017 | + linuxCgroupLease !== undefined | |
| 2018 | + ? "cgroup-v2" | |
| 2019 | + : options.ownDescendantProcessGroups === true | |
| 2020 | + ? "process-ledger-reduced-guarantee" | |
| 2021 | + : options.ownDetachedProcessGroup === true | |
| 2022 | + ? "process-group" | |
| 2023 | + : "none", | |
| 763 | 2024 | handleRequestPermission: acp.handleRequestPermission, |
| 764 | 2025 | handleElicitation: acp.handleElicitation, |
| 765 | 2026 | handleReadTextFile: acp.handleReadTextFile, |
| @@ -782,17 +2043,13 @@ export const make = ( · 16 unmodified lines | ||
| 782 | 2043 | getConfigOptions: Ref.get(configOptionsRef), |
| 783 | 2044 | loadSession: (sessionId, activationOptions) => |
| 784 | 2045 | start.pipe( |
| 785 | − Effect.flatMap(() => { | |
| 2046 | + Effect.flatMap((started) => { | |
| 786 | 2047 | const requestPayload = { |
| 787 | 2048 | sessionId, |
| 788 | 2049 | cwd: options.cwd, |
| 789 | 2050 | mcpServers: activationOptions?.mcpServers ?? options.mcpServers ?? [], |
| 790 | 2051 | } satisfies EffectAcpSchema.LoadSessionRequest; |
| 791 | − return runLoggedRequest( | |
| 792 | − "session/load", | |
| 793 | − requestPayload, | |
| 794 | − acp.agent.loadSession(requestPayload), | |
| 795 | − ); | |
| 2052 | + return runLoadSessionWithReplayIdle(requestPayload, started.initializeResult); | |
| 796 | 2053 | }), |
| 797 | 2054 | Effect.flatMap((response) => adoptSession(sessionId, response)), |
| 798 | 2055 | ), |
| @@ -914,6 +2171,7 @@ export const make = ( · 115 unmodified lines | ||
| 914 | 2171 | }), |
| 915 | 2172 | ), |
| 916 | 2173 | ), |
| 2174 | + ...(options.ownDetachedProcessGroup === true ? { terminateProcessGroup } : {}), | |
| 917 | 2175 | setMode: (modeId) => |
| 918 | 2176 | Ref.get(modeStateRef).pipe( |
| 919 | 2177 | Effect.flatMap((modeState) => { |
| @@ -5,11 +5,33 @@ import * as EffectAcpErrors from "effect-acp/errors"; · 4 unmodified lines | ||
| 5 | 5 | import { |
| 6 | 6 | applyGrokAcpModelSelection, |
| 7 | 7 | buildGrokAcpSpawnInput, |
| 8 | − grokAcpSpawnArgs, | |
| 9 | − isValidGrokReasoningEffortToken, | |
| 8 | + grokAcpRuntimeProcessOwnership, | |
| 10 | 9 | resolveGrokAcpBaseModelId, |
| 11 | 10 | } from "./GrokAcpSupport.ts"; |
| 12 | 11 | |
| 12 | +describe("grokAcpRuntimeProcessOwnership", () => { | |
| 13 | + it("opts Grok into detached process-tree ownership on the injected host platform", () => { | |
| 14 | + expect(grokAcpRuntimeProcessOwnership("linux")).toEqual({ | |
| 15 | + ownDescendantProcessGroups: true, | |
| 16 | + ownDetachedProcessGroup: true, | |
| 17 | + processGroupPlatform: "linux", | |
| 18 | + }); | |
| 19 | + }); | |
| 20 | + | |
| 21 | + it("uses the prior provider-group path on Darwin and Windows", () => { | |
| 22 | + expect(grokAcpRuntimeProcessOwnership("darwin")).toEqual({ | |
| 23 | + ownDescendantProcessGroups: false, | |
| 24 | + ownDetachedProcessGroup: true, | |
| 25 | + processGroupPlatform: "darwin", | |
| 26 | + }); | |
| 27 | + expect(grokAcpRuntimeProcessOwnership("win32")).toEqual({ | |
| 28 | + ownDescendantProcessGroups: false, | |
| 29 | + ownDetachedProcessGroup: true, | |
| 30 | + processGroupPlatform: "win32", | |
| 31 | + }); | |
| 32 | + }); | |
| 33 | +}); | |
| 34 | + | |
| 13 | 35 | describe("resolveGrokAcpBaseModelId", () => { |
| 14 | 36 | it("normalizes empty and custom Grok model ids", () => { |
| 15 | 37 | expect(resolveGrokAcpBaseModelId(undefined)).toBe("grok-build"); |
| @@ -18,35 +40,6 @@ describe("resolveGrokAcpBaseModelId", () => { · 2 unmodified lines | ||
| 18 | 40 | }); |
| 19 | 41 | }); |
| 20 | 42 | |
| 21 | −describe("grokAcpSpawnArgs", () => { | |
| 22 | − it("inherits the Grok CLI config when no T3 runtime mode is set", () => { | |
| 23 | − expect(grokAcpSpawnArgs()).toEqual(["agent", "stdio"]); | |
| 24 | − }); | |
| 25 | − | |
| 26 | − it("forces Grok to ask when T3 is Supervised", () => { | |
| 27 | − expect(grokAcpSpawnArgs("approval-required")).toEqual([ | |
| 28 | − "--permission-mode", | |
| 29 | − "default", | |
| 30 | − "agent", | |
| 31 | − "stdio", | |
| 32 | − ]); | |
| 33 | − }); | |
| 34 | − | |
| 35 | − it("maps Full access to Grok always-approve", () => { | |
| 36 | − expect(grokAcpSpawnArgs("full-access")).toEqual(["agent", "--always-approve", "stdio"]); | |
| 37 | − }); | |
| 38 | − | |
| 39 | − it("maps Auto-accept edits and Auto onto Grok permission modes", () => { | |
| 40 | − expect(grokAcpSpawnArgs("auto-accept-edits")).toEqual([ | |
| 41 | − "--permission-mode", | |
| 42 | − "acceptEdits", | |
| 43 | − "agent", | |
| 44 | − "stdio", | |
| 45 | − ]); | |
| 46 | − expect(grokAcpSpawnArgs("auto")).toEqual(["--permission-mode", "auto", "agent", "stdio"]); | |
| 47 | − }); | |
| 48 | −}); | |
| 49 | − | |
| 50 | 43 | describe("buildGrokAcpSpawnInput", () => { |
| 51 | 44 | it("passes the T3 Code referrer through Grok OAuth env", () => { |
| 52 | 45 | const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", { |
| @@ -64,38 +57,15 @@ describe("buildGrokAcpSpawnInput", () => { · 11 unmodified lines | ||
| 64 | 57 | }, |
| 65 | 58 | }); |
| 66 | 59 | }); |
| 67 | − | |
| 68 | − it("puts Supervised on the Grok argv so config always-approve cannot win", () => { | |
| 69 | − const spawn = buildGrokAcpSpawnInput( | |
| 70 | − { binaryPath: "/usr/local/bin/grok" }, | |
| 71 | − "/tmp/project", | |
| 72 | − undefined, | |
| 73 | − "approval-required", | |
| 74 | − ); | |
| 75 | − expect(spawn.args).toEqual(["--permission-mode", "default", "agent", "stdio"]); | |
| 76 | − }); | |
| 77 | −}); | |
| 78 | − | |
| 79 | −describe("isValidGrokReasoningEffortToken", () => { | |
| 80 | − it("accepts future ACP tokens and rejects malformed metadata values", () => { | |
| 81 | − expect(isValidGrokReasoningEffortToken("xhigh")).toBe(true); | |
| 82 | − expect(isValidGrokReasoningEffortToken("turbo_v2")).toBe(true); | |
| 83 | − expect(isValidGrokReasoningEffortToken("not a token")).toBe(false); | |
| 84 | − expect(isValidGrokReasoningEffortToken("-leading-dash")).toBe(false); | |
| 85 | − expect(isValidGrokReasoningEffortToken("x".repeat(33))).toBe(false); | |
| 86 | − }); | |
| 87 | 60 | }); |
| 88 | 61 | |
| 89 | 62 | describe("applyGrokAcpModelSelection", () => { |
| 90 | 63 | const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { |
| 91 | − const modelCalls: Array<{ | |
| 92 | − modelId: string; | |
| 93 | − meta?: { readonly [key: string]: unknown } | null; | |
| 94 | − }> = []; | |
| 64 | + const modelCalls: Array<string> = []; | |
| 95 | 65 | const runtime = { |
| 96 | − setSessionModel: (modelId: string, meta?: { readonly [key: string]: unknown } | null) => | |
| 66 | + setSessionModel: (modelId: string) => | |
| 97 | 67 | Effect.gen(function* () { |
| 98 | − modelCalls.push(meta === undefined ? { modelId } : { modelId, meta }); | |
| 68 | + modelCalls.push(modelId); | |
| 99 | 69 | if (failure) return yield* failure; |
| 100 | 70 | return {}; |
| 101 | 71 | }), |
| @@ -112,58 +82,11 @@ describe("applyGrokAcpModelSelection", () => { · 10 unmodified lines | ||
| 112 | 82 | requestedModelId: "grok-mock-alt", |
| 113 | 83 | mapError: (cause) => cause.message, |
| 114 | 84 | }); |
| 115 | − expect(modelCalls).toEqual([{ modelId: "grok-mock-alt" }]); | |
| 85 | + expect(modelCalls).toEqual(["grok-mock-alt"]); | |
| 116 | 86 | expect(result).toBe("grok-mock-alt"); |
| 117 | 87 | }), |
| 118 | 88 | ); |
| 119 | 89 | |
| 120 | − it.effect("applies reasoning effort through session/set_model metadata", () => | |
| 121 | − Effect.gen(function* () { | |
| 122 | − const { runtime, modelCalls } = makeRecordingRuntime(); | |
| 123 | − const result = yield* applyGrokAcpModelSelection({ | |
| 124 | − runtime, | |
| 125 | − currentModelId: "grok-4.6", | |
| 126 | − currentReasoningEffort: "high", | |
| 127 | − requestedModelId: "grok-4.6", | |
| 128 | − requestedReasoningEffort: "xhigh", | |
| 129 | − mapError: (cause) => cause.message, | |
| 130 | − }); | |
| 131 | − expect(modelCalls).toEqual([{ modelId: "grok-4.6", meta: { reasoningEffort: "xhigh" } }]); | |
| 132 | − expect(result).toBe("grok-4.6"); | |
| 133 | − }), | |
| 134 | − ); | |
| 135 | − | |
| 136 | − it.effect("does not clear reasoning when same-model selection omits effort", () => | |
| 137 | − Effect.gen(function* () { | |
| 138 | − const { runtime, modelCalls } = makeRecordingRuntime(); | |
| 139 | − const result = yield* applyGrokAcpModelSelection({ | |
| 140 | − runtime, | |
| 141 | − currentModelId: "grok-4.6", | |
| 142 | − currentReasoningEffort: "high", | |
| 143 | − requestedModelId: "grok-4.6", | |
| 144 | − requestedReasoningEffort: undefined, | |
| 145 | − mapError: (cause) => cause.message, | |
| 146 | − }); | |
| 147 | − expect(modelCalls).toEqual([]); | |
| 148 | − expect(result).toBe("grok-4.6"); | |
| 149 | − }), | |
| 150 | − ); | |
| 151 | − | |
| 152 | − it.effect("drops malformed effort metadata instead of sending it", () => | |
| 153 | − Effect.gen(function* () { | |
| 154 | − const { runtime, modelCalls } = makeRecordingRuntime(); | |
| 155 | − yield* applyGrokAcpModelSelection({ | |
| 156 | − runtime, | |
| 157 | − currentModelId: "grok-4.6", | |
| 158 | − currentReasoningEffort: "high", | |
| 159 | − requestedModelId: "grok-4.6", | |
| 160 | − requestedReasoningEffort: "not a token", | |
| 161 | − mapError: (cause) => cause.message, | |
| 162 | − }); | |
| 163 | − expect(modelCalls).toEqual([{ modelId: "grok-4.6" }]); | |
| 164 | − }), | |
| 165 | − ); | |
| 166 | − | |
| 167 | 90 | it.effect("skips set_model when requested matches current", () => |
| 168 | 91 | Effect.gen(function* () { |
| 169 | 92 | const { runtime, modelCalls } = makeRecordingRuntime(); |
| @@ -1,4 +1,5 @@ | ||
| 1 | −import { type GrokSettings, ProviderDriverKind, type RuntimeMode } from "@t3tools/contracts"; | |
| 1 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | +import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; | |
| 2 | 3 | import * as Crypto from "effect/Crypto"; |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| 4 | 5 | import * as Layer from "effect/Layer"; |
| @@ -6,6 +7,7 @@ import * as Scope from "effect/Scope"; · 1 unmodified line | ||
| 6 | 7 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 7 | 8 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 8 | 9 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 10 | +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 9 | 11 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 10 | 12 | |
| 11 | 13 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| @@ -24,33 +26,16 @@ interface GrokAcpRuntimeInput extends Omit<AcpSessionRuntimeOptions, "authMethod · 12 unmodified lines | ||
| 24 | 26 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; |
| 25 | 27 | readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined; |
| 26 | 28 | readonly environment?: NodeJS.ProcessEnv; |
| 27 | − readonly runtimeMode?: RuntimeMode; | |
| 28 | −} | |
| 29 | − | |
| 30 | −export function grokAcpSpawnArgs(runtimeMode?: RuntimeMode): ReadonlyArray<string> { | |
| 31 | − switch (runtimeMode) { | |
| 32 | − case "approval-required": | |
| 33 | − return ["--permission-mode", "default", "agent", "stdio"]; | |
| 34 | − case "auto-accept-edits": | |
| 35 | − return ["--permission-mode", "acceptEdits", "agent", "stdio"]; | |
| 36 | − case "auto": | |
| 37 | − return ["--permission-mode", "auto", "agent", "stdio"]; | |
| 38 | − case "full-access": | |
| 39 | − return ["agent", "--always-approve", "stdio"]; | |
| 40 | − default: | |
| 41 | − return ["agent", "stdio"]; | |
| 42 | − } | |
| 43 | 29 | } |
| 44 | 30 | |
| 45 | 31 | export function buildGrokAcpSpawnInput( |
| 46 | 32 | grokSettings: GrokAcpRuntimeGrokSettings | null | undefined, |
| 47 | 33 | cwd: string, |
| 48 | 34 | environment?: NodeJS.ProcessEnv, |
| 49 | − runtimeMode?: RuntimeMode, | |
| 50 | 35 | ): AcpSessionRuntime.AcpSpawnInput { |
| 51 | 36 | return { |
| 52 | 37 | command: grokSettings?.binaryPath || "grok", |
| 53 | − args: [...grokAcpSpawnArgs(runtimeMode)], | |
| 38 | + args: ["agent", "stdio"], | |
| 54 | 39 | cwd, |
| 55 | 40 | env: { |
| 56 | 41 | ...environment, |
| @@ -65,6 +50,21 @@ function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): st · 8 unmodified lines | ||
| 65 | 50 | : GROK_AUTH_METHOD_CACHED_TOKEN; |
| 66 | 51 | } |
| 67 | 52 | |
| 53 | +export function grokAcpRuntimeProcessOwnership( | |
| 54 | + processGroupPlatform: NodeJS.Platform, | |
| 55 | +): Pick< | |
| 56 | + AcpSessionRuntime.AcpSessionRuntimeOptions, | |
| 57 | + "ownDescendantProcessGroups" | "ownDetachedProcessGroup" | "processGroupPlatform" | |
| 58 | +> { | |
| 59 | + return { | |
| 60 | + // macOS keeps the prior provider-group teardown until a stable libproc | |
| 61 | + // identity provider can cover Grok's nested detached tool groups. | |
| 62 | + ownDescendantProcessGroups: processGroupPlatform === "linux", | |
| 63 | + ownDetachedProcessGroup: true, | |
| 64 | + processGroupPlatform, | |
| 65 | + }; | |
| 66 | +} | |
| 67 | + | |
| 68 | 68 | export const makeGrokAcpRuntime = ( |
| 69 | 69 | input: GrokAcpRuntimeInput, |
| 70 | 70 | ): Effect.Effect< |
| @@ -73,16 +73,15 @@ export const makeGrokAcpRuntime = ( · 2 unmodified lines | ||
| 73 | 73 | Crypto.Crypto | Scope.Scope |
| 74 | 74 | > => |
| 75 | 75 | Effect.gen(function* () { |
| 76 | + const processGroupPlatform = yield* HostProcessPlatform.pipe( | |
| 77 | + Effect.provide(NodeServices.layer), | |
| 78 | + ); | |
| 76 | 79 | const acpContext = yield* Layer.build( |
| 77 | 80 | AcpSessionRuntime.layer({ |
| 78 | 81 | ...input, |
| 79 | − spawn: buildGrokAcpSpawnInput( | |
| 80 | − input.grokSettings, | |
| 81 | − input.cwd, | |
| 82 | − input.environment, | |
| 83 | − input.runtimeMode, | |
| 84 | − ), | |
| 82 | + spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment), | |
| 85 | 83 | authMethodId: resolveGrokAuthMethodId(input.environment), |
| 84 | + ...grokAcpRuntimeProcessOwnership(processGroupPlatform), | |
| 86 | 85 | }).pipe( |
| 87 | 86 | Layer.provide( |
| 88 | 87 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), |
| @@ -107,17 +106,6 @@ export function resolveGrokAcpBaseModelId(model: string | null | undefined): str · 18 unmodified lines | ||
| 107 | 106 | return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? GROK_DEFAULT_MODEL_SLUG; |
| 108 | 107 | } |
| 109 | 108 | |
| 110 | −const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i; | |
| 111 | − | |
| 112 | −export function isValidGrokReasoningEffortToken(value: string): boolean { | |
| 113 | − return GROK_REASONING_EFFORT_TOKEN.test(value); | |
| 114 | −} | |
| 115 | − | |
| 116 | −export function normalizeGrokReasoningEffort(value: string | undefined): string | undefined { | |
| 117 | − const effort = value?.trim(); | |
| 118 | − return effort && isValidGrokReasoningEffortToken(effort) ? effort : undefined; | |
| 119 | −} | |
| 120 | − | |
| 121 | 109 | export function currentGrokModelIdFromSessionSetup( |
| 122 | 110 | sessionSetupResult: |
| 123 | 111 | | EffectAcpSchema.LoadSessionResponse |
| @@ -127,59 +115,18 @@ export function currentGrokModelIdFromSessionSetup( · 3 unmodified lines | ||
| 127 | 115 | return sessionSetupResult.models?.currentModelId?.trim() || undefined; |
| 128 | 116 | } |
| 129 | 117 | |
| 130 | −export function currentGrokReasoningEffortFromSessionSetup( | |
| 131 | − sessionSetupResult: | |
| 132 | − | EffectAcpSchema.LoadSessionResponse | |
| 133 | − | EffectAcpSchema.NewSessionResponse | |
| 134 | − | EffectAcpSchema.ResumeSessionResponse, | |
| 135 | −): string | undefined { | |
| 136 | − const modelState = sessionSetupResult.models; | |
| 137 | − if (!modelState) { | |
| 138 | − return undefined; | |
| 139 | − } | |
| 140 | − const currentModelId = modelState.currentModelId.trim(); | |
| 141 | − if (currentModelId.length === 0) { | |
| 142 | − return undefined; | |
| 143 | − } | |
| 144 | − const currentModel = modelState.availableModels.find( | |
| 145 | − (model) => model.modelId.trim() === currentModelId, | |
| 146 | − ); | |
| 147 | − const reasoningEffort = currentModel?._meta?.reasoningEffort; | |
| 148 | − return typeof reasoningEffort === "string" | |
| 149 | − ? normalizeGrokReasoningEffort(reasoningEffort) | |
| 150 | − : undefined; | |
| 151 | −} | |
| 152 | − | |
| 153 | 118 | export function applyGrokAcpModelSelection<E>(input: { |
| 154 | 119 | readonly runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "setSessionModel">; |
| 155 | 120 | readonly currentModelId: string | undefined; |
| 156 | − readonly currentReasoningEffort?: string | undefined; | |
| 157 | 121 | readonly requestedModelId: string | undefined; |
| 158 | − readonly requestedReasoningEffort?: string | undefined; | |
| 159 | 122 | readonly mapError: (cause: EffectAcpErrors.AcpError) => E; |
| 160 | 123 | }): Effect.Effect<string | undefined, E> { |
| 161 | − // The product slug is never sent over the wire; it keeps the session's current model. | |
| 162 | − const requestedModelId = | |
| 163 | − input.requestedModelId === GROK_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId; | |
| 164 | − const modelChanged = requestedModelId !== undefined && requestedModelId !== input.currentModelId; | |
| 165 | − const reasoningProvided = input.requestedReasoningEffort !== undefined; | |
| 166 | − const reasoningEffort = reasoningProvided | |
| 167 | − ? normalizeGrokReasoningEffort(input.requestedReasoningEffort) | |
| 168 | − : undefined; | |
| 169 | − const reasoningEffortChanged = | |
| 170 | − reasoningProvided && reasoningEffort !== input.currentReasoningEffort; | |
| 171 | − const targetModelId = requestedModelId ?? input.currentModelId; | |
| 172 | − if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) { | |
| 124 | + const shouldSwitchModel = | |
| 125 | + input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; | |
| 126 | + if (!shouldSwitchModel) { | |
| 173 | 127 | return Effect.succeed(input.currentModelId); |
| 174 | 128 | } |
| 175 | − const reasoningMeta = | |
| 176 | − reasoningProvided && reasoningEffort !== undefined ? { reasoningEffort } : undefined; | |
| 177 | − // When reasoning was explicitly provided but invalid (normalize => undefined), we deliberately | |
| 178 | − // send no meta so the invalid value is dropped rather than forwarded. When reasoning was not | |
| 179 | − // provided at all, we also send no meta, but we only reach this call when the model itself | |
| 180 | − // changed - an omitted reasoning preference must not be treated as an explicit clear of the | |
| 181 | − // CLI-advertised default (e.g. Extra High) on same-model reselections. | |
| 182 | 129 | return input.runtime |
| 183 | − .setSessionModel(targetModelId, reasoningMeta) | |
| 184 | − .pipe(Effect.mapError(input.mapError), Effect.as(targetModelId)); | |
| 130 | + .setSessionModel(input.requestedModelId) | |
| 131 | + .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); | |
| 185 | 132 | } |
| @@ -1,42 +1,77 @@ | ||
| 1 | −// @effect-diagnostics nodeBuiltinImport:off | |
| 2 | −import * as NodePath from "node:path"; | |
| 3 | −import * as NodeURL from "node:url"; | |
| 4 | − | |
| 5 | −import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 6 | 1 | import { it } from "@effect/vitest"; |
| 2 | +import * as Deferred from "effect/Deferred"; | |
| 7 | 3 | import * as Effect from "effect/Effect"; |
| 4 | +import * as Fiber from "effect/Fiber"; | |
| 8 | 5 | import * as Schema from "effect/Schema"; |
| 9 | 6 | import { describe, expect } from "vite-plus/test"; |
| 10 | 7 | |
| 11 | 8 | import { |
| 12 | − extractXAiAskUserQuestions, | |
| 9 | + extractXAiAcpBackgroundToolMutation, | |
| 10 | + extractXAiAcpSubagentEndNotice, | |
| 13 | 11 | extractXAiAcpSubagentUpdate, |
| 12 | + extractXAiAskUserQuestions, | |
| 13 | + extractXAiBackgroundTaskCompletion, | |
| 14 | + extractXAiKilledBackgroundTasks, | |
| 15 | + extractXAiMonitorTaskId, | |
| 16 | + isGenericAcpToolTitle, | |
| 17 | + isXAiMonitorTool, | |
| 18 | + isXAiPersistentMonitor, | |
| 14 | 19 | makeXAiAskUserQuestionCancelledResponse, |
| 15 | 20 | makeXAiAskUserQuestionResponse, |
| 16 | 21 | makeXAiPromptCompletionRuntime, |
| 22 | + normalizeXAiAcpToolCallState, | |
| 23 | + resolveXAiAcpToolTitle, | |
| 24 | + xAiBackgroundTaskLifecycleMutation, | |
| 25 | + xAiPromptCompleteFromSessionUpdate, | |
| 17 | 26 | XAiAskUserQuestionRequest, |
| 18 | 27 | } from "./XAiAcpExtension.ts"; |
| 19 | 28 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| 20 | 29 | |
| 21 | −const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | |
| 22 | −const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); | |
| 30 | +const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); | |
| 23 | 31 | |
| 24 | −const makePromptCompletionRuntime = (env: NodeJS.ProcessEnv) => | |
| 25 | − Effect.gen(function* () { | |
| 26 | − const runtime = yield* AcpSessionRuntime.make({ | |
| 27 | − spawn: { | |
| 28 | − command: process.execPath, | |
| 29 | − args: [mockAgentPath], | |
| 30 | − env, | |
| 31 | − }, | |
| 32 | − cwd: process.cwd(), | |
| 33 | − clientInfo: { name: "t3-test", version: "0.0.0" }, | |
| 34 | − authMethodId: "test", | |
| 32 | +describe("xAiPromptCompleteFromSessionUpdate", () => { | |
| 33 | + it("maps live turn_completed snake_case payloads", () => { | |
| 34 | + expect( | |
| 35 | + xAiPromptCompleteFromSessionUpdate({ | |
| 36 | + sessionId: "019f4428-4bf1-7e52-b7c6-c29b506543b1", | |
| 37 | + update: { | |
| 38 | + sessionUpdate: "turn_completed", | |
| 39 | + prompt_id: "t3-xai-prompt-1", | |
| 40 | + stop_reason: "end_turn", | |
| 41 | + }, | |
| 42 | + }), | |
| 43 | + ).toEqual({ | |
| 44 | + sessionId: "019f4428-4bf1-7e52-b7c6-c29b506543b1", | |
| 45 | + promptId: "t3-xai-prompt-1", | |
| 46 | + stopReason: "end_turn", | |
| 35 | 47 | }); |
| 36 | − return yield* makeXAiPromptCompletionRuntime(runtime); | |
| 37 | 48 | }); |
| 38 | 49 | |
| 39 | −const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); | |
| 50 | + it("ignores non-turn updates and task-completed prompt ids", () => { | |
| 51 | + expect( | |
| 52 | + xAiPromptCompleteFromSessionUpdate({ | |
| 53 | + sessionId: "root", | |
| 54 | + update: { sessionUpdate: "hook_execution", prompt_id: "t3-xai-prompt-1" }, | |
| 55 | + }), | |
| 56 | + ).toBeNull(); | |
| 57 | + expect( | |
| 58 | + xAiPromptCompleteFromSessionUpdate({ | |
| 59 | + sessionId: "root", | |
| 60 | + update: { | |
| 61 | + sessionUpdate: "turn_completed", | |
| 62 | + prompt_id: "task-completed-call-abc", | |
| 63 | + stop_reason: "end_turn", | |
| 64 | + }, | |
| 65 | + }), | |
| 66 | + ).toBeNull(); | |
| 67 | + expect( | |
| 68 | + xAiPromptCompleteFromSessionUpdate({ | |
| 69 | + sessionId: "root", | |
| 70 | + update: { sessionUpdate: "turn_completed", stop_reason: "end_turn" }, | |
| 71 | + }), | |
| 72 | + ).toBeNull(); | |
| 73 | + }); | |
| 74 | +}); | |
| 40 | 75 | |
| 41 | 76 | describe("XAiAcpExtension", () => { |
| 42 | 77 | it("recognizes Grok Task starts as native subagents", () => { |
| @@ -62,6 +97,7 @@ describe("XAiAcpExtension", () => { · 19 unmodified lines | ||
| 62 | 97 | status: "running", |
| 63 | 98 | childSessionId: null, |
| 64 | 99 | result: null, |
| 100 | + suppressNormalTool: true, | |
| 65 | 101 | }); |
| 66 | 102 | }); |
| 67 | 103 | |
| @@ -95,6 +131,792 @@ describe("XAiAcpExtension", () => { · 27 unmodified lines | ||
| 95 | 131 | status: "completed", |
| 96 | 132 | childSessionId: "019f0220-e192-7c41-9e9d-b406bc3459c8", |
| 97 | 133 | result: "Server audit complete.", |
| 134 | + suppressNormalTool: true, | |
| 135 | + }); | |
| 136 | + }); | |
| 137 | + | |
| 138 | + it("keeps async spawn_subagent ACKs running and binds subagent_id", () => { | |
| 139 | + expect( | |
| 140 | + extractXAiAcpSubagentUpdate({ | |
| 141 | + toolCallId: "call-spawn-1", | |
| 142 | + title: "spawn_subagent", | |
| 143 | + status: "completed", | |
| 144 | + data: { | |
| 145 | + rawInput: { | |
| 146 | + description: "Demo subagent wait and ls", | |
| 147 | + prompt: "Wait then ls.", | |
| 148 | + subagent_type: "general-purpose", | |
| 149 | + }, | |
| 150 | + rawOutput: { | |
| 151 | + type: "Text", | |
| 152 | + text: [ | |
| 153 | + "Subagent started in background.", | |
| 154 | + "subagent_id: 019f44a6-4820-7402-925d-bc862ee711dd", | |
| 155 | + "type: general-purpose", | |
| 156 | + "description: Demo subagent wait and ls", | |
| 157 | + "", | |
| 158 | + 'Use get_command_or_subagent_output with task_ids=["019f44a6-4820-7402-925d-bc862ee711dd"] and timeout_ms to wait for results.', | |
| 159 | + ].join("\n"), | |
| 160 | + }, | |
| 161 | + }, | |
| 162 | + }), | |
| 163 | + ).toEqual({ | |
| 164 | + nativeTaskId: "call-spawn-1", | |
| 165 | + prompt: "Wait then ls.", | |
| 166 | + title: "Demo subagent wait and ls", | |
| 167 | + model: null, | |
| 168 | + status: "running", | |
| 169 | + childSessionId: "019f44a6-4820-7402-925d-bc862ee711dd", | |
| 170 | + result: null, | |
| 171 | + suppressNormalTool: true, | |
| 172 | + }); | |
| 173 | + }); | |
| 174 | + | |
| 175 | + it("hydrates subagent completion from get_command_or_subagent_output", () => { | |
| 176 | + expect( | |
| 177 | + extractXAiAcpSubagentUpdate({ | |
| 178 | + toolCallId: "call-get-1", | |
| 179 | + title: "get_command_or_subagent_output", | |
| 180 | + status: "completed", | |
| 181 | + data: { | |
| 182 | + rawInput: { | |
| 183 | + task_ids: ["019f44a6-4820-7402-925d-bc862ee711dd"], | |
| 184 | + timeout_ms: 30000, | |
| 185 | + }, | |
| 186 | + rawOutput: { | |
| 187 | + type: "Text", | |
| 188 | + text: [ | |
| 189 | + "=== Task 019f44a6-4820-7402-925d-bc862ee711dd ===", | |
| 190 | + "Status: completed", | |
| 191 | + "Exit Code: 0", | |
| 192 | + "", | |
| 193 | + "=== Output ===", | |
| 194 | + "SUBAGENT_MARKER: 35 entries", | |
| 195 | + ].join("\n"), | |
| 196 | + }, | |
| 197 | + }, | |
| 198 | + }), | |
| 199 | + ).toEqual({ | |
| 200 | + nativeTaskId: "call-get-1", | |
| 201 | + prompt: "", | |
| 202 | + title: null, | |
| 203 | + model: null, | |
| 204 | + status: "completed", | |
| 205 | + childSessionId: "019f44a6-4820-7402-925d-bc862ee711dd", | |
| 206 | + result: "SUBAGENT_MARKER: 35 entries", | |
| 207 | + suppressNormalTool: true, | |
| 208 | + }); | |
| 209 | + }); | |
| 210 | + | |
| 211 | + it("attributes multi-task get_output envelopes to the result task_id", () => { | |
| 212 | + expect( | |
| 213 | + extractXAiAcpSubagentUpdate({ | |
| 214 | + toolCallId: "call-get-multi", | |
| 215 | + title: "get_command_or_subagent_output", | |
| 216 | + status: "completed", | |
| 217 | + data: { | |
| 218 | + rawInput: { | |
| 219 | + task_ids: [ | |
| 220 | + "019f44a6-4820-7402-925d-bc862ee711dd", | |
| 221 | + "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | |
| 222 | + ], | |
| 223 | + timeout_ms: 30000, | |
| 224 | + }, | |
| 225 | + rawOutput: { | |
| 226 | + type: "TaskOutput", | |
| 227 | + Result: { | |
| 228 | + task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | |
| 229 | + status: "completed", | |
| 230 | + exit_code: 0, | |
| 231 | + output: "SECOND_SUB_DONE\n", | |
| 232 | + }, | |
| 233 | + }, | |
| 234 | + }, | |
| 235 | + }), | |
| 236 | + ).toMatchObject({ | |
| 237 | + childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | |
| 238 | + status: "completed", | |
| 239 | + result: "SECOND_SUB_DONE", | |
| 240 | + }); | |
| 241 | + }); | |
| 242 | + | |
| 243 | + it("hydrates structured ACP TaskOutput tool envelopes", () => { | |
| 244 | + expect( | |
| 245 | + extractXAiAcpSubagentUpdate({ | |
| 246 | + toolCallId: "call-get-2", | |
| 247 | + title: "[subagent:general-purpose] Sleep then return SUBAGENT_DONE (019f44b0)", | |
| 248 | + status: "completed", | |
| 249 | + data: { | |
| 250 | + rawInput: { | |
| 251 | + variant: "TaskOutput", | |
| 252 | + task_ids: ["019f44b0-1b73-7f32-bb1b-1ff696f536e3"], | |
| 253 | + timeout_ms: 20000, | |
| 254 | + }, | |
| 255 | + rawOutput: { | |
| 256 | + type: "TaskOutput", | |
| 257 | + Result: { | |
| 258 | + task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | |
| 259 | + status: "completed", | |
| 260 | + exit_code: 0, | |
| 261 | + output: | |
| 262 | + "SUBAGENT_DONE\n\n<subagent_meta>id=019f44b0-1b73-7f32-bb1b-1ff696f536e3</subagent_meta>\n", | |
| 263 | + }, | |
| 264 | + }, | |
| 265 | + }, | |
| 266 | + }), | |
| 267 | + ).toEqual({ | |
| 268 | + nativeTaskId: "call-get-2", | |
| 269 | + prompt: "", | |
| 270 | + title: null, | |
| 271 | + model: null, | |
| 272 | + status: "completed", | |
| 273 | + childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | |
| 274 | + result: "SUBAGENT_DONE", | |
| 275 | + suppressNormalTool: true, | |
| 276 | + }); | |
| 277 | + }); | |
| 278 | + | |
| 279 | + it("does not leak raw machine tags when get_output is only meta/result blocks", () => { | |
| 280 | + const metaOnlyToolCall = { | |
| 281 | + toolCallId: "call-get-meta-only", | |
| 282 | + title: "get_command_or_subagent_output", | |
| 283 | + status: "completed" as const, | |
| 284 | + data: { | |
| 285 | + rawInput: { | |
| 286 | + variant: "TaskOutput", | |
| 287 | + task_ids: ["019f44b0-1b73-7f32-bb1b-1ff696f536e3"], | |
| 288 | + timeout_ms: 20000, | |
| 289 | + }, | |
| 290 | + rawOutput: { | |
| 291 | + type: "TaskOutput", | |
| 292 | + Result: { | |
| 293 | + task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | |
| 294 | + status: "completed", | |
| 295 | + exit_code: 0, | |
| 296 | + output: | |
| 297 | + "<subagent_meta>id=019f44b0-1b73-7f32-bb1b-1ff696f536e3</subagent_meta>\n<subagent_result>done</subagent_result>\n", | |
| 298 | + }, | |
| 299 | + }, | |
| 300 | + }, | |
| 301 | + }; | |
| 302 | + expect(extractXAiAcpSubagentUpdate(metaOnlyToolCall)).toEqual({ | |
| 303 | + nativeTaskId: "call-get-meta-only", | |
| 304 | + prompt: "", | |
| 305 | + title: null, | |
| 306 | + model: null, | |
| 307 | + status: "completed", | |
| 308 | + childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | |
| 309 | + result: null, | |
| 310 | + suppressNormalTool: true, | |
| 311 | + }); | |
| 312 | + expect(extractXAiBackgroundTaskCompletion(metaOnlyToolCall)).toEqual([ | |
| 313 | + { | |
| 314 | + taskId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | |
| 315 | + status: "completed", | |
| 316 | + appendOutput: "", | |
| 317 | + }, | |
| 318 | + ]); | |
| 319 | + }); | |
| 320 | + | |
| 321 | + it("keeps monitor start ACKs running and extracts task ids", () => { | |
| 322 | + const toolCall = { | |
| 323 | + toolCallId: "call-mon-1", | |
| 324 | + title: "monitor", | |
| 325 | + status: "completed" as const, | |
| 326 | + data: { | |
| 327 | + rawInput: { | |
| 328 | + command: "echo mon_line_1", | |
| 329 | + description: "Demo monitor", | |
| 330 | + }, | |
| 331 | + rawOutput: { | |
| 332 | + type: "Text", | |
| 333 | + text: "Monitor started (task 019f44a5-87d1-7640-8e35-6a4667ffc873, timeout 36000000ms).\nYou will be notified on each event.", | |
| 334 | + }, | |
| 335 | + }, | |
| 336 | + }; | |
| 337 | + expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress"); | |
| 338 | + expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873"); | |
| 339 | + }); | |
| 340 | + | |
| 341 | + it("completes Monitor variant tools from structured Bash exit codes", () => { | |
| 342 | + const toolCall = { | |
| 343 | + toolCallId: "call-mon-2", | |
| 344 | + title: "Tool", | |
| 345 | + status: "inProgress" as const, | |
| 346 | + data: { | |
| 347 | + rawInput: { | |
| 348 | + variant: "Monitor", | |
| 349 | + command: "echo MON_DONE", | |
| 350 | + description: "Stream mon lines", | |
| 351 | + }, | |
| 352 | + rawOutput: { | |
| 353 | + type: "Bash", | |
| 354 | + output: Array.from(new TextEncoder().encode("mon_line_1\nMON_DONE\n")), | |
| 355 | + output_for_prompt: "mon_line_1\nMON_DONE\n", | |
| 356 | + exit_code: 0, | |
| 357 | + }, | |
| 358 | + }, | |
| 359 | + }; | |
| 360 | + expect(isXAiMonitorTool(toolCall)).toBe(true); | |
| 361 | + expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("completed"); | |
| 362 | + }); | |
| 363 | + | |
| 364 | + it("detects Monitor start ACKs from structured rawOutput when title is generic", () => { | |
| 365 | + const toolCall = { | |
| 366 | + toolCallId: "call-mon-generic-title", | |
| 367 | + title: "Tool", | |
| 368 | + status: "completed" as const, | |
| 369 | + data: { | |
| 370 | + rawInput: { | |
| 371 | + command: "echo mon_line", | |
| 372 | + description: "Demo monitor without variant", | |
| 373 | + }, | |
| 374 | + rawOutput: { | |
| 375 | + type: "Monitor", | |
| 376 | + taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", | |
| 377 | + timeoutMs: 36000000, | |
| 378 | + persistent: false, | |
| 379 | + }, | |
| 380 | + }, | |
| 381 | + }; | |
| 382 | + expect(isXAiMonitorTool(toolCall)).toBe(true); | |
| 383 | + expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress"); | |
| 384 | + expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873"); | |
| 385 | + }); | |
| 386 | + | |
| 387 | + it("completes wake re-reports of finished commands despite generic titles", () => { | |
| 388 | + // Post-settle wake replay: the finished monitor is re-reported with empty | |
| 389 | + // rawInput and a generic title, so monitor detection cannot match; the | |
| 390 | + // structured Bash result with exit_code must still terminalize it. | |
| 391 | + const toolCall = { | |
| 392 | + toolCallId: "call-wake-1", | |
| 393 | + title: "Tool", | |
| 394 | + status: "inProgress" as const, | |
| 395 | + data: { | |
| 396 | + rawInput: {}, | |
| 397 | + rawOutput: { | |
| 398 | + type: "Bash", | |
| 399 | + output: Array.from(new TextEncoder().encode("STREAM_1\nSTREAM_DONE\n")), | |
| 400 | + output_for_prompt: "STREAM_1\nSTREAM_DONE\n", | |
| 401 | + exit_code: 0, | |
| 402 | + command: "for i in 1 2; do echo STREAM_$i; done; echo STREAM_DONE", | |
| 403 | + }, | |
| 404 | + }, | |
| 405 | + }; | |
| 406 | + expect(isXAiMonitorTool(toolCall)).toBe(false); | |
| 407 | + const normalized = normalizeXAiAcpToolCallState(toolCall); | |
| 408 | + expect(normalized.status).toBe("completed"); | |
| 409 | + expect(normalized.title).toBe("for i in 1 2; do echo STREAM_$i; done; echo STREAM_DONE"); | |
| 410 | + expect( | |
| 411 | + normalizeXAiAcpToolCallState({ | |
| 412 | + ...toolCall, | |
| 413 | + data: { | |
| 414 | + ...toolCall.data, | |
| 415 | + rawOutput: { ...toolCall.data.rawOutput, exit_code: 2 }, | |
| 416 | + }, | |
| 417 | + }).status, | |
| 418 | + ).toBe("failed"); | |
| 419 | + }); | |
| 420 | + | |
| 421 | + it("keeps empty successful Bash updates running until native output or turn settlement", () => { | |
| 422 | + const normalized = normalizeXAiAcpToolCallState({ | |
| 423 | + toolCallId: "call-bash-permission-ack", | |
| 424 | + title: "Ran command", | |
| 425 | + status: "completed", | |
| 426 | + data: { | |
| 427 | + rawInput: { command: "sleep 40" }, | |
| 428 | + rawOutput: { type: "Bash", exit_code: 0 }, | |
| 429 | + }, | |
| 430 | + }); | |
| 431 | + expect(normalized.status).toBe("inProgress"); | |
| 432 | + }); | |
| 433 | + | |
| 434 | + it("keeps structured Monitor start ACKs running and extracts taskId", () => { | |
| 435 | + const toolCall = { | |
| 436 | + toolCallId: "call-mon-3", | |
| 437 | + title: "Start monitor: Wait 30s then list directory", | |
| 438 | + status: "completed" as const, | |
| 439 | + data: { | |
| 440 | + rawInput: { | |
| 441 | + variant: "Monitor", | |
| 442 | + command: "sleep 30 && ls", | |
| 443 | + description: "Wait 30s then list directory", | |
| 444 | + }, | |
| 445 | + rawOutput: { | |
| 446 | + type: "Monitor", | |
| 447 | + taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", | |
| 448 | + timeoutMs: 36000000, | |
| 449 | + persistent: false, | |
| 450 | + }, | |
| 451 | + }, | |
| 452 | + }; | |
| 453 | + expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress"); | |
| 454 | + expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44b8-8e98-7c80-a40e-df1e26a5f9e3"); | |
| 455 | + }); | |
| 456 | + | |
| 457 | + it("replaces generic ACP titles with description / Monitor labels", () => { | |
| 458 | + expect(isGenericAcpToolTitle("Tool")).toBe(true); | |
| 459 | + expect(isGenericAcpToolTitle("Read package.json")).toBe(false); | |
| 460 | + expect( | |
| 461 | + resolveXAiAcpToolTitle({ | |
| 462 | + toolCallId: "call-mon-title", | |
| 463 | + title: "Tool", | |
| 464 | + status: "completed", | |
| 465 | + data: { | |
| 466 | + rawInput: { | |
| 467 | + variant: "Monitor", | |
| 468 | + command: "sleep 30 && ls", | |
| 469 | + description: "Wait 30s then list directory", | |
| 470 | + }, | |
| 471 | + }, | |
| 472 | + }), | |
| 473 | + ).toBe("Monitor: Wait 30s then list directory"); | |
| 474 | + const normalized = normalizeXAiAcpToolCallState({ | |
| 475 | + toolCallId: "call-mon-title", | |
| 476 | + title: "Tool", | |
| 477 | + status: "completed", | |
| 478 | + data: { | |
| 479 | + rawInput: { | |
| 480 | + variant: "Monitor", | |
| 481 | + command: "sleep 30 && ls", | |
| 482 | + description: "Wait 30s then list directory", | |
| 483 | + }, | |
| 484 | + rawOutput: { | |
| 485 | + type: "Monitor", | |
| 486 | + taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", | |
| 487 | + timeoutMs: 36000000, | |
| 488 | + persistent: false, | |
| 489 | + }, | |
| 490 | + }, | |
| 491 | + }); | |
| 492 | + expect(normalized.title).toBe("Monitor: Wait 30s then list directory"); | |
| 493 | + expect(normalized.status).toBe("inProgress"); | |
| 494 | + // Non-generic titles from the CLI are preserved. | |
| 495 | + expect( | |
| 496 | + resolveXAiAcpToolTitle({ | |
| 497 | + toolCallId: "call-read", | |
| 498 | + title: "Read package.json", | |
| 499 | + status: "inProgress", | |
| 500 | + data: { rawInput: { path: "package.json" } }, | |
| 501 | + }), | |
| 502 | + ).toBe("Read package.json"); | |
| 503 | + }); | |
| 504 | + | |
| 505 | + it("hydrates monitor completion from TaskOutput get_command envelopes", () => { | |
| 506 | + expect( | |
| 507 | + extractXAiBackgroundTaskCompletion({ | |
| 508 | + toolCallId: "call-get-mon", | |
| 509 | + title: "Wait 30s then list directory", | |
| 510 | + status: "completed", | |
| 511 | + data: { | |
| 512 | + rawInput: { | |
| 513 | + variant: "TaskOutput", | |
| 514 | + task_ids: ["019f44b8-8e98-7c80-a40e-df1e26a5f9e3"], | |
| 515 | + }, | |
| 516 | + rawOutput: { | |
| 517 | + type: "TaskOutput", | |
| 518 | + Result: { | |
| 519 | + task_id: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", | |
| 520 | + command: "[monitor] Wait 30s then list directory", | |
| 521 | + status: "completed", | |
| 522 | + exit_code: 0, | |
| 523 | + output: "agents\nAGENTS.md\nnotes\n", | |
| 524 | + }, | |
| 525 | + }, | |
| 526 | + }, | |
| 527 | + }), | |
| 528 | + ).toEqual([ | |
| 529 | + { | |
| 530 | + taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", | |
| 531 | + status: "completed", | |
| 532 | + appendOutput: "agents\nAGENTS.md\nnotes", | |
| 533 | + }, | |
| 534 | + ]); | |
| 535 | + }); | |
| 536 | + | |
| 537 | + it("hydrates monitor completion from a standalone TaskOutput frame without rawInput", () => { | |
| 538 | + // Post-settle shape observed live (2026-07-12): the final | |
| 539 | + // get_command_or_subagent_output update carries only rawOutput; the frame | |
| 540 | + // is parsed in isolation (no in-turn merge), and this completion is the | |
| 541 | + // ONLY end signal when the agent consumed the output itself (no "Monitor | |
| 542 | + // ended" reminder follows). | |
| 543 | + expect( | |
| 544 | + extractXAiBackgroundTaskCompletion({ | |
| 545 | + toolCallId: "call-c29e64dd-ce5d-4eac-a6ca-c20513fefac3-1", | |
| 546 | + title: "[monitor] Stream numbered markers every 3s (019f54a0)", | |
| 547 | + status: "completed", | |
| 548 | + data: { | |
| 549 | + rawOutput: { | |
| 550 | + type: "TaskOutput", | |
| 551 | + Result: { | |
| 552 | + task_id: "019f54a0-06a8-77f2-8214-e24937cad564", | |
| 553 | + command: "[monitor] Stream numbered markers every 3s", | |
| 554 | + status: "completed", | |
| 555 | + exit_code: 0, | |
| 556 | + output: "STREAM_1\nSTREAM_DONE\n", | |
| 557 | + }, | |
| 558 | + }, | |
| 559 | + }, | |
| 560 | + }), | |
| 561 | + ).toMatchObject([ | |
| 562 | + { | |
| 563 | + taskId: "019f54a0-06a8-77f2-8214-e24937cad564", | |
| 564 | + status: "completed", | |
| 565 | + }, | |
| 566 | + ]); | |
| 567 | + }); | |
| 568 | + | |
| 569 | + it("hydrates every requested monitor when TaskOutput has no result task_id", () => { | |
| 570 | + expect( | |
| 571 | + extractXAiBackgroundTaskCompletion({ | |
| 572 | + toolCallId: "call-get-multi-mon", | |
| 573 | + title: "get_command_or_subagent_output", | |
| 574 | + status: "completed", | |
| 575 | + data: { | |
| 576 | + rawInput: { | |
| 577 | + variant: "TaskOutput", | |
| 578 | + task_ids: [ | |
| 579 | + "019f44a6-4820-7402-925d-bc862ee711dd", | |
| 580 | + "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | |
| 581 | + ], | |
| 582 | + }, | |
| 583 | + rawOutput: { | |
| 584 | + type: "TaskOutput", | |
| 585 | + Result: { | |
| 586 | + status: "completed", | |
| 587 | + exit_code: 0, | |
| 588 | + output: "BOTH_DONE\n", | |
| 589 | + }, | |
| 590 | + }, | |
| 591 | + }, | |
| 592 | + }), | |
| 593 | + ).toEqual([ | |
| 594 | + { | |
| 595 | + taskId: "019f44a6-4820-7402-925d-bc862ee711dd", | |
| 596 | + status: "completed", | |
| 597 | + appendOutput: "BOTH_DONE", | |
| 598 | + }, | |
| 599 | + { | |
| 600 | + taskId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | |
| 601 | + status: "completed", | |
| 602 | + appendOutput: "BOTH_DONE", | |
| 603 | + }, | |
| 604 | + ]); | |
| 605 | + }); | |
| 606 | + | |
| 607 | + it("treats Exit Code: -1 text envelopes as failed", () => { | |
| 608 | + expect( | |
| 609 | + extractXAiBackgroundTaskCompletion({ | |
| 610 | + toolCallId: "call-get-neg", | |
| 611 | + title: "get_command_or_subagent_output", | |
| 612 | + status: "completed", | |
| 613 | + data: { | |
| 614 | + rawInput: { | |
| 615 | + variant: "TaskOutput", | |
| 616 | + task_ids: ["019f44b8-8e98-7c80-a40e-df1e26a5f9e3"], | |
| 617 | + }, | |
| 618 | + rawOutput: { | |
| 619 | + type: "Text", | |
| 620 | + text: ["=== Task 019f44b8-8e98-7c80-a40e-df1e26a5f9e3 ===", "Exit Code: -1"].join("\n"), | |
| 621 | + }, | |
| 622 | + }, | |
| 623 | + }), | |
| 624 | + ).toMatchObject([ | |
| 625 | + { | |
| 626 | + taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", | |
| 627 | + status: "failed", | |
| 628 | + }, | |
| 629 | + ]); | |
| 630 | + }); | |
| 631 | + | |
| 632 | + it("tombstones tasks the model kills via kill_command_or_subagent", () => { | |
| 633 | + expect( | |
| 634 | + extractXAiKilledBackgroundTasks({ | |
| 635 | + toolCallId: "call-kill-1", | |
| 636 | + title: "kill_command_or_subagent", | |
| 637 | + status: "completed", | |
| 638 | + data: { | |
| 639 | + rawInput: { | |
| 640 | + variant: "Kill", | |
| 641 | + task_ids: ["call-bg-1", "call-bg-2", "call-bg-1"], | |
| 642 | + }, | |
| 643 | + }, | |
| 644 | + }), | |
| 645 | + ).toEqual([ | |
| 646 | + { taskId: "call-bg-1", status: "completed", appendOutput: "" }, | |
| 647 | + { taskId: "call-bg-2", status: "completed", appendOutput: "" }, | |
| 648 | + ]); | |
| 649 | + // Singular task_id shape, matched by variant when the title is generic. | |
| 650 | + expect( | |
| 651 | + extractXAiKilledBackgroundTasks({ | |
| 652 | + toolCallId: "call-kill-2", | |
| 653 | + title: "Tool", | |
| 654 | + status: "failed", | |
| 655 | + data: { rawInput: { variant: "kill", task_id: "call-bg-3" } }, | |
| 656 | + }), | |
| 657 | + ).toEqual([{ taskId: "call-bg-3", status: "completed", appendOutput: "" }]); | |
| 658 | + // In-flight kill calls and unrelated tools contribute nothing. | |
| 659 | + expect( | |
| 660 | + extractXAiKilledBackgroundTasks({ | |
| 661 | + toolCallId: "call-kill-3", | |
| 662 | + title: "kill_command_or_subagent", | |
| 663 | + status: "inProgress", | |
| 664 | + data: { rawInput: { task_ids: ["call-bg-4"] } }, | |
| 665 | + }), | |
| 666 | + ).toEqual([]); | |
| 667 | + expect( | |
| 668 | + extractXAiKilledBackgroundTasks({ | |
| 669 | + toolCallId: "call-get", | |
| 670 | + title: "get_command_or_subagent_output", | |
| 671 | + status: "completed", | |
| 672 | + data: { rawInput: { variant: "TaskOutput", task_ids: ["call-bg-5"] } }, | |
| 673 | + }), | |
| 674 | + ).toEqual([]); | |
| 675 | + }); | |
| 676 | + | |
| 677 | + it("maps task lifecycle notifications to background mutations", () => { | |
| 678 | + // task_backgrounded carries the cancelled call's tool_call_id as task_id. | |
| 679 | + expect( | |
| 680 | + xAiBackgroundTaskLifecycleMutation( | |
| 681 | + { | |
| 682 | + sessionId: "session-1", | |
| 683 | + update: { | |
| 684 | + sessionUpdate: "task_backgrounded", | |
| 685 | + tool_call_id: "call-bg-1", | |
| 686 | + task_id: "call-bg-1", | |
| 687 | + }, | |
| 688 | + }, | |
| 689 | + "running", | |
| 690 | + ), | |
| 691 | + ).toEqual({ sessionId: "session-1", taskId: "call-bg-1", status: "running" }); | |
| 692 | + // task_completed nests the id inside task_snapshot. | |
| 693 | + expect( | |
| 694 | + xAiBackgroundTaskLifecycleMutation( | |
| 695 | + { | |
| 696 | + sessionId: "session-1", | |
| 697 | + update: { | |
| 698 | + sessionUpdate: "task_completed", | |
| 699 | + task_snapshot: { task_id: "call-bg-1" }, | |
| 700 | + }, | |
| 701 | + }, | |
| 702 | + "completed", | |
| 703 | + ), | |
| 704 | + ).toEqual({ sessionId: "session-1", taskId: "call-bg-1", status: "completed" }); | |
| 705 | + expect( | |
| 706 | + xAiBackgroundTaskLifecycleMutation( | |
| 707 | + { sessionId: "session-1", update: { sessionUpdate: "task_completed" } }, | |
| 708 | + "completed", | |
| 709 | + ), | |
| 710 | + ).toBeNull(); | |
| 711 | + }); | |
| 712 | + | |
| 713 | + it("detects persistent Monitor start ACKs", () => { | |
| 714 | + expect( | |
| 715 | + isXAiPersistentMonitor({ | |
| 716 | + toolCallId: "call-mon-persist", | |
| 717 | + title: "Monitor", | |
| 718 | + status: "completed", | |
| 719 | + data: { | |
| 720 | + rawInput: { variant: "Monitor" }, | |
| 721 | + rawOutput: { | |
| 722 | + type: "Monitor", | |
| 723 | + taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", | |
| 724 | + timeoutMs: 36000000, | |
| 725 | + persistent: true, | |
| 726 | + }, | |
| 727 | + }, | |
| 728 | + }), | |
| 729 | + ).toBe(true); | |
| 730 | + expect( | |
| 731 | + isXAiPersistentMonitor({ | |
| 732 | + toolCallId: "call-mon-ephemeral", | |
| 733 | + title: "Monitor", | |
| 734 | + status: "completed", | |
| 735 | + data: { | |
| 736 | + rawInput: { variant: "Monitor" }, | |
| 737 | + rawOutput: { | |
| 738 | + type: "Monitor", | |
| 739 | + taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", | |
| 740 | + timeoutMs: 36000000, | |
| 741 | + persistent: false, | |
| 742 | + }, | |
| 743 | + }, | |
| 744 | + }), | |
| 745 | + ).toBe(false); | |
| 746 | + }); | |
| 747 | + | |
| 748 | + it("keeps a standalone TaskOutput completion terminal through normalize", () => { | |
| 749 | + // Must remain offer evidence post-settle: normalize must not demote it to | |
| 750 | + // inProgress the way monitor start ACKs are. | |
| 751 | + expect( | |
| 752 | + normalizeXAiAcpToolCallState({ | |
| 753 | + toolCallId: "call-c29e64dd-ce5d-4eac-a6ca-c20513fefac3-1", | |
| 754 | + title: "[monitor] Stream numbered markers every 3s (019f54a0)", | |
| 755 | + status: "completed", | |
| 756 | + data: { | |
| 757 | + rawOutput: { | |
| 758 | + type: "TaskOutput", | |
| 759 | + Result: { | |
| 760 | + task_id: "019f54a0-06a8-77f2-8214-e24937cad564", | |
| 761 | + status: "completed", | |
| 762 | + exit_code: 0, | |
| 763 | + output: "STREAM_DONE\n", | |
| 764 | + }, | |
| 765 | + }, | |
| 766 | + }, | |
| 767 | + }).status, | |
| 768 | + ).toBe("completed"); | |
| 769 | + }); | |
| 770 | + | |
| 771 | + it("parses monitor event lines and end reminders", () => { | |
| 772 | + expect( | |
| 773 | + extractXAiAcpBackgroundToolMutation( | |
| 774 | + '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">\n[Demo] mon_line_1\n</monitor-event>', | |
| 775 | + ), | |
| 776 | + ).toEqual([ | |
| 777 | + { | |
| 778 | + taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", | |
| 779 | + status: "running", | |
| 780 | + appendOutput: "[Demo] mon_line_1\n", | |
| 781 | + }, | |
| 782 | + ]); | |
| 783 | + expect( | |
| 784 | + extractXAiAcpBackgroundToolMutation( | |
| 785 | + [ | |
| 786 | + "<system-reminder>", | |
| 787 | + 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].', | |
| 788 | + "Description: Demo monitor", | |
| 789 | + "</system-reminder>", | |
| 790 | + ].join("\n"), | |
| 791 | + ), | |
| 792 | + ).toMatchObject([ | |
| 793 | + { | |
| 794 | + taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", | |
| 795 | + status: "completed", | |
| 796 | + }, | |
| 797 | + ]); | |
| 798 | + // Description/output may contain "error" without meaning the monitor failed. | |
| 799 | + expect( | |
| 800 | + extractXAiAcpBackgroundToolMutation( | |
| 801 | + [ | |
| 802 | + "<system-reminder>", | |
| 803 | + 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].', | |
| 804 | + "Description: watch error logs for failed deploys", | |
| 805 | + "error: nothing found", | |
| 806 | + "</system-reminder>", | |
| 807 | + ].join("\n"), | |
| 808 | + ), | |
| 809 | + ).toMatchObject([ | |
| 810 | + { | |
| 811 | + taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", | |
| 812 | + status: "completed", | |
| 813 | + }, | |
| 814 | + ]); | |
| 815 | + }); | |
| 816 | + | |
| 817 | + it("parses every monitor-event and trailing end notice in one chunk", () => { | |
| 818 | + const mutations = extractXAiAcpBackgroundToolMutation( | |
| 819 | + [ | |
| 820 | + '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">', | |
| 821 | + "[Demo] mon_line_1", | |
| 822 | + "</monitor-event>", | |
| 823 | + '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">', | |
| 824 | + "[Demo] mon_line_2", | |
| 825 | + "</monitor-event>", | |
| 826 | + 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].', | |
| 827 | + ].join("\n"), | |
| 828 | + ); | |
| 829 | + expect(mutations).toHaveLength(3); | |
| 830 | + expect(mutations[0]).toEqual({ | |
| 831 | + taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", | |
| 832 | + status: "running", | |
| 833 | + appendOutput: "[Demo] mon_line_1\n", | |
| 834 | + }); | |
| 835 | + expect(mutations[1]).toEqual({ | |
| 836 | + taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", | |
| 837 | + status: "running", | |
| 838 | + appendOutput: "[Demo] mon_line_2\n", | |
| 839 | + }); | |
| 840 | + expect(mutations[2]).toMatchObject({ | |
| 841 | + taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", | |
| 842 | + status: "completed", | |
| 843 | + }); | |
| 844 | + }); | |
| 845 | + | |
| 846 | + it("parses batched monitor event blocks as running mutations", () => { | |
| 847 | + expect( | |
| 848 | + extractXAiAcpBackgroundToolMutation( | |
| 849 | + [ | |
| 850 | + "3 monitor events from 1 monitor (use get_command_or_subagent_output to identify each monitor):", | |
| 851 | + "", | |
| 852 | + '<monitor description="Stream STREAM_i every 3s" task_id="019f545d-4d39-7001-b1c8-c7744c448ec1">', | |
| 853 | + "[1] STREAM_7", | |
| 854 | + "[2] STREAM_8", | |
| 855 | + "[3] STREAM_9", | |
| 856 | + "</monitor>", | |
| 857 | + ].join("\n"), | |
| 858 | + ), | |
| 859 | + ).toEqual([ | |
| 860 | + { | |
| 861 | + taskId: "019f545d-4d39-7001-b1c8-c7744c448ec1", | |
| 862 | + status: "running", | |
| 863 | + appendOutput: "[1] STREAM_7\n[2] STREAM_8\n[3] STREAM_9\n", | |
| 864 | + }, | |
| 865 | + ]); | |
| 866 | + }); | |
| 867 | + | |
| 868 | + it("parses the subagent completed reminder as an end notice", () => { | |
| 869 | + expect( | |
| 870 | + extractXAiAcpSubagentEndNotice( | |
| 871 | + [ | |
| 872 | + "<system-reminder>", | |
| 873 | + 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") completed successfully.', | |
| 874 | + "Duration: 28.8s | Tool calls: 1 | Turns: 1", | |
| 875 | + 'Use get_task_output("019f5470-bf92-7a90-afb3-5a6cea5b34a3") to see the full output.', | |
| 876 | + "</system-reminder>", | |
| 877 | + ].join("\n"), | |
| 878 | + ), | |
| 879 | + ).toEqual({ | |
| 880 | + childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3", | |
| 881 | + status: "completed", | |
| 882 | + }); | |
| 883 | + }); | |
| 884 | + | |
| 885 | + it("parses a failed subagent reminder as a failed end notice", () => { | |
| 886 | + expect( | |
| 887 | + extractXAiAcpSubagentEndNotice( | |
| 888 | + 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") failed.', | |
| 889 | + ), | |
| 890 | + ).toEqual({ | |
| 891 | + childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3", | |
| 892 | + status: "failed", | |
| 893 | + }); | |
| 894 | + }); | |
| 895 | + | |
| 896 | + it("ignores prompt text that merely mentions subagents", () => { | |
| 897 | + expect( | |
| 898 | + extractXAiAcpSubagentEndNotice("Live-test post-settle subagent completion."), | |
| 899 | + ).toBeUndefined(); | |
| 900 | + expect( | |
| 901 | + extractXAiAcpSubagentEndNotice( | |
| 902 | + 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") is still running.', | |
| 903 | + ), | |
| 904 | + ).toBeUndefined(); | |
| 905 | + }); | |
| 906 | + | |
| 907 | + it("does not treat nested parentheses in the title as the outcome verb", () => { | |
| 908 | + expect( | |
| 909 | + extractXAiAcpSubagentEndNotice( | |
| 910 | + 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Check (backend) failed tests") is still running.', | |
| 911 | + ), | |
| 912 | + ).toBeUndefined(); | |
| 913 | + expect( | |
| 914 | + extractXAiAcpSubagentEndNotice( | |
| 915 | + 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Check (backend) failed tests") completed successfully.', | |
| 916 | + ), | |
| 917 | + ).toEqual({ | |
| 918 | + childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3", | |
| 919 | + status: "completed", | |
| 98 | 920 | }); |
| 99 | 921 | }); |
| 100 | 922 | |
| @@ -335,58 +1157,265 @@ describe("XAiAcpExtension", () => { · 234 unmodified lines | ||
| 335 | 1157 | }); |
| 336 | 1158 | }); |
| 337 | 1159 | |
| 338 | − it.effect("resolves a hung standard prompt from xAI prompt completion", () => | |
| 1160 | + it.effect("settles a hung prompt from a root-session prompt_complete notification", () => | |
| 339 | 1161 | Effect.gen(function* () { |
| 340 | − const runtime = yield* makePromptCompletionRuntime({ | |
| 341 | − T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", | |
| 342 | − }); | |
| 343 | − yield* runtime.start(); | |
| 1162 | + const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); | |
| 1163 | + const hungPrompt = yield* Deferred.make<never>(); | |
| 1164 | + const baseRuntime = { | |
| 1165 | + start: () => | |
| 1166 | + Effect.succeed({ | |
| 1167 | + sessionId: "root-session", | |
| 1168 | + initializeResult: {}, | |
| 1169 | + sessionSetupResult: {}, | |
| 1170 | + modelConfigId: undefined, | |
| 1171 | + }), | |
| 1172 | + prompt: () => Deferred.await(hungPrompt), | |
| 1173 | + cancel: Effect.void, | |
| 1174 | + handleExtNotification: ( | |
| 1175 | + method: string, | |
| 1176 | + _schema: unknown, | |
| 1177 | + handler: (notification: unknown) => Effect.Effect<void>, | |
| 1178 | + ) => { | |
| 1179 | + handlers.set(method, handler); | |
| 1180 | + return Effect.void; | |
| 1181 | + }, | |
| 1182 | + handleExtRequest: () => Effect.void, | |
| 1183 | + } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; | |
| 344 | 1184 | |
| 345 | − const promptResult = yield* runtime.prompt({ | |
| 346 | − prompt: [{ type: "text", text: "hi" }], | |
| 1185 | + const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); | |
| 1186 | + const promptFiber = yield* runtime | |
| 1187 | + .prompt({ prompt: [{ type: "text", text: "hi" }] }) | |
| 1188 | + .pipe(Effect.forkChild); | |
| 1189 | + yield* Effect.yieldNow; | |
| 1190 | + const promptCompleteHandler = handlers.get("_x.ai/session/prompt_complete"); | |
| 1191 | + expect(promptCompleteHandler).toBeDefined(); | |
| 1192 | + yield* promptCompleteHandler!({ | |
| 1193 | + sessionId: "root-session", | |
| 1194 | + stopReason: "end_turn", | |
| 347 | 1195 | }); |
| 348 | − const promptId = promptResult._meta?.promptId; | |
| 1196 | + const response = yield* Fiber.join(promptFiber); | |
| 1197 | + expect(response.stopReason).toBe("end_turn"); | |
| 1198 | + }), | |
| 1199 | + ); | |
| 349 | 1200 | |
| 1201 | + it.effect("settles a hung prompt from _x.ai/session/update turn_completed", () => | |
| 1202 | + Effect.gen(function* () { | |
| 1203 | + const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); | |
| 1204 | + let capturedMeta: Record<string, unknown> | null | undefined; | |
| 1205 | + const hungPrompt = yield* Deferred.make<never>(); | |
| 1206 | + const baseRuntime = { | |
| 1207 | + start: () => | |
| 1208 | + Effect.succeed({ | |
| 1209 | + sessionId: "root-session", | |
| 1210 | + initializeResult: {}, | |
| 1211 | + sessionSetupResult: {}, | |
| 1212 | + modelConfigId: undefined, | |
| 1213 | + }), | |
| 1214 | + prompt: (payload: { readonly _meta?: Record<string, unknown> | null }) => { | |
| 1215 | + capturedMeta = payload._meta ?? null; | |
| 1216 | + return Deferred.await(hungPrompt); | |
| 1217 | + }, | |
| 1218 | + cancel: Effect.void, | |
| 1219 | + handleExtNotification: ( | |
| 1220 | + method: string, | |
| 1221 | + _schema: unknown, | |
| 1222 | + handler: (notification: unknown) => Effect.Effect<void>, | |
| 1223 | + ) => { | |
| 1224 | + handlers.set(method, handler); | |
| 1225 | + return Effect.void; | |
| 1226 | + }, | |
| 1227 | + handleExtRequest: () => Effect.void, | |
| 1228 | + } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; | |
| 1229 | + | |
| 1230 | + const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); | |
| 1231 | + const promptFiber = yield* runtime | |
| 1232 | + .prompt({ prompt: [{ type: "text", text: "hi" }] }) | |
| 1233 | + .pipe(Effect.forkChild); | |
| 1234 | + yield* Effect.yieldNow; | |
| 1235 | + const promptId = capturedMeta?.promptId; | |
| 350 | 1236 | expect(typeof promptId).toBe("string"); |
| 351 | − expect(promptResult).toMatchObject({ | |
| 352 | − stopReason: "end_turn", | |
| 353 | − _meta: { | |
| 354 | − sessionId: "mock-session-1", | |
| 355 | − promptId, | |
| 356 | − requestId: promptId, | |
| 1237 | + const sessionUpdateHandler = handlers.get("_x.ai/session/update"); | |
| 1238 | + expect(sessionUpdateHandler).toBeDefined(); | |
| 1239 | + yield* sessionUpdateHandler!({ | |
| 1240 | + sessionId: "root-session", | |
| 1241 | + update: { | |
| 1242 | + sessionUpdate: "turn_completed", | |
| 1243 | + prompt_id: promptId, | |
| 1244 | + stop_reason: "end_turn", | |
| 357 | 1245 | }, |
| 358 | 1246 | }); |
| 359 | − }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 1247 | + const response = yield* Fiber.join(promptFiber); | |
| 1248 | + expect(response.stopReason).toBe("end_turn"); | |
| 1249 | + }), | |
| 360 | 1250 | ); |
| 361 | 1251 | |
| 362 | − it.effect("ignores stale xAI completion from an already settled prompt", () => | |
| 1252 | + it.effect("ignores turn_completed for non-pending prompt ids and task completions", () => | |
| 363 | 1253 | Effect.gen(function* () { |
| 364 | − const runtime = yield* makePromptCompletionRuntime({ | |
| 365 | − T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG: "1", | |
| 366 | − }); | |
| 367 | − yield* runtime.start(); | |
| 1254 | + const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); | |
| 1255 | + const hungPrompt = yield* Deferred.make<never>(); | |
| 1256 | + const baseRuntime = { | |
| 1257 | + start: () => | |
| 1258 | + Effect.succeed({ | |
| 1259 | + sessionId: "root-session", | |
| 1260 | + initializeResult: {}, | |
| 1261 | + sessionSetupResult: {}, | |
| 1262 | + modelConfigId: undefined, | |
| 1263 | + }), | |
| 1264 | + prompt: () => Deferred.await(hungPrompt), | |
| 1265 | + cancel: Effect.void, | |
| 1266 | + handleExtNotification: ( | |
| 1267 | + method: string, | |
| 1268 | + _schema: unknown, | |
| 1269 | + handler: (notification: unknown) => Effect.Effect<void>, | |
| 1270 | + ) => { | |
| 1271 | + handlers.set(method, handler); | |
| 1272 | + return Effect.void; | |
| 1273 | + }, | |
| 1274 | + handleExtRequest: () => Effect.void, | |
| 1275 | + } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; | |
| 368 | 1276 | |
| 369 | − const firstPromptResult = yield* runtime.prompt({ | |
| 370 | − prompt: [{ type: "text", text: "first" }], | |
| 1277 | + const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); | |
| 1278 | + const promptFiber = yield* runtime | |
| 1279 | + .prompt({ prompt: [{ type: "text", text: "hi" }] }) | |
| 1280 | + .pipe(Effect.forkChild); | |
| 1281 | + yield* Effect.yieldNow; | |
| 1282 | + const sessionUpdateHandler = handlers.get("_x.ai/session/update"); | |
| 1283 | + expect(sessionUpdateHandler).toBeDefined(); | |
| 1284 | + yield* sessionUpdateHandler!({ | |
| 1285 | + sessionId: "root-session", | |
| 1286 | + update: { | |
| 1287 | + sessionUpdate: "turn_completed", | |
| 1288 | + prompt_id: "task-completed-call-abc", | |
| 1289 | + stop_reason: "end_turn", | |
| 1290 | + }, | |
| 371 | 1291 | }); |
| 372 | − expect(firstPromptResult).toMatchObject({ | |
| 373 | − stopReason: "end_turn", | |
| 374 | − _meta: { promptId: "mock-stale-xai-prompt-1" }, | |
| 1292 | + yield* sessionUpdateHandler!({ | |
| 1293 | + sessionId: "root-session", | |
| 1294 | + update: { | |
| 1295 | + sessionUpdate: "turn_completed", | |
| 1296 | + prompt_id: "some-other-cli-turn", | |
| 1297 | + stop_reason: "end_turn", | |
| 1298 | + }, | |
| 375 | 1299 | }); |
| 1300 | + yield* Effect.yieldNow; | |
| 1301 | + expect(promptFiber.pollUnsafe()).toBeUndefined(); | |
| 1302 | + yield* Fiber.interrupt(promptFiber); | |
| 1303 | + }), | |
| 1304 | + ); | |
| 1305 | + | |
| 1306 | + it.effect("ignores prompt_complete notifications for foreign session ids", () => | |
| 1307 | + Effect.gen(function* () { | |
| 1308 | + const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); | |
| 1309 | + const hungPrompt = yield* Deferred.make<never>(); | |
| 1310 | + const baseRuntime = { | |
| 1311 | + start: () => | |
| 1312 | + Effect.succeed({ | |
| 1313 | + sessionId: "root-session", | |
| 1314 | + initializeResult: {}, | |
| 1315 | + sessionSetupResult: {}, | |
| 1316 | + modelConfigId: undefined, | |
| 1317 | + }), | |
| 1318 | + prompt: () => Deferred.await(hungPrompt), | |
| 1319 | + cancel: Effect.void, | |
| 1320 | + handleExtNotification: ( | |
| 1321 | + method: string, | |
| 1322 | + _schema: unknown, | |
| 1323 | + handler: (notification: unknown) => Effect.Effect<void>, | |
| 1324 | + ) => { | |
| 1325 | + handlers.set(method, handler); | |
| 1326 | + return Effect.void; | |
| 1327 | + }, | |
| 1328 | + handleExtRequest: () => Effect.void, | |
| 1329 | + } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; | |
| 376 | 1330 | |
| 377 | − const secondPromptResult = yield* runtime.prompt({ | |
| 378 | − prompt: [{ type: "text", text: "second" }], | |
| 1331 | + const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); | |
| 1332 | + const promptFiber = yield* runtime | |
| 1333 | + .prompt({ prompt: [{ type: "text", text: "hi" }] }) | |
| 1334 | + .pipe(Effect.forkChild); | |
| 1335 | + yield* Effect.yieldNow; | |
| 1336 | + const promptCompleteHandler = handlers.get("_x.ai/session/prompt_complete"); | |
| 1337 | + expect(promptCompleteHandler).toBeDefined(); | |
| 1338 | + yield* promptCompleteHandler!({ | |
| 1339 | + sessionId: "child-session", | |
| 379 | 1340 | }); |
| 380 | − const secondPromptId = secondPromptResult._meta?.promptId; | |
| 381 | − expect(typeof secondPromptId).toBe("string"); | |
| 382 | − expect(secondPromptId).not.toBe("mock-stale-xai-prompt-1"); | |
| 383 | − expect(secondPromptResult).toMatchObject({ | |
| 384 | − stopReason: "end_turn", | |
| 385 | − _meta: { | |
| 386 | − promptId: secondPromptId, | |
| 387 | − requestId: secondPromptId, | |
| 1341 | + yield* Effect.yieldNow; | |
| 1342 | + expect(promptFiber.pollUnsafe()).toBeUndefined(); | |
| 1343 | + yield* Fiber.interrupt(promptFiber); | |
| 1344 | + }), | |
| 1345 | + ); | |
| 1346 | + | |
| 1347 | + it.effect( | |
| 1348 | + "ignores promptId-less prompt_complete when multiple prompts are pending on the same session", | |
| 1349 | + () => | |
| 1350 | + Effect.gen(function* () { | |
| 1351 | + const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); | |
| 1352 | + const hungPrompt = yield* Deferred.make<never>(); | |
| 1353 | + const baseRuntime = { | |
| 1354 | + start: () => | |
| 1355 | + Effect.succeed({ | |
| 1356 | + sessionId: "root-session", | |
| 1357 | + initializeResult: {}, | |
| 1358 | + sessionSetupResult: {}, | |
| 1359 | + modelConfigId: undefined, | |
| 1360 | + }), | |
| 1361 | + prompt: () => Deferred.await(hungPrompt), | |
| 1362 | + cancel: Effect.void, | |
| 1363 | + handleExtNotification: ( | |
| 1364 | + method: string, | |
| 1365 | + _schema: unknown, | |
| 1366 | + handler: (notification: unknown) => Effect.Effect<void>, | |
| 1367 | + ) => { | |
| 1368 | + handlers.set(method, handler); | |
| 1369 | + return Effect.void; | |
| 1370 | + }, | |
| 1371 | + handleExtRequest: () => Effect.void, | |
| 1372 | + } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; | |
| 1373 | + | |
| 1374 | + const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); | |
| 1375 | + const firstPromptFiber = yield* runtime | |
| 1376 | + .prompt({ prompt: [{ type: "text", text: "first" }] }) | |
| 1377 | + .pipe(Effect.forkChild); | |
| 1378 | + yield* Effect.yieldNow; | |
| 1379 | + const secondPromptFiber = yield* runtime | |
| 1380 | + .prompt({ prompt: [{ type: "text", text: "second" }] }) | |
| 1381 | + .pipe(Effect.forkChild); | |
| 1382 | + yield* Effect.yieldNow; | |
| 1383 | + const promptCompleteHandler = handlers.get("_x.ai/session/prompt_complete"); | |
| 1384 | + expect(promptCompleteHandler).toBeDefined(); | |
| 1385 | + yield* promptCompleteHandler!({ | |
| 1386 | + sessionId: "root-session", | |
| 1387 | + stopReason: "end_turn", | |
| 1388 | + }); | |
| 1389 | + yield* Effect.yieldNow; | |
| 1390 | + expect(firstPromptFiber.pollUnsafe()).toBeUndefined(); | |
| 1391 | + expect(secondPromptFiber.pollUnsafe()).toBeUndefined(); | |
| 1392 | + yield* Fiber.interrupt(firstPromptFiber); | |
| 1393 | + yield* Fiber.interrupt(secondPromptFiber); | |
| 1394 | + }), | |
| 1395 | + ); | |
| 1396 | + | |
| 1397 | + it.effect("injects promptId and requestId into prompt _meta", () => | |
| 1398 | + Effect.gen(function* () { | |
| 1399 | + let capturedMeta: Record<string, unknown> | null | undefined; | |
| 1400 | + const baseRuntime = { | |
| 1401 | + start: () => Effect.succeed({ sessionId: "session-1" }), | |
| 1402 | + prompt: (payload: { readonly _meta?: Record<string, unknown> | null }) => { | |
| 1403 | + capturedMeta = payload._meta ?? null; | |
| 1404 | + return Effect.succeed({ stopReason: "end_turn" as const }); | |
| 388 | 1405 | }, |
| 1406 | + cancel: Effect.void, | |
| 1407 | + handleExtNotification: () => Effect.void, | |
| 1408 | + handleExtRequest: () => Effect.void, | |
| 1409 | + } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; | |
| 1410 | + | |
| 1411 | + const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); | |
| 1412 | + yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }] }); | |
| 1413 | + | |
| 1414 | + expect(typeof capturedMeta?.promptId).toBe("string"); | |
| 1415 | + expect(capturedMeta).toMatchObject({ | |
| 1416 | + promptId: capturedMeta?.promptId, | |
| 1417 | + requestId: capturedMeta?.promptId, | |
| 389 | 1418 | }); |
| 390 | − }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 1419 | + }), | |
| 391 | 1420 | ); |
| 392 | 1421 | }); |
| @@ -1,16 +1,19 @@ | ||
| 1 | −import * as NodeOS from "node:os"; | |
| 2 | − | |
| 3 | 1 | import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; |
| 2 | +import * as Cause from "effect/Cause"; | |
| 4 | 3 | import * as Deferred from "effect/Deferred"; |
| 5 | 4 | import * as Effect from "effect/Effect"; |
| 5 | +import * as Fiber from "effect/Fiber"; | |
| 6 | 6 | import * as Ref from "effect/Ref"; |
| 7 | 7 | import * as Schema from "effect/Schema"; |
| 8 | −import * as EffectAcpErrors from "effect-acp/errors"; | |
| 8 | +import type * as EffectAcpErrors from "effect-acp/errors"; | |
| 9 | 9 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 10 | 10 | |
| 11 | 11 | import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| 12 | 12 | import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; |
| 13 | 13 | |
| 14 | +const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; | |
| 15 | +const completedXAiPromptIdLimit = 128; | |
| 16 | + | |
| 14 | 17 | const XAiPromptCompleteNotification = Schema.Struct({ |
| 15 | 18 | sessionId: Schema.String, |
| 16 | 19 | promptId: Schema.optional(Schema.String), |
| @@ -20,19 +23,60 @@ const XAiPromptCompleteNotification = Schema.Struct({ · 3 unmodified lines | ||
| 20 | 23 | |
| 21 | 24 | type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type; |
| 22 | 25 | |
| 26 | +/** | |
| 27 | + * Live Grok CLI (0.2.x) emits root turn completion as an extension session | |
| 28 | + * update, not as top-level `_x.ai/session/prompt_complete`: | |
| 29 | + * `{ method: "_x.ai/session/update", params: { sessionId, update: { | |
| 30 | + * sessionUpdate: "turn_completed", prompt_id, stop_reason } } }`. | |
| 31 | + */ | |
| 32 | +const XAiSessionUpdateNotification = Schema.Struct({ | |
| 33 | + sessionId: Schema.String, | |
| 34 | + update: Schema.Struct({ | |
| 35 | + sessionUpdate: Schema.String, | |
| 36 | + prompt_id: Schema.optional(Schema.String), | |
| 37 | + promptId: Schema.optional(Schema.String), | |
| 38 | + stop_reason: Schema.optional(Schema.String), | |
| 39 | + stopReason: Schema.optional(Schema.String), | |
| 40 | + }), | |
| 41 | + _meta: Schema.optional(Schema.Unknown), | |
| 42 | +}); | |
| 43 | + | |
| 44 | +type XAiSessionUpdateNotification = typeof XAiSessionUpdateNotification.Type; | |
| 45 | + | |
| 46 | +const XAI_TASK_COMPLETED_PROMPT_ID_PREFIX = "task-completed-"; | |
| 47 | + | |
| 48 | +/** | |
| 49 | + * Map a Grok `_x.ai/session/update` payload to a prompt-complete shape when it | |
| 50 | + * is a root `turn_completed` for a real prompt id. Returns null for hooks, | |
| 51 | + * background task completions, and other update kinds. | |
| 52 | + */ | |
| 53 | +export function xAiPromptCompleteFromSessionUpdate( | |
| 54 | + notification: XAiSessionUpdateNotification, | |
| 55 | +): XAiPromptCompleteNotification | null { | |
| 56 | + const update = notification.update; | |
| 57 | + if (update.sessionUpdate !== "turn_completed") { | |
| 58 | + return null; | |
| 59 | + } | |
| 60 | + const promptId = nonEmptyString(update.prompt_id) ?? nonEmptyString(update.promptId); | |
| 61 | + // Require prompt id so we never session-fallback match a background task | |
| 62 | + // completion (`task-completed-*`) or an unrelated CLI turn. | |
| 63 | + if (promptId === undefined || promptId.startsWith(XAI_TASK_COMPLETED_PROMPT_ID_PREFIX)) { | |
| 64 | + return null; | |
| 65 | + } | |
| 66 | + const stopReason = nonEmptyString(update.stop_reason) ?? nonEmptyString(update.stopReason); | |
| 67 | + return { | |
| 68 | + sessionId: notification.sessionId, | |
| 69 | + promptId, | |
| 70 | + ...(stopReason === undefined ? {} : { stopReason }), | |
| 71 | + }; | |
| 72 | +} | |
| 73 | + | |
| 23 | 74 | interface PendingXAiPromptCompletion { |
| 24 | 75 | readonly sessionId: string; |
| 25 | 76 | readonly promptId: string; |
| 26 | − readonly deferred: Deferred.Deferred< | |
| 27 | − EffectAcpSchema.PromptResponse, | |
| 28 | − EffectAcpErrors.AcpRequestError | |
| 29 | − >; | |
| 77 | + readonly deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse>; | |
| 30 | 78 | } |
| 31 | 79 | |
| 32 | −const completedXAiPromptIdLimit = 128; | |
| 33 | −const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; | |
| 34 | −const xAiRateLimitedErrorCode = -32003; | |
| 35 | − | |
| 36 | 80 | export interface XAiAcpSubagentUpdate { |
| 37 | 81 | readonly nativeTaskId: string; |
| 38 | 82 | readonly prompt: string; |
| @@ -41,8 +85,26 @@ export interface XAiAcpSubagentUpdate { · 2 unmodified lines | ||
| 41 | 85 | readonly status: "running" | "completed" | "failed"; |
| 42 | 86 | readonly childSessionId: string | null; |
| 43 | 87 | readonly result: string | null; |
| 88 | + /** | |
| 89 | + * When false, the ACP adapter also projects a normal tool turn item for this | |
| 90 | + * tool call (used for get_command_or_subagent_output hydration). Default true. | |
| 91 | + */ | |
| 92 | + readonly suppressNormalTool?: boolean; | |
| 93 | +} | |
| 94 | + | |
| 95 | +export interface XAiAcpBackgroundToolMutation { | |
| 96 | + readonly taskId: string; | |
| 97 | + readonly status: "running" | "completed" | "failed"; | |
| 98 | + readonly appendOutput: string; | |
| 99 | +} | |
| 100 | + | |
| 101 | +export interface XAiAcpSubagentEndNotice { | |
| 102 | + readonly childSessionId: string; | |
| 103 | + readonly status: "completed" | "failed"; | |
| 44 | 104 | } |
| 45 | 105 | |
| 106 | +const XAI_UUID_RE = "[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}"; | |
| 107 | + | |
| 46 | 108 | function unknownRecord(value: unknown): Record<string, unknown> | undefined { |
| 47 | 109 | return typeof value === "object" && value !== null && !Array.isArray(value) |
| 48 | 110 | ? (value as Record<string, unknown>) |
| @@ -53,10 +115,42 @@ function nonEmptyString(value: unknown): string | undefined { · 4 unmodified lines | ||
| 53 | 115 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; |
| 54 | 116 | } |
| 55 | 117 | |
| 56 | −function xAiTaskOutputText(toolCall: AcpToolCallState): string | undefined { | |
| 118 | +function titleKey(toolCall: AcpToolCallState): string { | |
| 119 | + return (toolCall.title ?? "").trim().toLowerCase(); | |
| 120 | +} | |
| 121 | + | |
| 122 | +function decodeByteText(value: unknown): string | undefined { | |
| 123 | + if (typeof value === "string") return nonEmptyString(value); | |
| 124 | + if (!Array.isArray(value)) return undefined; | |
| 125 | + if (value.length === 0) return undefined; | |
| 126 | + if (!value.every((entry) => typeof entry === "number")) return undefined; | |
| 127 | + try { | |
| 128 | + return nonEmptyString(new TextDecoder().decode(Uint8Array.from(value as number[]))); | |
| 129 | + } catch { | |
| 130 | + return undefined; | |
| 131 | + } | |
| 132 | +} | |
| 133 | + | |
| 134 | +function xAiToolOutputText(toolCall: AcpToolCallState): string | undefined { | |
| 57 | 135 | const rawOutput = unknownRecord(toolCall.data.rawOutput); |
| 58 | − const rawOutputText = nonEmptyString(rawOutput?.text); | |
| 59 | − if (rawOutputText !== undefined) return rawOutputText; | |
| 136 | + if (rawOutput !== undefined) { | |
| 137 | + const direct = | |
| 138 | + nonEmptyString(rawOutput.text) ?? | |
| 139 | + nonEmptyString(rawOutput.output_for_prompt) ?? | |
| 140 | + decodeByteText(rawOutput.output); | |
| 141 | + if (direct !== undefined) return direct; | |
| 142 | + const result = unknownRecord(rawOutput.Result) ?? unknownRecord(rawOutput.result); | |
| 143 | + if (result !== undefined) { | |
| 144 | + const nested = | |
| 145 | + nonEmptyString(result.output) ?? | |
| 146 | + nonEmptyString(result.text) ?? | |
| 147 | + decodeByteText(result.output); | |
| 148 | + if (nested !== undefined) return nested; | |
| 149 | + } | |
| 150 | + } | |
| 151 | + if (typeof toolCall.data.rawOutput === "string") { | |
| 152 | + return nonEmptyString(toolCall.data.rawOutput); | |
| 153 | + } | |
| 60 | 154 | const content = toolCall.data.content; |
| 61 | 155 | if (!Array.isArray(content)) return undefined; |
| 62 | 156 | const text = content |
| @@ -70,41 +164,631 @@ function xAiTaskOutputText(toolCall: AcpToolCallState): string | undefined { · 7 unmodified lines | ||
| 70 | 164 | return text.length > 0 ? text : undefined; |
| 71 | 165 | } |
| 72 | 166 | |
| 167 | +function firstUuidMatch(text: string, pattern: RegExp): string | undefined { | |
| 168 | + const match = pattern.exec(text); | |
| 169 | + return match?.[1]; | |
| 170 | +} | |
| 171 | + | |
| 172 | +function extractXAiChildSessionId(output: string | undefined): string | null { | |
| 173 | + if (output === undefined) return null; | |
| 174 | + return ( | |
| 175 | + firstUuidMatch(output, new RegExp(`(?:^|\\n)\\s*Agent ID:\\s*(${XAI_UUID_RE})\\b`, "i")) ?? | |
| 176 | + firstUuidMatch(output, new RegExp(`(?:^|\\n)\\s*subagent_id:\\s*(${XAI_UUID_RE})\\b`, "i")) ?? | |
| 177 | + firstUuidMatch(output, new RegExp(`===\\s*Task\\s+(${XAI_UUID_RE})\\s*===`, "i")) ?? | |
| 178 | + null | |
| 179 | + ); | |
| 180 | +} | |
| 181 | + | |
| 182 | +function isXAiAsyncSpawnAck(output: string | undefined): boolean { | |
| 183 | + if (output === undefined) return false; | |
| 184 | + if (/subagent started in background/i.test(output)) return true; | |
| 185 | + return ( | |
| 186 | + new RegExp(`subagent_id:\\s*${XAI_UUID_RE}`, "i").test(output) && | |
| 187 | + /get_command_or_subagent_output/i.test(output) | |
| 188 | + ); | |
| 189 | +} | |
| 190 | + | |
| 191 | +function isXAiMonitorStartAck(output: string | undefined): boolean { | |
| 192 | + if (output === undefined) return false; | |
| 193 | + return /monitor started\s*\(\s*task\s+/i.test(output); | |
| 194 | +} | |
| 195 | + | |
| 196 | +function isXAiSpawnOrTaskTool( | |
| 197 | + toolCall: AcpToolCallState, | |
| 198 | + rawInput: Record<string, unknown> | undefined, | |
| 199 | +): boolean { | |
| 200 | + const title = titleKey(toolCall); | |
| 201 | + if (title === "task" || title === "spawn_subagent" || title.includes("spawn subagent")) { | |
| 202 | + return true; | |
| 203 | + } | |
| 204 | + const variant = nonEmptyString(rawInput?.variant)?.toLowerCase(); | |
| 205 | + if (variant === "cursortask" || variant === "task" || variant === "spawn_subagent") { | |
| 206 | + return true; | |
| 207 | + } | |
| 208 | + if (nonEmptyString(rawInput?.subagent_type) !== undefined) return true; | |
| 209 | + if (nonEmptyString(rawInput?.subagentType) !== undefined) return true; | |
| 210 | + return false; | |
| 211 | +} | |
| 212 | + | |
| 213 | +function isXAiGetSubagentOutputTool( | |
| 214 | + toolCall: AcpToolCallState, | |
| 215 | + rawInput: Record<string, unknown> | undefined, | |
| 216 | +): boolean { | |
| 217 | + const title = titleKey(toolCall); | |
| 218 | + if ( | |
| 219 | + title === "get_command_or_subagent_output" || | |
| 220 | + title.includes("get_command_or_subagent_output") || | |
| 221 | + title === "wait_commands_or_subagents" | |
| 222 | + ) { | |
| 223 | + return true; | |
| 224 | + } | |
| 225 | + const variant = nonEmptyString(rawInput?.variant)?.toLowerCase(); | |
| 226 | + if (variant === "taskoutput" || variant === "task_output") return true; | |
| 227 | + const rawOutput = unknownRecord(toolCall.data.rawOutput); | |
| 228 | + const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase(); | |
| 229 | + if (outputType === "taskoutput" || outputType === "task_output") return true; | |
| 230 | + // ACP often titles this tool with the finished subagent label. | |
| 231 | + if (title.startsWith("[subagent:") && Array.isArray(rawInput?.task_ids)) return true; | |
| 232 | + return false; | |
| 233 | +} | |
| 234 | + | |
| 235 | +export function isXAiMonitorTool(toolCall: AcpToolCallState): boolean { | |
| 236 | + const title = titleKey(toolCall); | |
| 237 | + if (title === "monitor" || title.startsWith("monitor ")) return true; | |
| 238 | + const rawInput = unknownRecord(toolCall.data.rawInput); | |
| 239 | + const variant = nonEmptyString(rawInput?.variant)?.toLowerCase(); | |
| 240 | + if (variant === "monitor") return true; | |
| 241 | + // Live start ACKs often keep a generic title ("Tool") and only mark the | |
| 242 | + // structured envelope as Monitor; title rewrite to description must not | |
| 243 | + // prevent monitor registration / inProgress forcing. | |
| 244 | + const rawOutput = unknownRecord(toolCall.data.rawOutput); | |
| 245 | + const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase(); | |
| 246 | + return outputType === "monitor"; | |
| 247 | +} | |
| 248 | + | |
| 249 | +export function extractXAiMonitorTaskId(toolCall: AcpToolCallState): string | undefined { | |
| 250 | + if (!isXAiMonitorTool(toolCall)) return undefined; | |
| 251 | + const rawOutput = unknownRecord(toolCall.data.rawOutput); | |
| 252 | + // Live ACP start ACK: { type: "Monitor", taskId, timeoutMs, persistent } | |
| 253 | + if (rawOutput !== undefined && nonEmptyString(rawOutput.type)?.toLowerCase() === "monitor") { | |
| 254 | + const structured = nonEmptyString(rawOutput.taskId) ?? nonEmptyString(rawOutput.task_id); | |
| 255 | + if (structured !== undefined) return structured; | |
| 256 | + } | |
| 257 | + const output = xAiToolOutputText(toolCall); | |
| 258 | + if (output !== undefined) { | |
| 259 | + const fromAck = firstUuidMatch( | |
| 260 | + output, | |
| 261 | + new RegExp(`monitor started\\s*\\(\\s*task\\s+(${XAI_UUID_RE})\\b`, "i"), | |
| 262 | + ); | |
| 263 | + if (fromAck !== undefined) return fromAck; | |
| 264 | + } | |
| 265 | + const rawInput = unknownRecord(toolCall.data.rawInput); | |
| 266 | + return ( | |
| 267 | + nonEmptyString(rawInput?.task_id) ?? | |
| 268 | + nonEmptyString(rawInput?.taskId) ?? | |
| 269 | + (Array.isArray(rawInput?.task_ids) ? nonEmptyString(rawInput.task_ids[0]) : undefined) | |
| 270 | + ); | |
| 271 | +} | |
| 272 | + | |
| 273 | +/** | |
| 274 | + * Live Monitor start ACK `{ type: "Monitor", persistent: true }` (or matching | |
| 275 | + * rawInput). Persistent monitors should not hold root-turn deferred finalize. | |
| 276 | + */ | |
| 277 | +export function isXAiPersistentMonitor(toolCall: AcpToolCallState): boolean { | |
| 278 | + if (!isXAiMonitorTool(toolCall)) return false; | |
| 279 | + const rawOutput = unknownRecord(toolCall.data.rawOutput); | |
| 280 | + if (rawOutput !== undefined && nonEmptyString(rawOutput.type)?.toLowerCase() === "monitor") { | |
| 281 | + return rawOutput.persistent === true; | |
| 282 | + } | |
| 283 | + const rawInput = unknownRecord(toolCall.data.rawInput); | |
| 284 | + return rawInput?.persistent === true; | |
| 285 | +} | |
| 286 | + | |
| 287 | +/** | |
| 288 | + * When get_command_or_subagent_output / TaskOutput completes registered monitor | |
| 289 | + * task(s), return hydration for each completed background tool id. | |
| 290 | + */ | |
| 291 | +export function extractXAiBackgroundTaskCompletion(toolCall: AcpToolCallState): ReadonlyArray<{ | |
| 292 | + readonly taskId: string; | |
| 293 | + readonly status: "running" | "completed" | "failed"; | |
| 294 | + readonly appendOutput: string; | |
| 295 | +}> { | |
| 296 | + const rawInput = unknownRecord(toolCall.data.rawInput); | |
| 297 | + if (!isXAiGetSubagentOutputTool(toolCall, rawInput)) return []; | |
| 298 | + // Caller matches taskId against registered background tools (monitors). | |
| 299 | + // Subagent hydration stays on extractXAiAcpSubagentUpdate. | |
| 300 | + const text = xAiToolOutputText(toolCall); | |
| 301 | + const status = statusFromGetOutputTool(toolCall, text); | |
| 302 | + const appendOutput = resultFromGetOutputTool(toolCall, text) ?? ""; | |
| 303 | + // Prefer the completed identity from the result/header over the request list | |
| 304 | + // (one call can poll multiple task_ids). | |
| 305 | + const resultTaskId = resultTaskIdFromGetOutputTool(toolCall, text); | |
| 306 | + if (resultTaskId !== undefined) { | |
| 307 | + return [{ taskId: resultTaskId, status, appendOutput }]; | |
| 308 | + } | |
| 309 | + const requestIds: string[] = []; | |
| 310 | + const push = (value: unknown) => { | |
| 311 | + const id = nonEmptyString(value); | |
| 312 | + if (id !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(id)) { | |
| 313 | + requestIds.push(id); | |
| 314 | + } | |
| 315 | + }; | |
| 316 | + if (Array.isArray(rawInput?.task_ids)) { | |
| 317 | + for (const entry of rawInput.task_ids) push(entry); | |
| 318 | + } | |
| 319 | + push(rawInput?.task_id); | |
| 320 | + push(rawInput?.taskId); | |
| 321 | + const unique = [...new Set(requestIds)]; | |
| 322 | + return unique.map((taskId) => ({ taskId, status, appendOutput })); | |
| 323 | +} | |
| 324 | + | |
| 325 | +/** | |
| 326 | + * A finished kill_command_or_subagent call is the genuine end signal for every | |
| 327 | + * task id it names: the Grok CLI emits no `_x.ai/task_completed` for a killed | |
| 328 | + * task. A failed kill usually means the task was already gone, so it ends the | |
| 329 | + * id too (failing open to a continuation beats pinning the running set). | |
| 330 | + */ | |
| 331 | +export function extractXAiKilledBackgroundTasks(toolCall: AcpToolCallState): ReadonlyArray<{ | |
| 332 | + readonly taskId: string; | |
| 333 | + readonly status: "running" | "completed" | "failed"; | |
| 334 | + readonly appendOutput: string; | |
| 335 | +}> { | |
| 336 | + const title = titleKey(toolCall); | |
| 337 | + const rawInput = unknownRecord(toolCall.data.rawInput); | |
| 338 | + const variant = nonEmptyString(rawInput?.variant)?.toLowerCase(); | |
| 339 | + if (!title.includes("kill_command_or_subagent") && variant !== "kill") return []; | |
| 340 | + if (toolCall.status !== "completed" && toolCall.status !== "failed") return []; | |
| 341 | + const taskIds: string[] = []; | |
| 342 | + const push = (value: unknown) => { | |
| 343 | + const id = nonEmptyString(value); | |
| 344 | + if (id !== undefined) taskIds.push(id); | |
| 345 | + }; | |
| 346 | + if (Array.isArray(rawInput?.task_ids)) { | |
| 347 | + for (const entry of rawInput.task_ids) push(entry); | |
| 348 | + } | |
| 349 | + push(rawInput?.task_id); | |
| 350 | + push(rawInput?.taskId); | |
| 351 | + return [...new Set(taskIds)].map((taskId) => ({ | |
| 352 | + taskId, | |
| 353 | + status: "completed" as const, | |
| 354 | + appendOutput: "", | |
| 355 | + })); | |
| 356 | +} | |
| 357 | + | |
| 358 | +const XAiTaskLifecycleNotification = Schema.Struct({ | |
| 359 | + sessionId: Schema.String, | |
| 360 | + update: Schema.Struct({ | |
| 361 | + sessionUpdate: Schema.String, | |
| 362 | + task_id: Schema.optional(Schema.String), | |
| 363 | + tool_call_id: Schema.optional(Schema.String), | |
| 364 | + task_snapshot: Schema.optional( | |
| 365 | + Schema.Struct({ | |
| 366 | + task_id: Schema.optional(Schema.String), | |
| 367 | + }), | |
| 368 | + ), | |
| 369 | + }), | |
| 370 | + _meta: Schema.optional(Schema.Unknown), | |
| 371 | +}); | |
| 372 | + | |
| 373 | +type XAiTaskLifecycleNotification = typeof XAiTaskLifecycleNotification.Type; | |
| 374 | + | |
| 375 | +export interface XAiBackgroundTaskLifecycleMutation { | |
| 376 | + readonly sessionId: string; | |
| 377 | + readonly taskId: string; | |
| 378 | + readonly status: "running" | "completed"; | |
| 379 | +} | |
| 380 | + | |
| 381 | +export function xAiBackgroundTaskLifecycleMutation( | |
| 382 | + notification: XAiTaskLifecycleNotification, | |
| 383 | + status: "running" | "completed", | |
| 384 | +): XAiBackgroundTaskLifecycleMutation | null { | |
| 385 | + const update = notification.update; | |
| 386 | + const taskId = | |
| 387 | + nonEmptyString(update.task_snapshot?.task_id) ?? | |
| 388 | + nonEmptyString(update.task_id) ?? | |
| 389 | + nonEmptyString(update.tool_call_id); | |
| 390 | + if (taskId === undefined) return null; | |
| 391 | + return { sessionId: notification.sessionId, taskId, status }; | |
| 392 | +} | |
| 393 | + | |
| 394 | +/** | |
| 395 | + * Grok `session/cancel` is detach-and-continue: the CLI reaps the foreground | |
| 396 | + * tool shell, then re-runs the command as a background task | |
| 397 | + * (`_x.ai/task_backgrounded`) that executes to completion | |
| 398 | + * (`_x.ai/task_completed`). Soft interrupts keep the process alive, so those | |
| 399 | + * tasks must be tracked like monitors: residual frames buffer instead of | |
| 400 | + * waking synthetic continuations, and pending-background-work stays truthful | |
| 401 | + * until the task ends (or the model kills it via kill_command_or_subagent). | |
| 402 | + */ | |
| 403 | +export const registerXAiBackgroundTaskTracking = ( | |
| 404 | + runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "handleExtNotification">, | |
| 405 | + apply: (mutation: XAiBackgroundTaskLifecycleMutation) => Effect.Effect<void>, | |
| 406 | +): Effect.Effect<void, EffectAcpErrors.AcpError> => | |
| 407 | + Effect.gen(function* () { | |
| 408 | + yield* runtime.handleExtNotification( | |
| 409 | + "_x.ai/task_backgrounded", | |
| 410 | + XAiTaskLifecycleNotification, | |
| 411 | + (notification) => { | |
| 412 | + const mutation = xAiBackgroundTaskLifecycleMutation(notification, "running"); | |
| 413 | + return mutation === null ? Effect.void : apply(mutation); | |
| 414 | + }, | |
| 415 | + ); | |
| 416 | + yield* runtime.handleExtNotification( | |
| 417 | + "_x.ai/task_completed", | |
| 418 | + XAiTaskLifecycleNotification, | |
| 419 | + (notification) => { | |
| 420 | + const mutation = xAiBackgroundTaskLifecycleMutation(notification, "completed"); | |
| 421 | + return mutation === null ? Effect.void : apply(mutation); | |
| 422 | + }, | |
| 423 | + ); | |
| 424 | + }); | |
| 425 | + | |
| 426 | +/** | |
| 427 | + * Grok ACP often sends `title: "Tool"` even when rawInput has a useful | |
| 428 | + * description or variant (especially Monitor). Prefer description/variant so | |
| 429 | + * the timeline matches Claude-style tool names rather than a generic label. | |
| 430 | + */ | |
| 431 | +export function isGenericAcpToolTitle(title: string | undefined): boolean { | |
| 432 | + const key = (title ?? "").trim().toLowerCase(); | |
| 433 | + return key.length === 0 || key === "tool" || key === "tool call" || key === "terminal"; | |
| 434 | +} | |
| 435 | + | |
| 436 | +export function resolveXAiAcpToolTitle(toolCall: AcpToolCallState): string | undefined { | |
| 437 | + if (!isGenericAcpToolTitle(toolCall.title)) { | |
| 438 | + return nonEmptyString(toolCall.title); | |
| 439 | + } | |
| 440 | + const rawInput = unknownRecord(toolCall.data.rawInput); | |
| 441 | + const description = nonEmptyString(rawInput?.description); | |
| 442 | + const variant = nonEmptyString(rawInput?.variant)?.toLowerCase(); | |
| 443 | + if (description !== undefined) { | |
| 444 | + if (variant === "monitor" && !description.toLowerCase().startsWith("monitor")) { | |
| 445 | + return `Monitor: ${description}`; | |
| 446 | + } | |
| 447 | + return description; | |
| 448 | + } | |
| 449 | + if (variant === "monitor") return "Monitor"; | |
| 450 | + if (variant === "task" || variant === "cursortask" || variant === "spawn_subagent") { | |
| 451 | + return "Task"; | |
| 452 | + } | |
| 453 | + if (variant === "taskoutput" || variant === "task_output") return "Task output"; | |
| 454 | + if (variant !== undefined) { | |
| 455 | + return variant; | |
| 456 | + } | |
| 457 | + const command = | |
| 458 | + nonEmptyString(rawInput?.command) ?? | |
| 459 | + nonEmptyString(unknownRecord(toolCall.data.rawOutput)?.command); | |
| 460 | + if (command !== undefined) { | |
| 461 | + return command.length > 80 ? `${command.slice(0, 77)}...` : command; | |
| 462 | + } | |
| 463 | + return nonEmptyString(toolCall.title); | |
| 464 | +} | |
| 465 | + | |
| 466 | +/** | |
| 467 | + * Normalize Grok ACP tool presentation and monitor lifecycle: | |
| 468 | + * - replace generic titles ("Tool") with description / variant labels | |
| 469 | + * - structured Monitor start ACK stays running | |
| 470 | + * - text start ACK stays running | |
| 471 | + * - structured Bash results with exit_code are terminal (any tool: post-settle | |
| 472 | + * wake re-reports of a finished monitor arrive with empty rawInput and a | |
| 473 | + * generic title, so monitor detection cannot match; without this they replay | |
| 474 | + * as running and the timeline row spins forever) | |
| 475 | + */ | |
| 476 | +export function normalizeXAiAcpToolCallState(toolCall: AcpToolCallState): AcpToolCallState { | |
| 477 | + const resolvedTitle = resolveXAiAcpToolTitle(toolCall); | |
| 478 | + const withTitle = | |
| 479 | + resolvedTitle !== undefined && resolvedTitle !== toolCall.title | |
| 480 | + ? { ...toolCall, title: resolvedTitle } | |
| 481 | + : toolCall; | |
| 482 | + | |
| 483 | + const rawOutput = unknownRecord(withTitle.data.rawOutput); | |
| 484 | + const outputType = nonEmptyString(rawOutput?.type)?.toLowerCase(); | |
| 485 | + if (outputType === "bash") { | |
| 486 | + // Same key set as the adapter's command projection (commandExitCode). | |
| 487 | + const exitCode = ["exit_code", "exitCode", "code"] | |
| 488 | + .map((key) => rawOutput?.[key]) | |
| 489 | + .find((value) => typeof value === "number" && Number.isInteger(value)); | |
| 490 | + if (typeof exitCode === "number") { | |
| 491 | + if (exitCode === 0 && (xAiToolOutputText(withTitle)?.trim().length ?? 0) === 0) { | |
| 492 | + return { ...withTitle, status: "inProgress" }; | |
| 493 | + } | |
| 494 | + return { | |
| 495 | + ...withTitle, | |
| 496 | + status: exitCode === 0 ? "completed" : "failed", | |
| 497 | + }; | |
| 498 | + } | |
| 499 | + } | |
| 500 | + if (!isXAiMonitorTool(withTitle)) { | |
| 501 | + return withTitle; | |
| 502 | + } | |
| 503 | + if (outputType === "monitor") { | |
| 504 | + // Start registration only; process still running in the background. | |
| 505 | + return { ...withTitle, status: "inProgress" }; | |
| 506 | + } | |
| 507 | + const output = xAiToolOutputText(withTitle); | |
| 508 | + if (withTitle.status === "completed" && isXAiMonitorStartAck(output)) { | |
| 509 | + return { ...withTitle, status: "inProgress" }; | |
| 510 | + } | |
| 511 | + return withTitle; | |
| 512 | +} | |
| 513 | + | |
| 514 | +/** | |
| 515 | + * Parse Grok synthetic monitor traffic that arrives as root text chunks | |
| 516 | + * (`<monitor-event>` lines, batched `<monitor>` event blocks, and | |
| 517 | + * "Monitor ... ended" reminders). Coalesced chunks may contain several events; | |
| 518 | + * return every match so later progress / end notices are not dropped. | |
| 519 | + */ | |
| 520 | +export function extractXAiAcpBackgroundToolMutation( | |
| 521 | + text: string, | |
| 522 | +): ReadonlyArray<XAiAcpBackgroundToolMutation> { | |
| 523 | + const mutations: XAiAcpBackgroundToolMutation[] = []; | |
| 524 | + | |
| 525 | + const eventRe = new RegExp( | |
| 526 | + `<monitor-event\\s+task_id=["']?(${XAI_UUID_RE})["']?\\s*>\\s*([\\s\\S]*?)\\s*</monitor-event>`, | |
| 527 | + "gi", | |
| 528 | + ); | |
| 529 | + for (const eventMatch of text.matchAll(eventRe)) { | |
| 530 | + if (eventMatch[1] === undefined) continue; | |
| 531 | + const line = (eventMatch[2] ?? "").trim(); | |
| 532 | + mutations.push({ | |
| 533 | + taskId: eventMatch[1], | |
| 534 | + status: "running", | |
| 535 | + appendOutput: line.length > 0 ? `${line}\n` : "", | |
| 536 | + }); | |
| 537 | + } | |
| 538 | + | |
| 539 | + // Batched form: "N monitor events from 1 monitor ...:\n<monitor | |
| 540 | + // description="..." task_id="...">\n[1] line\n</monitor>". Still a running | |
| 541 | + // monitor; must track and filter like single-event chatter. | |
| 542 | + const batchedRe = new RegExp( | |
| 543 | + `<monitor\\s[^>]*task_id=["']?(${XAI_UUID_RE})["']?[^>]*>\\s*([\\s\\S]*?)\\s*</monitor>`, | |
| 544 | + "gi", | |
| 545 | + ); | |
| 546 | + for (const batchedMatch of text.matchAll(batchedRe)) { | |
| 547 | + if (batchedMatch[1] === undefined) continue; | |
| 548 | + const lines = (batchedMatch[2] ?? "").trim(); | |
| 549 | + mutations.push({ | |
| 550 | + taskId: batchedMatch[1], | |
| 551 | + status: "running", | |
| 552 | + appendOutput: lines.length > 0 ? `${lines}\n` : "", | |
| 553 | + }); | |
| 554 | + } | |
| 555 | + | |
| 556 | + const endedRe = new RegExp(`Monitor\\s+["']?(${XAI_UUID_RE})["']?\\s+ended`, "gi"); | |
| 557 | + for (const endedMatch of text.matchAll(endedRe)) { | |
| 558 | + if (endedMatch[1] === undefined) continue; | |
| 559 | + // Only the "Monitor … ended …" clause is outcome text. Description/output | |
| 560 | + // after the header can contain words like "error" without meaning failure. | |
| 561 | + const outcomeClause = (endedMatch[0] ?? "").trim(); | |
| 562 | + const afterHeader = text.slice((endedMatch.index ?? 0) + endedMatch[0].length); | |
| 563 | + const outcomeTail = afterHeader.split(/\n/)[0] ?? ""; | |
| 564 | + const outcome = `${outcomeClause}${outcomeTail}`; | |
| 565 | + const summary = text.replace(/<\/?system-reminder>/gi, "").trim(); | |
| 566 | + mutations.push({ | |
| 567 | + taskId: endedMatch[1], | |
| 568 | + status: | |
| 569 | + /exited\s*\(\s*code\s*0\s*\)/i.test(outcome) || /ended cleanly/i.test(outcome) | |
| 570 | + ? "completed" | |
| 571 | + : /exited|failed|error|signal/i.test(outcome) | |
| 572 | + ? "failed" | |
| 573 | + : "completed", | |
| 574 | + appendOutput: summary.length > 0 ? `${summary}\n` : "Monitor ended.\n", | |
| 575 | + }); | |
| 576 | + } | |
| 577 | + | |
| 578 | + return mutations; | |
| 579 | +} | |
| 580 | + | |
| 581 | +/** | |
| 582 | + * Parse Grok's root-session subagent end reminder ("Background subagent | |
| 583 | + * "<uuid>" (general-purpose: "...") completed successfully."). The agent is | |
| 584 | + * free to skip get_command_or_subagent_output hydration, so this reminder can | |
| 585 | + * be the only terminal signal for the subagent row; a row stuck on running | |
| 586 | + * holds deferred finalize (and the whole run) open until harness timeout. | |
| 587 | + */ | |
| 588 | +function stripLeadingBalancedParenGroup(text: string): string { | |
| 589 | + const trimmed = text.replace(/^\s+/, ""); | |
| 590 | + if (!trimmed.startsWith("(")) return trimmed; | |
| 591 | + let depth = 0; | |
| 592 | + for (let index = 0; index < trimmed.length; index += 1) { | |
| 593 | + const char = trimmed[index]; | |
| 594 | + if (char === "(") depth += 1; | |
| 595 | + else if (char === ")") { | |
| 596 | + depth -= 1; | |
| 597 | + if (depth === 0) return trimmed.slice(index + 1).replace(/^\s+/, ""); | |
| 598 | + } | |
| 599 | + } | |
| 600 | + return trimmed; | |
| 601 | +} | |
| 602 | + | |
| 603 | +export function extractXAiAcpSubagentEndNotice(text: string): XAiAcpSubagentEndNotice | undefined { | |
| 604 | + const match = text.match(new RegExp(`Background subagent\\s+["']?(${XAI_UUID_RE})["']?`, "i")); | |
| 605 | + if (match?.[1] === undefined) return undefined; | |
| 606 | + // Skip the parenthesized descriptor (subagent type and title) so its words | |
| 607 | + // cannot masquerade as the outcome verb. Balance parens so nested titles | |
| 608 | + // like `(general-purpose: "Check (backend) failed")` do not leak. | |
| 609 | + const tail = stripLeadingBalancedParenGroup(text.slice((match.index ?? 0) + match[0].length)); | |
| 610 | + if (/completed successfully/i.test(tail)) { | |
| 611 | + return { childSessionId: match[1], status: "completed" }; | |
| 612 | + } | |
| 613 | + if (/\b(failed|errored|error|crashed|cancelled|canceled|interrupted|timed out)\b/i.test(tail)) { | |
| 614 | + return { childSessionId: match[1], status: "failed" }; | |
| 615 | + } | |
| 616 | + if (/\b(completed|finished|ended)\b/i.test(tail)) { | |
| 617 | + return { childSessionId: match[1], status: "completed" }; | |
| 618 | + } | |
| 619 | + return undefined; | |
| 620 | +} | |
| 621 | + | |
| 622 | +function taskIdsFromGetOutputTool( | |
| 623 | + toolCall: AcpToolCallState, | |
| 624 | + rawInput: Record<string, unknown> | undefined, | |
| 625 | +): ReadonlyArray<string> { | |
| 626 | + const ids: string[] = []; | |
| 627 | + const push = (value: unknown) => { | |
| 628 | + const text = nonEmptyString(value); | |
| 629 | + if (text !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(text)) { | |
| 630 | + ids.push(text); | |
| 631 | + } | |
| 632 | + }; | |
| 633 | + if (Array.isArray(rawInput?.task_ids)) { | |
| 634 | + for (const entry of rawInput.task_ids) push(entry); | |
| 635 | + } | |
| 636 | + push(rawInput?.task_id); | |
| 637 | + push(rawInput?.taskId); | |
| 638 | + const resultId = resultTaskIdFromGetOutputTool(toolCall, xAiToolOutputText(toolCall)); | |
| 639 | + if (resultId !== undefined) ids.push(resultId); | |
| 640 | + return [...new Set(ids)]; | |
| 641 | +} | |
| 642 | + | |
| 643 | +/** Identity of the task the get_output envelope actually reports (not the request list). */ | |
| 644 | +function resultTaskIdFromGetOutputTool( | |
| 645 | + toolCall: AcpToolCallState, | |
| 646 | + output: string | undefined, | |
| 647 | +): string | undefined { | |
| 648 | + const rawOutput = unknownRecord(toolCall.data.rawOutput); | |
| 649 | + const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result); | |
| 650 | + const structured = | |
| 651 | + nonEmptyString(result?.task_id) ?? | |
| 652 | + nonEmptyString(result?.taskId) ?? | |
| 653 | + nonEmptyString(rawOutput?.task_id) ?? | |
| 654 | + nonEmptyString(rawOutput?.taskId); | |
| 655 | + if (structured !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(structured)) { | |
| 656 | + return structured; | |
| 657 | + } | |
| 658 | + if (output === undefined) return undefined; | |
| 659 | + const taskHeader = firstUuidMatch( | |
| 660 | + output, | |
| 661 | + new RegExp(`===\\s*Task\\s+(${XAI_UUID_RE})\\s*===`, "i"), | |
| 662 | + ); | |
| 663 | + if (taskHeader !== undefined) return taskHeader; | |
| 664 | + return firstUuidMatch(output, new RegExp(`subagent_id:\\s*(${XAI_UUID_RE})\\b`, "i")); | |
| 665 | +} | |
| 666 | + | |
| 667 | +function resultFromGetOutputTool( | |
| 668 | + toolCall: AcpToolCallState, | |
| 669 | + output: string | undefined, | |
| 670 | +): string | null { | |
| 671 | + const rawOutput = unknownRecord(toolCall.data.rawOutput); | |
| 672 | + const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result); | |
| 673 | + const structured = | |
| 674 | + nonEmptyString(result?.output) ?? | |
| 675 | + nonEmptyString(result?.text) ?? | |
| 676 | + decodeByteText(result?.output); | |
| 677 | + if (structured !== undefined) { | |
| 678 | + // Drop trailing machine meta blocks when present. | |
| 679 | + const cleaned = structured | |
| 680 | + .replace(/<subagent_meta>[\s\S]*?<\/subagent_meta>/gi, "") | |
| 681 | + .replace(/<subagent_result>[\s\S]*?<\/subagent_result>/gi, "") | |
| 682 | + .trim(); | |
| 683 | + if (cleaned.length > 0) return cleaned; | |
| 684 | + return null; | |
| 685 | + } | |
| 686 | + if (output === undefined) return null; | |
| 687 | + const marker = output.match(/=== Output ===\s*([\s\S]*)$/i); | |
| 688 | + const body = (marker?.[1] ?? output).trim(); | |
| 689 | + return body.length > 0 ? body : null; | |
| 690 | +} | |
| 691 | + | |
| 692 | +function statusFromGetOutputTool( | |
| 693 | + toolCall: AcpToolCallState, | |
| 694 | + output: string | undefined, | |
| 695 | +): "running" | "completed" | "failed" { | |
| 696 | + if (toolCall.status === "failed") return "failed"; | |
| 697 | + const rawOutput = unknownRecord(toolCall.data.rawOutput); | |
| 698 | + const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result); | |
| 699 | + const structuredStatus = nonEmptyString(result?.status)?.toLowerCase(); | |
| 700 | + if (structuredStatus === "completed" || structuredStatus === "success") return "completed"; | |
| 701 | + if (structuredStatus === "failed" || structuredStatus === "error") return "failed"; | |
| 702 | + if (structuredStatus === "running" || structuredStatus === "pending") return "running"; | |
| 703 | + if (typeof result?.exit_code === "number") { | |
| 704 | + return result.exit_code === 0 ? "completed" : "failed"; | |
| 705 | + } | |
| 706 | + if (output !== undefined) { | |
| 707 | + if (/Status:\s*failed/i.test(output) || /Exit Code:\s*-?(?!0\b)\d+/i.test(output)) { | |
| 708 | + return "failed"; | |
| 709 | + } | |
| 710 | + if (/Status:\s*completed/i.test(output) || /Status:\s*success/i.test(output)) { | |
| 711 | + return "completed"; | |
| 712 | + } | |
| 713 | + if (/Status:\s*running/i.test(output) || /Status:\s*pending/i.test(output)) { | |
| 714 | + return "running"; | |
| 715 | + } | |
| 716 | + } | |
| 717 | + return toolCall.status === "completed" ? "completed" : "running"; | |
| 718 | +} | |
| 719 | + | |
| 73 | 720 | /** |
| 74 | − * Recognizes Grok's private `Task` tool envelope without teaching the generic | |
| 75 | − * ACP adapter about xAI tool names or result formatting. | |
| 721 | + * Recognizes Grok Task / spawn_subagent envelopes and get_command hydration | |
| 722 | + * without teaching the generic ACP adapter about xAI tool names. | |
| 723 | + * | |
| 724 | + * Current Grok CLI returns spawn_subagent immediately with a background ACK | |
| 725 | + * (`subagent_id: ...`). That must stay `running` until child work finishes or | |
| 726 | + * get_command_or_subagent_output reports a terminal status. | |
| 76 | 727 | */ |
| 77 | 728 | export function extractXAiAcpSubagentUpdate( |
| 78 | 729 | toolCall: AcpToolCallState, |
| 79 | 730 | ): XAiAcpSubagentUpdate | undefined { |
| 80 | 731 | const rawInput = unknownRecord(toolCall.data.rawInput); |
| 81 | − const isTask = | |
| 82 | − toolCall.title === "Task" || | |
| 83 | − rawInput?.variant === "CursorTask" || | |
| 84 | − nonEmptyString(rawInput?.subagent_type) !== undefined; | |
| 85 | − if (!isTask || rawInput === undefined) return undefined; | |
| 86 | − | |
| 87 | − const output = xAiTaskOutputText(toolCall); | |
| 88 | − const childSessionId = | |
| 89 | − output?.match(/(?:^|\n)Agent ID:\s*([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\b/i)?.[1] ?? | |
| 90 | − null; | |
| 91 | − const result = | |
| 92 | − output?.replace(/(?:^|\n)Agent ID:\s*[0-9a-f-]+[^\n]*(?:\n|$)/gi, "\n").trim() || null; | |
| 93 | − const status = | |
| 94 | − toolCall.status === "failed" | |
| 95 | − ? "failed" | |
| 96 | − : toolCall.status === "completed" | |
| 97 | − ? "completed" | |
| 98 | − : "running"; | |
| 732 | + const output = xAiToolOutputText(toolCall); | |
| 733 | + | |
| 734 | + if (isXAiGetSubagentOutputTool(toolCall, rawInput)) { | |
| 735 | + const taskIds = taskIdsFromGetOutputTool(toolCall, rawInput); | |
| 736 | + // Prefer the completed task identity from the result/header over the first | |
| 737 | + // requested task_ids entry (one call can poll multiple task_ids). | |
| 738 | + const childSessionId = | |
| 739 | + resultTaskIdFromGetOutputTool(toolCall, output) ?? | |
| 740 | + taskIds[0] ?? | |
| 741 | + extractXAiChildSessionId(output); | |
| 742 | + if (childSessionId === null) return undefined; | |
| 743 | + // Prefer the durable subagent row as the completion surface (Claude/Codex | |
| 744 | + // style). Suppress the noisy TaskOutput tool card; monitor hydration is | |
| 745 | + // handled separately via extractBackgroundTaskCompletion. | |
| 746 | + return { | |
| 747 | + nativeTaskId: toolCall.toolCallId, | |
| 748 | + prompt: "", | |
| 749 | + title: null, | |
| 750 | + model: null, | |
| 751 | + status: statusFromGetOutputTool(toolCall, output), | |
| 752 | + childSessionId, | |
| 753 | + result: resultFromGetOutputTool(toolCall, output), | |
| 754 | + suppressNormalTool: true, | |
| 755 | + }; | |
| 756 | + } | |
| 757 | + | |
| 758 | + if (!isXAiSpawnOrTaskTool(toolCall, rawInput)) return undefined; | |
| 759 | + | |
| 760 | + const childSessionId = extractXAiChildSessionId(output); | |
| 761 | + const asyncSpawnAck = isXAiAsyncSpawnAck(output); | |
| 762 | + const legacyResult = | |
| 763 | + output | |
| 764 | + ?.replace(new RegExp(`(?:^|\\n)\\s*Agent ID:\\s*${XAI_UUID_RE}[^\\n]*(?:\\n|$)`, "gi"), "\n") | |
| 765 | + .replace( | |
| 766 | + new RegExp(`(?:^|\\n)\\s*subagent_id:\\s*${XAI_UUID_RE}[^\\n]*(?:\\n|$)`, "gi"), | |
| 767 | + "\n", | |
| 768 | + ) | |
| 769 | + .trim() || null; | |
| 770 | + | |
| 771 | + let status: "running" | "completed" | "failed"; | |
| 772 | + if (toolCall.status === "failed") { | |
| 773 | + status = "failed"; | |
| 774 | + } else if (asyncSpawnAck) { | |
| 775 | + // Spawn RPC completed, but the child is still running. | |
| 776 | + status = "running"; | |
| 777 | + } else if (toolCall.status === "completed") { | |
| 778 | + status = "completed"; | |
| 779 | + } else { | |
| 780 | + status = "running"; | |
| 781 | + } | |
| 99 | 782 | |
| 100 | 783 | return { |
| 101 | 784 | nativeTaskId: toolCall.toolCallId, |
| 102 | − prompt: nonEmptyString(rawInput.prompt) ?? "", | |
| 103 | − title: nonEmptyString(rawInput.description) ?? null, | |
| 104 | − model: nonEmptyString(rawInput.model) ?? null, | |
| 785 | + prompt: nonEmptyString(rawInput?.prompt) ?? "", | |
| 786 | + title: nonEmptyString(rawInput?.description) ?? null, | |
| 787 | + model: nonEmptyString(rawInput?.model) ?? null, | |
| 105 | 788 | status, |
| 106 | 789 | childSessionId, |
| 107 | − result, | |
| 790 | + result: asyncSpawnAck ? null : legacyResult, | |
| 791 | + suppressNormalTool: true, | |
| 108 | 792 | }; |
| 109 | 793 | } |
| 110 | 794 | |
| @@ -290,301 +974,57 @@ export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCan · 179 unmodified lines | ||
| 290 | 974 | return { outcome: "cancelled" }; |
| 291 | 975 | } |
| 292 | 976 | |
| 293 | −// --------------------------------------------------------------------------- | |
| 294 | −// x.ai/exit_plan_mode — plan approval gate (mirrors Grok Build TUI plan window) | |
| 295 | −// --------------------------------------------------------------------------- | |
| 296 | − | |
| 297 | −const XAiExitPlanModeParams = Schema.Struct({ | |
| 298 | − sessionId: Schema.String, | |
| 299 | − toolCallId: Schema.String, | |
| 300 | − planContent: Schema.optional(Schema.NullOr(Schema.String)), | |
| 301 | −}); | |
| 302 | − | |
| 303 | −const XAiWrappedExitPlanModeParams = Schema.Struct({ | |
| 304 | − method: Schema.Literals(["x.ai/exit_plan_mode", "_x.ai/exit_plan_mode"]), | |
| 305 | − params: XAiExitPlanModeParams, | |
| 306 | −}); | |
| 307 | − | |
| 308 | −export const XAiExitPlanModeRequest = Schema.Union([ | |
| 309 | − XAiExitPlanModeParams, | |
| 310 | − XAiWrappedExitPlanModeParams, | |
| 311 | −]); | |
| 312 | − | |
| 313 | −type XAiExitPlanModeRequestParams = typeof XAiExitPlanModeParams.Type; | |
| 314 | −type XAiExitPlanModeRequest = typeof XAiExitPlanModeRequest.Type; | |
| 315 | − | |
| 316 | −function unwrapExitPlanModeParams(params: XAiExitPlanModeRequest): XAiExitPlanModeRequestParams { | |
| 317 | − return "params" in params ? params.params : params; | |
| 318 | −} | |
| 319 | − | |
| 320 | −/** Empty-state copy when Grok exits plan mode without a plan file. */ | |
| 321 | −export const XAI_EMPTY_PLAN_MARKDOWN = | |
| 322 | − "# No plan written yet\n\n(The agent exited plan mode without writing a plan.)"; | |
| 323 | − | |
| 324 | −export function extractXAiExitPlanMarkdown( | |
| 325 | − params: XAiExitPlanModeRequest, | |
| 326 | − fallback?: string | null, | |
| 327 | −): string { | |
| 328 | − const content = unwrapExitPlanModeParams(params).planContent; | |
| 329 | − const fromRequest = typeof content === "string" ? trimmed(content) : undefined; | |
| 330 | − if (fromRequest) { | |
| 331 | − return fromRequest; | |
| 332 | − } | |
| 333 | − const fromFallback = fallback?.trim(); | |
| 334 | − if (fromFallback && fromFallback.length > 0) { | |
| 335 | − return fromFallback; | |
| 977 | +function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined { | |
| 978 | + const meta = response._meta; | |
| 979 | + if (meta === null || typeof meta !== "object") { | |
| 980 | + return undefined; | |
| 336 | 981 | } |
| 337 | − return XAI_EMPTY_PLAN_MARKDOWN; | |
| 338 | −} | |
| 339 | − | |
| 340 | −export type XAiExitPlanModeOutcome = "approved" | "abandoned" | "request_changes"; | |
| 341 | − | |
| 342 | −export interface XAiExitPlanModeResponse { | |
| 343 | − readonly outcome: XAiExitPlanModeOutcome; | |
| 344 | − readonly feedback?: string; | |
| 345 | −} | |
| 346 | − | |
| 347 | −/** | |
| 348 | − * Client captured the plan for T3's proposed-plan card. Abandon the native | |
| 349 | − * Grok plan-approval gate so the turn unblocks; the user implements via T3 UI. | |
| 350 | − */ | |
| 351 | −export function makeXAiExitPlanModeCapturedResponse(feedback?: string): XAiExitPlanModeResponse { | |
| 352 | − return { | |
| 353 | − outcome: "abandoned", | |
| 354 | − feedback: | |
| 355 | − feedback ?? | |
| 356 | − "The client captured your proposed plan. Stop here and wait for the user's feedback or implementation request in a later turn.", | |
| 357 | − }; | |
| 358 | −} | |
| 359 | − | |
| 360 | −function normalizeFsPath(value: string): string { | |
| 361 | − return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); | |
| 362 | −} | |
| 363 | − | |
| 364 | −function pathHasTraversalSegment(normalized: string): boolean { | |
| 365 | − return normalized.split("/").includes(".."); | |
| 982 | + const promptId = meta.promptId ?? meta.requestId; | |
| 983 | + return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined; | |
| 366 | 984 | } |
| 367 | 985 | |
| 368 | −function addGrokSessionPrefix( | |
| 369 | − prefixes: Set<string>, | |
| 370 | − homeOrRoot: string, | |
| 371 | − nestedGrokDir: boolean, | |
| 372 | −): void { | |
| 373 | − const root = normalizeFsPath(homeOrRoot); | |
| 374 | − if (!root) { | |
| 375 | − return; | |
| 986 | +function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.StopReason { | |
| 987 | + switch (value) { | |
| 988 | + case "cancelled": | |
| 989 | + case "end_turn": | |
| 990 | + case "max_tokens": | |
| 991 | + case "max_turn_requests": | |
| 992 | + case "refusal": | |
| 993 | + return value; | |
| 994 | + default: | |
| 995 | + return "end_turn"; | |
| 376 | 996 | } |
| 377 | − prefixes.add(nestedGrokDir ? `${root}/.grok/sessions/` : `${root}/sessions/`); | |
| 378 | −} | |
| 379 | − | |
| 380 | −/** Injected host bits so these helpers stay off `process.platform` / `process.env`. */ | |
| 381 | −export interface GrokPlanPathHost { | |
| 382 | − readonly platform: NodeJS.Platform; | |
| 383 | − readonly environment: NodeJS.ProcessEnv; | |
| 384 | −} | |
| 385 | − | |
| 386 | −function grokPlanSessionPrefixes(environment: NodeJS.ProcessEnv): ReadonlySet<string> { | |
| 387 | − const prefixes = new Set<string>(); | |
| 388 | − addGrokSessionPrefix(prefixes, NodeOS.homedir(), true); | |
| 389 | − addGrokSessionPrefix(prefixes, "~", true); | |
| 390 | − addGrokSessionPrefix(prefixes, environment.HOME ?? "", true); | |
| 391 | − addGrokSessionPrefix(prefixes, environment.USERPROFILE ?? "", true); | |
| 392 | − // ACP mock and isolated Grok spawns use a HOME that is not the server process home. | |
| 393 | − addGrokSessionPrefix(prefixes, "/tmp/mock-home", true); | |
| 394 | − const grokHome = environment.GROK_HOME ?? ""; | |
| 395 | − addGrokSessionPrefix(prefixes, grokHome, false); | |
| 396 | − addGrokSessionPrefix(prefixes, grokHome, true); | |
| 397 | − return prefixes; | |
| 398 | 997 | } |
| 399 | 998 | |
| 400 | −const CANONICAL_HOME_GROK_SESSION_PATH = | |
| 401 | − /^(?:\/home\/[^/]+|\/Users\/[^/]+|[a-zA-Z]:\/Users\/[^/]+)\/\.grok\/sessions\/(?:[^/]+\/)+plan\.md$/; | |
| 402 | −const CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH = new RegExp( | |
| 403 | − CANONICAL_HOME_GROK_SESSION_PATH.source, | |
| 404 | − "i", | |
| 405 | −); | |
| 406 | − | |
| 407 | −/** | |
| 408 | − * True when a path is Grok's session plan file under a Grok home | |
| 409 | − * (`~/.grok/sessions/.../plan.md`, `$HOME/.grok/sessions/...`, or `$GROK_HOME/sessions/...`). | |
| 410 | − * Deliberately does not match workspace files named `plan.md` (e.g. docs/plan.md | |
| 411 | − * or a repo-local `.grok/sessions/.../plan.md`). | |
| 412 | − */ | |
| 413 | −export function isGrokPlanMarkdownPath( | |
| 414 | − path: string | undefined | null, | |
| 415 | − host: GrokPlanPathHost, | |
| 416 | −): boolean { | |
| 417 | − if (typeof path !== "string") { | |
| 418 | − return false; | |
| 419 | − } | |
| 420 | − const normalized = path.trim().replace(/\\/g, "/"); | |
| 421 | − const win32 = host.platform === "win32"; | |
| 422 | − const haystack = win32 ? normalized.toLowerCase() : normalized; | |
| 423 | − if ( | |
| 424 | − normalized.length === 0 || | |
| 425 | − !haystack.endsWith("/plan.md") || | |
| 426 | − pathHasTraversalSegment(normalized) | |
| 427 | − ) { | |
| 428 | − return false; | |
| 999 | +function promptResponseFromXAi( | |
| 1000 | + notification: XAiPromptCompleteNotification, | |
| 1001 | +): EffectAcpSchema.PromptResponse { | |
| 1002 | + const stopReason = normalizeXAiStopReason(notification.stopReason); | |
| 1003 | + const meta: Record<string, unknown> = { | |
| 1004 | + sessionId: notification.sessionId, | |
| 1005 | + }; | |
| 1006 | + if (notification.stopReason === undefined) { | |
| 1007 | + meta[xAiStopReasonMissingMetaKey] = true; | |
| 429 | 1008 | } |
| 430 | − for (const prefix of grokPlanSessionPrefixes(host.environment)) { | |
| 431 | − const needle = win32 ? prefix.toLowerCase() : prefix; | |
| 432 | − if (!haystack.startsWith(needle)) { | |
| 433 | − continue; | |
| 434 | − } | |
| 435 | − const rest = haystack.slice(needle.length); | |
| 436 | − // Session layout: <home>/.grok/sessions/<encoded-cwd>/<session-id>/plan.md | |
| 437 | − if (rest !== "plan.md" && rest.endsWith("plan.md")) { | |
| 438 | − return true; | |
| 439 | − } | |
| 1009 | + if (notification.promptId !== undefined) { | |
| 1010 | + meta.promptId = notification.promptId; | |
| 1011 | + meta.requestId = notification.promptId; | |
| 440 | 1012 | } |
| 441 | − return ( | |
| 442 | − win32 ? CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH : CANONICAL_HOME_GROK_SESSION_PATH | |
| 443 | − ).test(haystack); | |
| 444 | −} | |
| 445 | − | |
| 446 | −/** | |
| 447 | − * Extract plan markdown from a Grok write/edit tool call targeting plan.md. | |
| 448 | − * Used so T3 can show the plan while plan mode is still active (before exit). | |
| 449 | − */ | |
| 450 | −export function extractGrokPlanMarkdownFromToolCallData( | |
| 451 | − data: Record<string, unknown> | undefined, | |
| 452 | − host: GrokPlanPathHost, | |
| 453 | −): string | undefined { | |
| 454 | − if (!data) { | |
| 455 | − return undefined; | |
| 1013 | + if (notification.agentResult !== undefined) { | |
| 1014 | + meta.agentResult = notification.agentResult; | |
| 456 | 1015 | } |
| 457 | − | |
| 458 | − let sawPlanWrite = false; | |
| 459 | − const takePlanText = ( | |
| 460 | − value: string | undefined, | |
| 461 | − filePath: string | undefined, | |
| 462 | − ): string | undefined => { | |
| 463 | − if (!isGrokPlanMarkdownPath(filePath, host) || value === undefined) { | |
| 464 | − return undefined; | |
| 465 | − } | |
| 466 | − sawPlanWrite = true; | |
| 467 | − const trimmed = value.trim(); | |
| 468 | − return trimmed.length > 0 ? trimmed : undefined; | |
| 1016 | + return { | |
| 1017 | + stopReason, | |
| 1018 | + _meta: meta, | |
| 469 | 1019 | }; |
| 470 | − | |
| 471 | − const rawInput = data.rawInput; | |
| 472 | − if (isRecord(rawInput)) { | |
| 473 | − const filePath = | |
| 474 | − (typeof rawInput.file_path === "string" ? rawInput.file_path : undefined) ?? | |
| 475 | − (typeof rawInput.path === "string" ? rawInput.path : undefined); | |
| 476 | − const content = typeof rawInput.content === "string" ? rawInput.content : undefined; | |
| 477 | − const fromRaw = takePlanText(content, filePath); | |
| 478 | − if (fromRaw !== undefined) { | |
| 479 | − return fromRaw; | |
| 480 | − } | |
| 481 | − } | |
| 482 | − | |
| 483 | − const content = data.content; | |
| 484 | − if (Array.isArray(content)) { | |
| 485 | − for (const block of content) { | |
| 486 | − if (!isRecord(block) || block.type !== "diff") { | |
| 487 | − continue; | |
| 488 | − } | |
| 489 | − const path = typeof block.path === "string" ? block.path : undefined; | |
| 490 | − const newText = typeof block.newText === "string" ? block.newText : undefined; | |
| 491 | − const fromDiff = takePlanText(newText, path); | |
| 492 | − if (fromDiff !== undefined) { | |
| 493 | − return fromDiff; | |
| 494 | − } | |
| 495 | − } | |
| 496 | − } | |
| 497 | − | |
| 498 | − return sawPlanWrite ? "" : undefined; | |
| 499 | −} | |
| 500 | − | |
| 501 | −function isRecord(value: unknown): value is Record<string, unknown> { | |
| 502 | − return typeof value === "object" && value !== null && !Array.isArray(value); | |
| 503 | 1020 | } |
| 504 | 1021 | |
| 505 | −/** | |
| 506 | − * Adds Grok's private prompt-completion fallback around a standards-only ACP runtime. | |
| 507 | − * The underlying runtime remains unaware of xAI methods and metadata. | |
| 508 | − */ | |
| 509 | −export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletionRuntime")( | |
| 510 | − function* (runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]) { | |
| 511 | − const activeSessionIdRef = yield* Ref.make<string | undefined>(undefined); | |
| 512 | − const pendingRef = yield* Ref.make<ReadonlyArray<PendingXAiPromptCompletion>>([]); | |
| 513 | − const completedPromptIdsRef = yield* Ref.make<ReadonlyArray<string>>([]); | |
| 514 | − let nextPromptFallbackId = 0; | |
| 515 | − const allocatePromptFallbackId = Effect.sync(() => { | |
| 516 | − nextPromptFallbackId += 1; | |
| 517 | − return `t3-xai-prompt-${nextPromptFallbackId}`; | |
| 518 | − }); | |
| 519 | − | |
| 520 | − yield* runtime.handleExtNotification( | |
| 521 | − "_x.ai/session/prompt_complete", | |
| 522 | − XAiPromptCompleteNotification, | |
| 523 | − (notification) => | |
| 524 | − resolveXAiPromptCompletionFallback({ | |
| 525 | − pendingRef, | |
| 526 | − completedPromptIdsRef, | |
| 527 | − notification, | |
| 528 | − }), | |
| 529 | − ); | |
| 530 | − | |
| 531 | − return { | |
| 532 | − ...runtime, | |
| 533 | − start: () => | |
| 534 | − runtime | |
| 535 | − .start() | |
| 536 | − .pipe(Effect.tap((started) => Ref.set(activeSessionIdRef, started.sessionId))), | |
| 537 | − prompt: (payload, promptOptions?) => | |
| 538 | − Effect.gen(function* () { | |
| 539 | − const sessionId = yield* Ref.get(activeSessionIdRef); | |
| 540 | − if (sessionId === undefined) { | |
| 541 | − return yield* runtime.prompt(payload, promptOptions); | |
| 542 | − } | |
| 543 | − | |
| 544 | − const promptId = yield* allocatePromptFallbackId; | |
| 545 | − const fallback = yield* registerXAiPromptCompletionFallback( | |
| 546 | − pendingRef, | |
| 547 | − sessionId, | |
| 548 | − promptId, | |
| 549 | − ); | |
| 550 | − const requestPayload = { | |
| 551 | − ...payload, | |
| 552 | − _meta: { | |
| 553 | − ...payload._meta, | |
| 554 | − promptId: fallback.promptId, | |
| 555 | − requestId: fallback.promptId, | |
| 556 | − }, | |
| 557 | − } satisfies Omit<EffectAcpSchema.PromptRequest, "sessionId">; | |
| 558 | − | |
| 559 | − return yield* Effect.raceFirst( | |
| 560 | − runtime.prompt(requestPayload, promptOptions), | |
| 561 | − Deferred.await(fallback.deferred), | |
| 562 | − ).pipe( | |
| 563 | − Effect.tap((response) => | |
| 564 | − rememberCompletedXAiPromptId(completedPromptIdsRef, response, fallback.promptId), | |
| 565 | − ), | |
| 566 | − Effect.ensuring(unregisterXAiPromptCompletionFallback(pendingRef, fallback.deferred)), | |
| 567 | − ); | |
| 568 | − }), | |
| 569 | − cancel: Ref.get(activeSessionIdRef).pipe( | |
| 570 | − Effect.flatMap((sessionId) => | |
| 571 | − sessionId === undefined | |
| 572 | − ? runtime.cancel | |
| 573 | − : abortPendingPromptCompletions(pendingRef, sessionId).pipe( | |
| 574 | − Effect.andThen(runtime.cancel), | |
| 575 | − ), | |
| 576 | − ), | |
| 577 | − ), | |
| 578 | − } satisfies AcpSessionRuntime.AcpSessionRuntime["Service"]; | |
| 579 | − }, | |
| 580 | −); | |
| 581 | − | |
| 582 | 1022 | const registerXAiPromptCompletionFallback = ( |
| 583 | 1023 | pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>, |
| 584 | 1024 | sessionId: string, |
| 585 | 1025 | promptId: string, |
| 586 | 1026 | ) => |
| 587 | − Deferred.make<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>().pipe( | |
| 1027 | + Deferred.make<EffectAcpSchema.PromptResponse>().pipe( | |
| 588 | 1028 | Effect.tap((deferred) => |
| 589 | 1029 | Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]), |
| 590 | 1030 | ), |
| @@ -593,7 +1033,7 @@ const registerXAiPromptCompletionFallback = ( · 2 unmodified lines | ||
| 593 | 1033 | |
| 594 | 1034 | const unregisterXAiPromptCompletionFallback = ( |
| 595 | 1035 | pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>, |
| 596 | − deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>, | |
| 1036 | + deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse>, | |
| 597 | 1037 | ) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred)); |
| 598 | 1038 | |
| 599 | 1039 | const abortPendingPromptCompletions = ( |
| @@ -655,7 +1095,15 @@ const resolveXAiPromptCompletionFallback = ({ · 55 unmodified lines | ||
| 655 | 1095 | entry.sessionId === notification.sessionId && |
| 656 | 1096 | entry.promptId === notification.promptId, |
| 657 | 1097 | ) |
| 658 | − : pending.findIndex((entry) => entry.sessionId === notification.sessionId); | |
| 1098 | + : (() => { | |
| 1099 | + const sessionPendingIndexes = pending.flatMap((entry, entryIndex) => | |
| 1100 | + entry.sessionId === notification.sessionId ? [entryIndex] : [], | |
| 1101 | + ); | |
| 1102 | + if (sessionPendingIndexes.length !== 1) { | |
| 1103 | + return -1; | |
| 1104 | + } | |
| 1105 | + return sessionPendingIndexes[0] ?? -1; | |
| 1106 | + })(); | |
| 659 | 1107 | if (index < 0) { |
| 660 | 1108 | return [Effect.void, pending] as const; |
| 661 | 1109 | } |
| @@ -664,54 +1112,22 @@ const resolveXAiPromptCompletionFallback = ({ · 2 unmodified lines | ||
| 664 | 1112 | return [Effect.void, pending] as const; |
| 665 | 1113 | } |
| 666 | 1114 | return [ |
| 667 | − settleXAiPromptCompletion(entry.deferred, notification), | |
| 1115 | + Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid), | |
| 668 | 1116 | [...pending.slice(0, index), ...pending.slice(index + 1)], |
| 669 | 1117 | ] as const; |
| 670 | 1118 | }).pipe(Effect.flatten); |
| 671 | 1119 | }), |
| 672 | 1120 | ); |
| 673 | 1121 | |
| 674 | −const settleXAiPromptCompletion = ( | |
| 675 | − deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>, | |
| 676 | − notification: XAiPromptCompleteNotification, | |
| 677 | −) => { | |
| 678 | − if (notification.stopReason === "rate_limit") { | |
| 679 | − return Deferred.fail( | |
| 680 | − deferred, | |
| 681 | − new EffectAcpErrors.AcpRequestError({ | |
| 682 | − code: xAiRateLimitedErrorCode, | |
| 683 | − errorMessage: "Grok usage limit reached. Try again later.", | |
| 684 | − }), | |
| 685 | − ).pipe(Effect.asVoid); | |
| 686 | − } | |
| 687 | − if (notification.stopReason === "error") { | |
| 688 | − return Deferred.fail( | |
| 689 | − deferred, | |
| 690 | − EffectAcpErrors.AcpRequestError.internalError( | |
| 691 | − xAiAgentResultMessage(notification.agentResult) ?? "Grok prompt failed.", | |
| 692 | − ), | |
| 693 | − ).pipe(Effect.asVoid); | |
| 694 | − } | |
| 695 | − return Deferred.succeed(deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid); | |
| 696 | −}; | |
| 697 | − | |
| 698 | −function xAiAgentResultMessage(value: unknown): string | undefined { | |
| 699 | − if (typeof value === "string") { | |
| 700 | − return trimmed(value); | |
| 701 | − } | |
| 702 | − if (value === null || typeof value !== "object") { | |
| 703 | − return undefined; | |
| 704 | − } | |
| 705 | − const message = "message" in value ? value.message : undefined; | |
| 706 | − return typeof message === "string" ? trimmed(message) : undefined; | |
| 707 | −} | |
| 708 | − | |
| 709 | 1122 | const rememberCompletedXAiPromptId = ( |
| 710 | 1123 | completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>, |
| 711 | 1124 | response: EffectAcpSchema.PromptResponse, |
| 712 | 1125 | fallbackPromptId: string, |
| 713 | 1126 | ) => { |
| 714 | 1127 | const promptId = promptIdFromResponse(response) ?? fallbackPromptId; |
| 1128 | + if (promptId.length === 0) { | |
| 1129 | + return Effect.void; | |
| 1130 | + } | |
| 715 | 1131 | return Ref.update(completedPromptIdsRef, (completedPromptIds) => { |
| 716 | 1132 | if (completedPromptIds.includes(promptId)) { |
| 717 | 1133 | return completedPromptIds; |
| @@ -720,14 +1136,111 @@ const rememberCompletedXAiPromptId = ( · 2 unmodified lines | ||
| 720 | 1136 | }); |
| 721 | 1137 | }; |
| 722 | 1138 | |
| 723 | −function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined { | |
| 724 | − const meta = response._meta; | |
| 725 | − if (meta === null || typeof meta !== "object") { | |
| 726 | − return undefined; | |
| 727 | − } | |
| 728 | − const promptId = meta.promptId ?? meta.requestId; | |
| 729 | − return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined; | |
| 730 | −} | |
| 1139 | +/** | |
| 1140 | + * Grok-specific ACP runtime wrapper. Races `session/prompt` against root-matched | |
| 1141 | + * terminal notifications: | |
| 1142 | + * - `_x.ai/session/update` + `turn_completed` + matching `prompt_id` (current CLI) | |
| 1143 | + * - `_x.ai/session/prompt_complete` (legacy / alternate builds) | |
| 1144 | + * | |
| 1145 | + * Pending entries are keyed by root sessionId + T3-injected promptId, so | |
| 1146 | + * foreign/child sessions and `task-completed-*` ids do not settle the root turn. | |
| 1147 | + */ | |
| 1148 | +export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletionRuntime")( | |
| 1149 | + function* (runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]) { | |
| 1150 | + let nextPromptFallbackId = 0; | |
| 1151 | + const allocatePromptFallbackId = Effect.sync(() => { | |
| 1152 | + nextPromptFallbackId += 1; | |
| 1153 | + return `t3-xai-prompt-${nextPromptFallbackId}`; | |
| 1154 | + }); | |
| 1155 | + const pendingXAiPromptCompletionsRef = yield* Ref.make< | |
| 1156 | + ReadonlyArray<PendingXAiPromptCompletion> | |
| 1157 | + >([]); | |
| 1158 | + const completedXAiPromptIdsRef = yield* Ref.make<ReadonlyArray<string>>([]); | |
| 1159 | + | |
| 1160 | + const settleFromPromptComplete = (notification: XAiPromptCompleteNotification) => | |
| 1161 | + resolveXAiPromptCompletionFallback({ | |
| 1162 | + pendingRef: pendingXAiPromptCompletionsRef, | |
| 1163 | + completedPromptIdsRef: completedXAiPromptIdsRef, | |
| 1164 | + notification, | |
| 1165 | + }).pipe(Effect.catch(() => Effect.void)); | |
| 1166 | + | |
| 1167 | + yield* runtime.handleExtNotification( | |
| 1168 | + "_x.ai/session/prompt_complete", | |
| 1169 | + XAiPromptCompleteNotification, | |
| 1170 | + settleFromPromptComplete, | |
| 1171 | + ); | |
| 1172 | + | |
| 1173 | + yield* runtime.handleExtNotification( | |
| 1174 | + "_x.ai/session/update", | |
| 1175 | + XAiSessionUpdateNotification, | |
| 1176 | + (notification) => { | |
| 1177 | + const complete = xAiPromptCompleteFromSessionUpdate(notification); | |
| 1178 | + if (complete === null) { | |
| 1179 | + return Effect.void; | |
| 1180 | + } | |
| 1181 | + return settleFromPromptComplete(complete); | |
| 1182 | + }, | |
| 1183 | + ); | |
| 1184 | + | |
| 1185 | + return { | |
| 1186 | + ...runtime, | |
| 1187 | + prompt: (payload) => | |
| 1188 | + Effect.gen(function* () { | |
| 1189 | + const started = yield* runtime.start(); | |
| 1190 | + const promptId = yield* allocatePromptFallbackId; | |
| 1191 | + const fallback = yield* registerXAiPromptCompletionFallback( | |
| 1192 | + pendingXAiPromptCompletionsRef, | |
| 1193 | + started.sessionId, | |
| 1194 | + promptId, | |
| 1195 | + ); | |
| 1196 | + const cancelledResponse = promptResponseFromXAi({ | |
| 1197 | + sessionId: started.sessionId, | |
| 1198 | + promptId: fallback.promptId, | |
| 1199 | + stopReason: "cancelled", | |
| 1200 | + agentResult: null, | |
| 1201 | + }); | |
| 1202 | + const promptRpcFiber = yield* runtime | |
| 1203 | + .prompt({ | |
| 1204 | + ...payload, | |
| 1205 | + _meta: { | |
| 1206 | + ...payload._meta, | |
| 1207 | + promptId: fallback.promptId, | |
| 1208 | + requestId: fallback.promptId, | |
| 1209 | + }, | |
| 1210 | + }) | |
| 1211 | + .pipe(Effect.forkChild); | |
| 1212 | + return yield* Effect.raceFirst( | |
| 1213 | + Fiber.join(promptRpcFiber).pipe( | |
| 1214 | + Effect.catchCause((cause) => | |
| 1215 | + Cause.hasInterruptsOnly(cause) | |
| 1216 | + ? Effect.succeed(cancelledResponse) | |
| 1217 | + : Effect.failCause(cause), | |
| 1218 | + ), | |
| 1219 | + ), | |
| 1220 | + Deferred.await(fallback.deferred), | |
| 1221 | + ).pipe( | |
| 1222 | + Effect.tap((response) => | |
| 1223 | + rememberCompletedXAiPromptId(completedXAiPromptIdsRef, response, fallback.promptId), | |
| 1224 | + ), | |
| 1225 | + Effect.ensuring( | |
| 1226 | + Effect.gen(function* () { | |
| 1227 | + yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); | |
| 1228 | + yield* unregisterXAiPromptCompletionFallback( | |
| 1229 | + pendingXAiPromptCompletionsRef, | |
| 1230 | + fallback.deferred, | |
| 1231 | + ); | |
| 1232 | + }), | |
| 1233 | + ), | |
| 1234 | + ); | |
| 1235 | + }), | |
| 1236 | + cancel: Effect.gen(function* () { | |
| 1237 | + const started = yield* runtime.start(); | |
| 1238 | + yield* abortPendingPromptCompletions(pendingXAiPromptCompletionsRef, started.sessionId); | |
| 1239 | + yield* runtime.cancel; | |
| 1240 | + }), | |
| 1241 | + } satisfies AcpSessionRuntime.AcpSessionRuntime["Service"]; | |
| 1242 | + }, | |
| 1243 | +); | |
| 731 | 1244 | |
| 732 | 1245 | export function promptResponseHasMissingXAiStopReason( |
| 733 | 1246 | response: EffectAcpSchema.PromptResponse, |
| @@ -735,39 +1248,3 @@ export function promptResponseHasMissingXAiStopReason( · 1 unmodified line | ||
| 735 | 1248 | const meta = response._meta; |
| 736 | 1249 | return meta !== null && typeof meta === "object" && meta[xAiStopReasonMissingMetaKey] === true; |
| 737 | 1250 | } |
| 738 | − | |
| 739 | −function promptResponseFromXAi( | |
| 740 | − notification: XAiPromptCompleteNotification, | |
| 741 | −): EffectAcpSchema.PromptResponse { | |
| 742 | − const stopReason = normalizeXAiStopReason(notification.stopReason); | |
| 743 | − const meta: Record<string, unknown> = { | |
| 744 | − sessionId: notification.sessionId, | |
| 745 | − }; | |
| 746 | − if (notification.stopReason === undefined) { | |
| 747 | − meta[xAiStopReasonMissingMetaKey] = true; | |
| 748 | − } | |
| 749 | − if (notification.promptId !== undefined) { | |
| 750 | − meta.promptId = notification.promptId; | |
| 751 | − meta.requestId = notification.promptId; | |
| 752 | − } | |
| 753 | − if (notification.agentResult !== undefined) { | |
| 754 | − meta.agentResult = notification.agentResult; | |
| 755 | − } | |
| 756 | − return { | |
| 757 | − stopReason, | |
| 758 | − _meta: meta, | |
| 759 | − }; | |
| 760 | −} | |
| 761 | − | |
| 762 | −function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.StopReason { | |
| 763 | − switch (value) { | |
| 764 | − case "cancelled": | |
| 765 | − case "end_turn": | |
| 766 | − case "max_tokens": | |
| 767 | − case "max_turn_requests": | |
| 768 | − case "refusal": | |
| 769 | − return value; | |
| 770 | − default: | |
| 771 | − return "end_turn"; | |
| 772 | − } | |
| 773 | −} | |
| @@ -1,4 +1,14 @@ | ||
| 1 | −import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId, RunId } from "@t3tools/contracts"; | |
| 1 | +import { | |
| 2 | + EnvironmentId, | |
| 3 | + MessageId, | |
| 4 | + ProjectId, | |
| 5 | + ProviderInstanceId, | |
| 6 | + ThreadId, | |
| 7 | + RunId, | |
| 8 | + TurnItemId, | |
| 9 | + type OrchestrationV2ProjectedTurnItem, | |
| 10 | +} from "@t3tools/contracts"; | |
| 11 | +import * as DateTime from "effect/DateTime"; | |
| 2 | 12 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 13 | |
| 4 | 14 | import type { Thread } from "../types"; |
| @@ -11,27 +21,17 @@ import { · 6 unmodified lines | ||
| 11 | 21 | buildLoadingThreadFromShell, |
| 12 | 22 | buildThreadTurnInterruptInput, |
| 13 | 23 | createLocalDispatchSnapshot, |
| 24 | + deriveCommittedServerUserMessageIds, | |
| 14 | 25 | deriveComposerSendState, |
| 15 | 26 | dismissBranchMismatchForSession, |
| 16 | − ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, | |
| 17 | 27 | getStartedThreadModelChangeBlockReason, |
| 18 | − isVideoPreviewRequestCurrent, | |
| 19 | − hasEnvironmentReconnectWarningGraceElapsed, | |
| 20 | 28 | hasServerAcknowledgedLocalDispatch, |
| 21 | 29 | isBranchMismatchDismissedForSession, |
| 22 | 30 | reconcileMountedTerminalThreadIds, |
| 23 | 31 | reconcileRetainedMountedThreadIds, |
| 24 | − resolveBackgroundDraftWorkspaceOptions, | |
| 25 | − resolveDraftPromotionNavigationTarget, | |
| 26 | − resolveProactiveTurnDiffAction, | |
| 27 | 32 | resolveThreadMetadataUpdateForNextTurn, |
| 28 | 33 | resolveSendEnvMode, |
| 29 | − resolveDraftHeroState, | |
| 30 | − scheduleEnvironmentReconnectWarning, | |
| 31 | 34 | startNewThreadForProject, |
| 32 | − codexArtifactTemplatePromptToAppend, | |
| 33 | − shouldDockDraftHeroForSubmission, | |
| 34 | − shouldReleaseTimelineAnchorForToolActivity, | |
| 35 | 35 | shouldShowBranchMismatchBanner, |
| 36 | 36 | shouldShowPlanFollowUpPrompt, |
| 37 | 37 | shouldWriteThreadErrorToCurrentServerThread, |
| @@ -259,184 +259,6 @@ const helloWorldTemplate: CodexArtifactTemplate = { · 221 unmodified lines | ||
| 259 | 259 | skillName: "artifact-template-hello-world", |
| 260 | 260 | }; |
| 261 | 261 | |
| 262 | −describe("draft hero submission transition", () => { | |
| 263 | − it("does not dock the composer before a background submission", () => { | |
| 264 | − expect( | |
| 265 | − shouldDockDraftHeroForSubmission({ | |
| 266 | − isDraftHeroState: true, | |
| 267 | − activeThreadKey: "environment-local:thread-1", | |
| 268 | − submissionIntent: "background", | |
| 269 | − }), | |
| 270 | − ).toBe(false); | |
| 271 | − }); | |
| 272 | − | |
| 273 | − it("keeps the composer in the hero layout until navigation after server promotion", () => { | |
| 274 | − expect( | |
| 275 | − resolveDraftHeroState({ | |
| 276 | − isLocalDraftThread: false, | |
| 277 | − hasTimelineEntries: true, | |
| 278 | − isWorking: true, | |
| 279 | − draftHeroDockRequested: false, | |
| 280 | − backgroundSubmissionPending: true, | |
| 281 | − }), | |
| 282 | − ).toBe(true); | |
| 283 | − }); | |
| 284 | − | |
| 285 | − it("does not auto-navigate a background submission after server promotion", () => { | |
| 286 | − expect( | |
| 287 | − resolveDraftPromotionNavigationTarget({ | |
| 288 | − serverThreadRef: { environmentId, threadId }, | |
| 289 | − serverThread: makeThread({ latestTurn: completedTurn }), | |
| 290 | − backgroundSubmissionPending: true, | |
| 291 | − }), | |
| 292 | − ).toBeNull(); | |
| 293 | − }); | |
| 294 | −}); | |
| 295 | − | |
| 296 | −describe("shouldReleaseTimelineAnchorForToolActivity", () => { | |
| 297 | − const activeTurnId = TurnId.make("active-turn"); | |
| 298 | − const anchorMessageId = MessageId.make("anchored-message"); | |
| 299 | − const activeToolEntry = { | |
| 300 | − id: "tool-entry", | |
| 301 | − kind: "work" as const, | |
| 302 | − createdAt: now, | |
| 303 | − entry: { | |
| 304 | − id: "active-tool", | |
| 305 | − createdAt: now, | |
| 306 | − turnId: activeTurnId, | |
| 307 | − label: "Run command", | |
| 308 | − tone: "tool" as const, | |
| 309 | − command: "git status", | |
| 310 | − }, | |
| 311 | − }; | |
| 312 | − | |
| 313 | − it("releases the send anchor for tool activity in the active turn", () => { | |
| 314 | − expect( | |
| 315 | − shouldReleaseTimelineAnchorForToolActivity({ | |
| 316 | − anchorMessageId, | |
| 317 | − liveFollowEnabled: true, | |
| 318 | − runningTurnId: activeTurnId, | |
| 319 | − timelineEntries: [activeToolEntry], | |
| 320 | − }), | |
| 321 | − ).toBe(true); | |
| 322 | − }); | |
| 323 | − | |
| 324 | − it("keeps the anchor while the user reads history", () => { | |
| 325 | − expect( | |
| 326 | − shouldReleaseTimelineAnchorForToolActivity({ | |
| 327 | − anchorMessageId, | |
| 328 | − liveFollowEnabled: false, | |
| 329 | − runningTurnId: activeTurnId, | |
| 330 | − timelineEntries: [activeToolEntry], | |
| 331 | − }), | |
| 332 | − ).toBe(false); | |
| 333 | − }); | |
| 334 | − | |
| 335 | − it("ignores tool activity from earlier turns", () => { | |
| 336 | − expect( | |
| 337 | − shouldReleaseTimelineAnchorForToolActivity({ | |
| 338 | − anchorMessageId, | |
| 339 | − liveFollowEnabled: true, | |
| 340 | − runningTurnId: activeTurnId, | |
| 341 | − timelineEntries: [ | |
| 342 | − { | |
| 343 | − ...activeToolEntry, | |
| 344 | − entry: { | |
| 345 | − ...activeToolEntry.entry, | |
| 346 | − turnId: TurnId.make("previous-turn"), | |
| 347 | − }, | |
| 348 | − }, | |
| 349 | − ], | |
| 350 | − }), | |
| 351 | − ).toBe(false); | |
| 352 | − }); | |
| 353 | − | |
| 354 | − it("ignores thinking and error rows without tool activity", () => { | |
| 355 | − expect( | |
| 356 | − shouldReleaseTimelineAnchorForToolActivity({ | |
| 357 | − anchorMessageId, | |
| 358 | − liveFollowEnabled: true, | |
| 359 | − runningTurnId: activeTurnId, | |
| 360 | − timelineEntries: [ | |
| 361 | − { | |
| 362 | − ...activeToolEntry, | |
| 363 | − entry: { | |
| 364 | − id: "thinking-entry", | |
| 365 | − createdAt: now, | |
| 366 | − turnId: activeTurnId, | |
| 367 | − label: "Thinking", | |
| 368 | − tone: "thinking", | |
| 369 | − }, | |
| 370 | − }, | |
| 371 | − { | |
| 372 | − ...activeToolEntry, | |
| 373 | − id: "error-entry", | |
| 374 | − entry: { | |
| 375 | − id: "error-entry", | |
| 376 | − createdAt: now, | |
| 377 | − turnId: activeTurnId, | |
| 378 | − label: "Provider error", | |
| 379 | − tone: "error", | |
| 380 | − }, | |
| 381 | − }, | |
| 382 | − ], | |
| 383 | − }), | |
| 384 | − ).toBe(false); | |
| 385 | − }); | |
| 386 | − | |
| 387 | − it("does nothing without an anchor or running turn", () => { | |
| 388 | − const input = { | |
| 389 | − anchorMessageId, | |
| 390 | − liveFollowEnabled: true, | |
| 391 | − runningTurnId: activeTurnId, | |
| 392 | − timelineEntries: [activeToolEntry], | |
| 393 | − }; | |
| 394 | − | |
| 395 | − expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, anchorMessageId: null })).toBe( | |
| 396 | − false, | |
| 397 | − ); | |
| 398 | − expect(shouldReleaseTimelineAnchorForToolActivity({ ...input, runningTurnId: null })).toBe( | |
| 399 | − false, | |
| 400 | − ); | |
| 401 | − }); | |
| 402 | −}); | |
| 403 | − | |
| 404 | −describe("environment reconnect warning grace", () => { | |
| 405 | − afterEach(() => vi.useRealTimers()); | |
| 406 | − | |
| 407 | − it("shows a persistent reconnect after the grace period", () => { | |
| 408 | − vi.useFakeTimers(); | |
| 409 | − const showWarning = vi.fn(); | |
| 410 | − | |
| 411 | − scheduleEnvironmentReconnectWarning(showWarning); | |
| 412 | − vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS - 1); | |
| 413 | − expect(showWarning).not.toHaveBeenCalled(); | |
| 414 | − | |
| 415 | − vi.advanceTimersByTime(1); | |
| 416 | − expect(showWarning).toHaveBeenCalledOnce(); | |
| 417 | − }); | |
| 418 | − | |
| 419 | − it("cancels the warning when the connection recovers during the grace period", () => { | |
| 420 | − vi.useFakeTimers(); | |
| 421 | − const showWarning = vi.fn(); | |
| 422 | − | |
| 423 | − const cancel = scheduleEnvironmentReconnectWarning(showWarning); | |
| 424 | − cancel(); | |
| 425 | − vi.advanceTimersByTime(ENVIRONMENT_RECONNECT_WARNING_GRACE_MS); | |
| 426 | − | |
| 427 | − expect(showWarning).not.toHaveBeenCalled(); | |
| 428 | − }); | |
| 429 | − | |
| 430 | − it("does not reuse elapsed grace from another environment", () => { | |
| 431 | − const anotherEnvironmentId = EnvironmentId.make("environment-remote"); | |
| 432 | − | |
| 433 | − expect(hasEnvironmentReconnectWarningGraceElapsed(environmentId, environmentId)).toBe(true); | |
| 434 | − expect(hasEnvironmentReconnectWarningGraceElapsed(anotherEnvironmentId, environmentId)).toBe( | |
| 435 | − false, | |
| 436 | − ); | |
| 437 | − }); | |
| 438 | −}); | |
| 439 | − | |
| 440 | 262 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 441 | 263 | return makeThreadFixture({ |
| 442 | 264 | id: threadId, |
| @@ -800,23 +622,6 @@ describe("resolveSendEnvMode", () => { · 357 unmodified lines | ||
| 800 | 622 | }); |
| 801 | 623 | }); |
| 802 | 624 | |
| 803 | −describe("resolveBackgroundDraftWorkspaceOptions", () => { | |
| 804 | − it("keeps New worktree selected without reusing the launched worktree", () => { | |
| 805 | − expect( | |
| 806 | − resolveBackgroundDraftWorkspaceOptions({ | |
| 807 | − envMode: "worktree", | |
| 808 | − branch: "main", | |
| 809 | − startFromOrigin: true, | |
| 810 | − }), | |
| 811 | − ).toEqual({ | |
| 812 | − envMode: "worktree", | |
| 813 | − branch: "main", | |
| 814 | − worktreePath: null, | |
| 815 | − startFromOrigin: true, | |
| 816 | − }); | |
| 817 | − }); | |
| 818 | −}); | |
| 819 | − | |
| 820 | 625 | describe("branchMismatchKey", () => { |
| 821 | 626 | it("builds a key from thread id and both branches", () => { |
| 822 | 627 | expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( |
| @@ -1042,29 +847,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 219 unmodified lines | ||
| 1042 | 847 | ).toBe(false); |
| 1043 | 848 | }); |
| 1044 | 849 | |
| 1045 | − it("keeps a follow-up active while its provider session is starting", () => { | |
| 1046 | − const localDispatch = createLocalDispatchSnapshot( | |
| 1047 | − makeThread({ latestTurn: completedTurn, session: readySession }), | |
| 1048 | − ); | |
| 1049 | − | |
| 1050 | − expect( | |
| 1051 | − hasServerAcknowledgedLocalDispatch({ | |
| 1052 | − localDispatch, | |
| 1053 | − phase: "connecting", | |
| 1054 | − latestTurn: completedTurn, | |
| 1055 | − latestUserMessageId: MessageId.make("message-followup"), | |
| 1056 | − session: { | |
| 1057 | − ...readySession, | |
| 1058 | − status: "starting", | |
| 1059 | − updatedAt: "2026-03-29T00:01:00.000Z", | |
| 1060 | − }, | |
| 1061 | − hasPendingApproval: false, | |
| 1062 | − hasPendingUserInput: false, | |
| 1063 | − threadError: null, | |
| 1064 | − }), | |
| 1065 | − ).toBe(false); | |
| 1066 | − }); | |
| 1067 | − | |
| 1068 | 850 | it("acknowledges a settled newer turn", () => { |
| 1069 | 851 | const localDispatch = createLocalDispatchSnapshot( |
| 1070 | 852 | makeThread({ latestRun: completedTurn, runtime: readySession }), |
| @@ -1195,3 +977,103 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 124 unmodified lines | ||
| 1195 | 977 | expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); |
| 1196 | 978 | }); |
| 1197 | 979 | }); |
| 980 | + | |
| 981 | +describe("deriveCommittedServerUserMessageIds", () => { | |
| 982 | + it("tracks only committed user turn items, not assistant rows or projection-only messages", () => { | |
| 983 | + const turnStartId = MessageId.make("message-turn-start"); | |
| 984 | + const steerId = MessageId.make("message-steer"); | |
| 985 | + const assistantId = MessageId.make("message-assistant"); | |
| 986 | + const committedAt = DateTime.makeUnsafe("2026-06-26T17:50:15.180Z"); | |
| 987 | + const runId = RunId.make("run:thread:thread-1:ordinal:1"); | |
| 988 | + const visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem> = [ | |
| 989 | + { | |
| 990 | + position: 0, | |
| 991 | + visibility: "local", | |
| 992 | + sourceThreadId: threadId, | |
| 993 | + sourceItemId: TurnItemId.make("turn-item:message-turn-start"), | |
| 994 | + item: { | |
| 995 | + id: TurnItemId.make("turn-item:message-turn-start"), | |
| 996 | + threadId, | |
| 997 | + runId, | |
| 998 | + nodeId: null, | |
| 999 | + providerThreadId: null, | |
| 1000 | + providerTurnId: null, | |
| 1001 | + nativeItemRef: null, | |
| 1002 | + parentItemId: null, | |
| 1003 | + ordinal: 1, | |
| 1004 | + status: "completed", | |
| 1005 | + title: null, | |
| 1006 | + startedAt: committedAt, | |
| 1007 | + completedAt: committedAt, | |
| 1008 | + updatedAt: committedAt, | |
| 1009 | + createdBy: "user", | |
| 1010 | + creationSource: "web", | |
| 1011 | + type: "user_message", | |
| 1012 | + messageId: turnStartId, | |
| 1013 | + inputIntent: "turn_start", | |
| 1014 | + text: "start", | |
| 1015 | + attachments: [], | |
| 1016 | + }, | |
| 1017 | + }, | |
| 1018 | + { | |
| 1019 | + position: 1, | |
| 1020 | + visibility: "local", | |
| 1021 | + sourceThreadId: threadId, | |
| 1022 | + sourceItemId: TurnItemId.make("turn-item:message-assistant"), | |
| 1023 | + item: { | |
| 1024 | + id: TurnItemId.make("turn-item:message-assistant"), | |
| 1025 | + threadId, | |
| 1026 | + runId, | |
| 1027 | + nodeId: null, | |
| 1028 | + providerThreadId: null, | |
| 1029 | + providerTurnId: null, | |
| 1030 | + nativeItemRef: null, | |
| 1031 | + parentItemId: null, | |
| 1032 | + ordinal: 2, | |
| 1033 | + status: "completed", | |
| 1034 | + title: null, | |
| 1035 | + startedAt: committedAt, | |
| 1036 | + completedAt: committedAt, | |
| 1037 | + updatedAt: committedAt, | |
| 1038 | + type: "assistant_message", | |
| 1039 | + messageId: assistantId, | |
| 1040 | + text: "working", | |
| 1041 | + streaming: false, | |
| 1042 | + }, | |
| 1043 | + }, | |
| 1044 | + { | |
| 1045 | + position: 2, | |
| 1046 | + visibility: "local", | |
| 1047 | + sourceThreadId: threadId, | |
| 1048 | + sourceItemId: TurnItemId.make("turn-item:message-steer"), | |
| 1049 | + item: { | |
| 1050 | + id: TurnItemId.make("turn-item:message-steer"), | |
| 1051 | + threadId, | |
| 1052 | + runId, | |
| 1053 | + nodeId: null, | |
| 1054 | + providerThreadId: null, | |
| 1055 | + providerTurnId: null, | |
| 1056 | + nativeItemRef: null, | |
| 1057 | + parentItemId: null, | |
| 1058 | + ordinal: 3, | |
| 1059 | + status: "completed", | |
| 1060 | + title: null, | |
| 1061 | + startedAt: committedAt, | |
| 1062 | + completedAt: committedAt, | |
| 1063 | + updatedAt: committedAt, | |
| 1064 | + createdBy: "user", | |
| 1065 | + creationSource: "web", | |
| 1066 | + type: "user_message", | |
| 1067 | + messageId: steerId, | |
| 1068 | + inputIntent: "steer", | |
| 1069 | + text: "continue", | |
| 1070 | + attachments: [], | |
| 1071 | + }, | |
| 1072 | + }, | |
| 1073 | + ]; | |
| 1074 | + | |
| 1075 | + expect(deriveCommittedServerUserMessageIds(visibleTurnItems)).toEqual( | |
| 1076 | + new Set([turnStartId, steerId]), | |
| 1077 | + ); | |
| 1078 | + }); | |
| 1079 | +}); | |
| @@ -6,7 +6,7 @@ import { · 5 unmodified lines | ||
| 6 | 6 | isProviderDriverKind, |
| 7 | 7 | ProjectId, |
| 8 | 8 | type ModelSelection, |
| 9 | − type ProviderInteractionMode, | |
| 9 | + type OrchestrationV2ProjectedTurnItem, | |
| 10 | 10 | type ProviderDriverKind, |
| 11 | 11 | type ServerProvider, |
| 12 | 12 | type ScopedProjectRef, |
| @@ -560,6 +560,22 @@ export function createLocalDispatchSnapshot( · 547 unmodified lines | ||
| 560 | 560 | }; |
| 561 | 561 | } |
| 562 | 562 | |
| 563 | +/** | |
| 564 | + * The timeline renders committed user rows from `visibleTurnItems`, but | |
| 565 | + * `message.updated` can land in `projection.messages` one event earlier than | |
| 566 | + * the matching `turn-item.updated`. Basing optimistic eviction on visible user | |
| 567 | + * turn items avoids dropping steer rows in that gap. | |
| 568 | + */ | |
| 569 | +export function deriveCommittedServerUserMessageIds( | |
| 570 | + visibleTurnItems: ReadonlyArray<OrchestrationV2ProjectedTurnItem>, | |
| 571 | +): ReadonlySet<ChatMessage["id"]> { | |
| 572 | + return new Set( | |
| 573 | + visibleTurnItems.flatMap((row) => | |
| 574 | + row.item.type === "user_message" ? [row.item.messageId] : [], | |
| 575 | + ), | |
| 576 | + ); | |
| 577 | +} | |
| 578 | + | |
| 563 | 579 | export function hasServerAcknowledgedLocalDispatch(input: { |
| 564 | 580 | localDispatch: LocalDispatchSnapshot | null; |
| 565 | 581 | phase: SessionPhase; |
| @@ -140,7 +140,6 @@ import { · 139 unmodified lines | ||
| 140 | 140 | selectThreadPanelOpen, |
| 141 | 141 | selectThreadRightPanelState, |
| 142 | 142 | type RightPanelSurface, |
| 143 | − updatePullRequestTabStatus, | |
| 144 | 143 | useRightPanelStore, |
| 145 | 144 | } from "../rightPanelStore"; |
| 146 | 145 | import { |
| @@ -157,11 +156,7 @@ import { · 10 unmodified lines | ||
| 157 | 156 | selectThreadPreviewMiniPlayer, |
| 158 | 157 | usePreviewMiniPlayerStore, |
| 159 | 158 | } from "../previewMiniPlayerStore"; |
| 160 | −import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic"; | |
| 161 | −import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; | |
| 162 | −import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; | |
| 163 | −import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; | |
| 164 | −import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs"; | |
| 159 | +import { RightPanelTabs } from "./RightPanelTabs"; | |
| 165 | 160 | import { AgentsPanel } from "./AgentsPanel"; |
| 166 | 161 | import { |
| 167 | 162 | deriveAgentPanelModel, |
| @@ -183,14 +178,9 @@ import { · 15 unmodified lines | ||
| 183 | 178 | projectScriptIdFromCommand, |
| 184 | 179 | } from "~/projectScripts"; |
| 185 | 180 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; |
| 186 | −import { useBrowserHistoryStore } from "~/browserHistoryStore"; | |
| 187 | 181 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; |
| 188 | 182 | import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; |
| 189 | −import { | |
| 190 | − useClientSettings, | |
| 191 | − useClientSettingsHydrated, | |
| 192 | − useEnvironmentSettings, | |
| 193 | −} from "../hooks/useSettings"; | |
| 183 | +import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; | |
| 194 | 184 | import { useNowMinute } from "../hooks/useNowMinute"; |
| 195 | 185 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; |
| 196 | 186 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| @@ -198,7 +188,6 @@ import { getTerminalFocusOwner } from "../lib/terminalFocus"; · 1 unmodified line | ||
| 198 | 188 | import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; |
| 199 | 189 | import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; |
| 200 | 190 | import { |
| 201 | − derivePhysicalProjectKey, | |
| 202 | 191 | deriveLogicalProjectKeyFromSettings, |
| 203 | 192 | selectProjectGroupingSettings, |
| 204 | 193 | } from "../logicalProject"; |
| @@ -311,10 +300,9 @@ import { · 106 unmodified lines | ||
| 311 | 300 | buildThreadTurnInterruptInput, |
| 312 | 301 | collectUserMessageBlobPreviewUrls, |
| 313 | 302 | createLocalDispatchSnapshot, |
| 303 | + deriveCommittedServerUserMessageIds, | |
| 314 | 304 | deriveComposerSendState, |
| 315 | 305 | dismissBranchMismatchForSession, |
| 316 | − hasEnvironmentReconnectWarningGraceElapsed, | |
| 317 | − scheduleEnvironmentReconnectWarning, | |
| 318 | 306 | hasServerAcknowledgedLocalDispatch, |
| 319 | 307 | isBranchMismatchDismissedForSession, |
| 320 | 308 | shouldShowBranchMismatchBanner, |
| @@ -519,14 +507,6 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { · 198 unmodified lines | ||
| 519 | 507 | if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; |
| 520 | 508 | if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false; |
| 521 | 509 | |
| 522 | − // The right-panel surface launcher claims its shortcut letters while it is | |
| 523 | − // visible (data attribute set in RightPanelTabs); those keys open surfaces | |
| 524 | − // instead of typing into the composer. | |
| 525 | − const launcherKeys = document | |
| 526 | − .querySelector("[data-surface-launcher-keys]") | |
| 527 | − ?.getAttribute("data-surface-launcher-keys"); | |
| 528 | − if (launcherKeys && launcherKeys.toLowerCase().includes(event.key.toLowerCase())) return false; | |
| 529 | − | |
| 530 | 510 | return true; |
| 531 | 511 | } |
| 532 | 512 | |
| @@ -1297,8 +1277,8 @@ function ChatViewContent(props: ChatViewProps) { · 764 unmodified lines | ||
| 1297 | 1277 | routeKind === "server" ? routeThreadRef : null, |
| 1298 | 1278 | ); |
| 1299 | 1279 | const committedServerMessageIds = useMemo( |
| 1300 | − () => new Set(serverProjection?.messages.map((message) => message.id) ?? []), | |
| 1301 | − [serverProjection], | |
| 1280 | + () => deriveCommittedServerUserMessageIds(serverVisibleTurnItems), | |
| 1281 | + [serverVisibleTurnItems], | |
| 1302 | 1282 | ); |
| 1303 | 1283 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); |
| 1304 | 1284 | const activeThreadLastVisitedAt = useUiStateStore( |
| @@ -1636,7 +1616,11 @@ function ChatViewContent(props: ChatViewProps) { · 331 unmodified lines | ||
| 1636 | 1616 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; |
| 1637 | 1617 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; |
| 1638 | 1618 | const activeThreadId = activeThread?.id ?? null; |
| 1639 | − const activeMessageCount = isServerThread ? committedServerMessageIds.size : 0; | |
| 1619 | + // Prefer the larger of turn-item-committed ids and projection messages so | |
| 1620 | + // env lock does not unlock while turn items lag projection hydration. | |
| 1621 | + const activeMessageCount = isServerThread | |
| 1622 | + ? Math.max(committedServerMessageIds.size, serverProjection?.messages.length ?? 0) | |
| 1623 | + : 0; | |
| 1640 | 1624 | const runningTerminalIds = useThreadRunningTerminalIds({ |
| 1641 | 1625 | environmentId: activeThread?.environmentId ?? null, |
| 1642 | 1626 | threadId: activeThreadId, |
| @@ -1672,11 +1656,8 @@ function ChatViewContent(props: ChatViewProps) { · 29 unmodified lines | ||
| 1672 | 1656 | return labels; |
| 1673 | 1657 | }, [activeThreadKnownSessions]); |
| 1674 | 1658 | const activeThreadRef = useMemo( |
| 1675 | − () => | |
| 1676 | − activeThreadEnvironmentId && activeThreadId | |
| 1677 | − ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) | |
| 1678 | − : null, | |
| 1679 | − [activeThreadEnvironmentId, activeThreadId], | |
| 1659 | + () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), | |
| 1660 | + [activeThread], | |
| 1680 | 1661 | ); |
| 1681 | 1662 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1682 | 1663 | const [timelineAnchor, setTimelineAnchor] = useState<{ |
| @@ -1697,23 +1678,6 @@ function ChatViewContent(props: ChatViewProps) { · 14 unmodified lines | ||
| 1697 | 1678 | const activeRightPanelSurface = useRightPanelStore((state) => |
| 1698 | 1679 | selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), |
| 1699 | 1680 | ); |
| 1700 | − const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState< | |
| 1701 | − Record<string, PullRequestTabStatus> | |
| 1702 | − >({}); | |
| 1703 | − // Keyed by the surface the panel is showing rather than by a key rebuilt from the status, so | |
| 1704 | − // the tab is found again whether or not that surface was opened with an environment on it. | |
| 1705 | − const activePullRequestSurfaceId = | |
| 1706 | − activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; | |
| 1707 | − const updatePullRequestTabStatusFromPanel = useCallback( | |
| 1708 | − (status: PullRequestTabStatus) => { | |
| 1709 | − const id = activePullRequestSurfaceId; | |
| 1710 | − if (id === undefined) return; | |
| 1711 | − setPullRequestTabStatuses((current) => updatePullRequestTabStatus(current, id, status)); | |
| 1712 | − }, | |
| 1713 | − [activePullRequestSurfaceId], | |
| 1714 | − ); | |
| 1715 | − const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); | |
| 1716 | − const sidebarPrRefreshKeyRef = useRef<string | null>(null); | |
| 1717 | 1681 | const activeFileSurface = |
| 1718 | 1682 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; |
| 1719 | 1683 | const activePreviewState = useThreadPreviewState(activeThreadRef); |
| @@ -1847,7 +1811,6 @@ function ChatViewContent(props: ChatViewProps) { · 127 unmodified lines | ||
| 1847 | 1811 | const activeProjectKey = activeProject |
| 1848 | 1812 | ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` |
| 1849 | 1813 | : null; |
| 1850 | − const clientSettingsHydrated = useClientSettingsHydrated(); | |
| 1851 | 1814 | const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< |
| 1852 | 1815 | ReadonlyMap<string, ReadonlySet<string>> |
| 1853 | 1816 | >(() => new Map()); |
| @@ -1886,54 +1849,11 @@ function ChatViewContent(props: ChatViewProps) { · 32 unmodified lines | ||
| 1886 | 1849 | // drive the environment picker in BranchToolbar. |
| 1887 | 1850 | const allProjects = useProjects(); |
| 1888 | 1851 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; |
| 1889 | − useEffect(() => { | |
| 1890 | − if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return; | |
| 1891 | − // Reuse the sidebar's grouping so history follows the project rows the user | |
| 1892 | − // sees. Deriving the key from the active project alone would miss the | |
| 1893 | − // identity a duplicate row borrows from its siblings. | |
| 1894 | − const logicalKeyByPhysicalKey = buildPhysicalToLogicalProjectKeyMap({ | |
| 1895 | − projects: allProjects, | |
| 1896 | − settings: projectGroupingSettings, | |
| 1897 | − primaryEnvironmentId, | |
| 1898 | − }); | |
| 1899 | − useBrowserHistoryStore | |
| 1900 | − .getState() | |
| 1901 | − .registerThreadProject( | |
| 1902 | − activeThreadRef, | |
| 1903 | − logicalKeyByPhysicalKey.get(derivePhysicalProjectKey(activeProject)) ?? | |
| 1904 | − deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings), | |
| 1905 | − ); | |
| 1906 | − }, [ | |
| 1907 | − activeProject, | |
| 1908 | − activeThreadRef, | |
| 1909 | − allProjects, | |
| 1910 | − clientSettingsHydrated, | |
| 1911 | − primaryEnvironmentId, | |
| 1912 | − projectGroupingSettings, | |
| 1913 | − ]); | |
| 1914 | 1852 | const activeEnvironment = |
| 1915 | 1853 | activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); |
| 1916 | 1854 | const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; |
| 1917 | 1855 | const activeEnvironmentUnavailable = |
| 1918 | 1856 | activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; |
| 1919 | − const activeReconnectingEnvironmentId = | |
| 1920 | − activeEnvironmentConnectionPhase === "connecting" || | |
| 1921 | − activeEnvironmentConnectionPhase === "reconnecting" | |
| 1922 | − ? (activeEnvironment?.environmentId ?? null) | |
| 1923 | − : null; | |
| 1924 | − const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] = | |
| 1925 | − useState<EnvironmentId | null>(null); | |
| 1926 | − const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed( | |
| 1927 | − activeReconnectingEnvironmentId, | |
| 1928 | − reconnectWarningGraceElapsedEnvironmentId, | |
| 1929 | − ); | |
| 1930 | − useEffect(() => { | |
| 1931 | − setReconnectWarningGraceElapsedEnvironmentId(null); | |
| 1932 | − if (activeReconnectingEnvironmentId === null) return; | |
| 1933 | − return scheduleEnvironmentReconnectWarning(() => | |
| 1934 | − setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), | |
| 1935 | − ); | |
| 1936 | − }, [activeReconnectingEnvironmentId]); | |
| 1937 | 1857 | const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; |
| 1938 | 1858 | const activeEnvironmentUnavailableState = useMemo<EnvironmentUnavailableState | null>(() => { |
| 1939 | 1859 | if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { |
| @@ -1962,6 +1882,7 @@ function ChatViewContent(props: ChatViewProps) { · 22 unmodified lines | ||
| 1962 | 1882 | }, |
| 1963 | 1883 | [retryEnvironment], |
| 1964 | 1884 | ); |
| 1885 | + const projectGroupingSettings = selectProjectGroupingSettings(settings); | |
| 1965 | 1886 | const logicalProjectEnvironments = useMemo(() => { |
| 1966 | 1887 | if (!activeProject) return []; |
| 1967 | 1888 | const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); |
| @@ -2115,8 +2036,6 @@ function ChatViewContent(props: ChatViewProps) { · 147 unmodified lines | ||
| 2115 | 2036 | const serverConfig = activeThread |
| 2116 | 2037 | ? (activeEnvironment?.serverConfig ?? null) |
| 2117 | 2038 | : (primaryEnvironment?.serverConfig ?? null); |
| 2118 | − const pullRequestsCapabilityKnown = serverConfig !== null; | |
| 2119 | − const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; | |
| 2120 | 2039 | const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); |
| 2121 | 2040 | const versionMismatchDismissKey = |
| 2122 | 2041 | versionMismatch && activeThread |
| @@ -3266,27 +3185,6 @@ function ChatViewContent(props: ChatViewProps) { · 1143 unmodified lines | ||
| 3266 | 3185 | }, |
| 3267 | 3186 | [activeProject, activeThreadRef], |
| 3268 | 3187 | ); |
| 3269 | − // The thread's own change request, placed against the project it belongs to. Without a | |
| 3270 | − // project there is nothing to resolve it against, so the caller falls back to the browser. | |
| 3271 | − const threadRepository = activeProject?.repositoryIdentity?.displayName ?? null; | |
| 3272 | − const openThreadPullRequest = useCallback( | |
| 3273 | − (number: number) => { | |
| 3274 | − if ( | |
| 3275 | − !supportsPullRequests || | |
| 3276 | − !activeThreadRef || | |
| 3277 | − !activeProject || | |
| 3278 | − threadRepository === null | |
| 3279 | − ) { | |
| 3280 | − return; | |
| 3281 | − } | |
| 3282 | − useRightPanelStore.getState().openPullRequest(activeThreadRef, { | |
| 3283 | − projectId: activeProject.id, | |
| 3284 | − repository: threadRepository, | |
| 3285 | − number, | |
| 3286 | − }); | |
| 3287 | − }, | |
| 3288 | − [activeProject, activeThreadRef, supportsPullRequests, threadRepository], | |
| 3289 | − ); | |
| 3290 | 3188 | const togglePreviewPanel = useCallback(() => { |
| 3291 | 3189 | if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; |
| 3292 | 3190 | if (previewPanelOpen) { |
| @@ -4136,113 +4034,6 @@ function ChatViewContent(props: ChatViewProps) { · 843 unmodified lines | ||
| 4136 | 4034 | threadBranch: activeThread?.branch ?? null, |
| 4137 | 4035 | gitStatus: gitStatusQuery.data ?? null, |
| 4138 | 4036 | }); |
| 4139 | − const handlePullRequestTabStatusChange = useCallback( | |
| 4140 | − (status: PullRequestTabStatus) => { | |
| 4141 | − updatePullRequestTabStatusFromPanel(status); | |
| 4142 | − const source = threadPullRequestRefreshSource({ | |
| 4143 | − panel: status, | |
| 4144 | − thread: { | |
| 4145 | − repository: threadRepository, | |
| 4146 | − number: linkedThreadPullRequest?.number ?? activeThreadPr?.number ?? null, | |
| 4147 | − state: activeThreadPr?.state ?? null, | |
| 4148 | − linked: linkedThreadPullRequest !== null, | |
| 4149 | − }, | |
| 4150 | − }); | |
| 4151 | − if (source === null) { | |
| 4152 | − sidebarPrRefreshKeyRef.current = null; | |
| 4153 | − return; | |
| 4154 | − } | |
| 4155 | − const refreshKey = `${activeThreadKey}:${source}:${status.repository}#${status.number}:${status.state}`; | |
| 4156 | − if (sidebarPrRefreshKeyRef.current === refreshKey) return; | |
| 4157 | − sidebarPrRefreshKeyRef.current = refreshKey; | |
| 4158 | − | |
| 4159 | − if (source === "linked-detail" && activeThreadRef && linkedThreadPullRequest) { | |
| 4160 | − appAtomRegistry.refresh( | |
| 4161 | − linkedPullRequestDetailAtom({ | |
| 4162 | − environmentId: activeThreadRef.environmentId, | |
| 4163 | − input: { | |
| 4164 | − projectId: linkedThreadPullRequest.projectId, | |
| 4165 | − repository: linkedThreadPullRequest.repository, | |
| 4166 | − number: linkedThreadPullRequest.number, | |
| 4167 | − }, | |
| 4168 | − }), | |
| 4169 | − ); | |
| 4170 | − return; | |
| 4171 | − } | |
| 4172 | − if (source === "vcs" && activeThreadRef && gitCwd !== null) { | |
| 4173 | − void refreshVcsStatus({ | |
| 4174 | − environmentId: activeThreadRef.environmentId, | |
| 4175 | − input: { cwd: gitCwd }, | |
| 4176 | − }).then(() => { | |
| 4177 | − if (sidebarPrRefreshKeyRef.current === refreshKey) { | |
| 4178 | − sidebarPrRefreshKeyRef.current = null; | |
| 4179 | − } | |
| 4180 | − }); | |
| 4181 | − } | |
| 4182 | − }, | |
| 4183 | − [ | |
| 4184 | − activeThreadKey, | |
| 4185 | − activeThreadPr?.number, | |
| 4186 | − activeThreadPr?.state, | |
| 4187 | − activeThreadRef, | |
| 4188 | − gitCwd, | |
| 4189 | − linkedThreadPullRequest, | |
| 4190 | − refreshVcsStatus, | |
| 4191 | − threadRepository, | |
| 4192 | − updatePullRequestTabStatusFromPanel, | |
| 4193 | − ], | |
| 4194 | − ); | |
| 4195 | − const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef); | |
| 4196 | − const activeThreadReferenceCopyTarget = useMemo( | |
| 4197 | − () => | |
| 4198 | − activeThreadId === null || !isServerThread | |
| 4199 | − ? null | |
| 4200 | − : resolveThreadReferenceCopyTarget({ | |
| 4201 | − threadId: activeThreadId, | |
| 4202 | − openPanelPullRequestUrl, | |
| 4203 | − linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, | |
| 4204 | − detectedPullRequestUrl: activeThreadPr?.url ?? null, | |
| 4205 | − }), | |
| 4206 | − [ | |
| 4207 | − activeThreadId, | |
| 4208 | − activeThreadPr?.url, | |
| 4209 | − isServerThread, | |
| 4210 | − linkedThreadPullRequest?.url, | |
| 4211 | − openPanelPullRequestUrl, | |
| 4212 | − ], | |
| 4213 | − ); | |
| 4214 | − const copyActiveThreadReference = useCallback(() => { | |
| 4215 | − const target = activeThreadReferenceCopyTarget; | |
| 4216 | − if (target === null) return; | |
| 4217 | − void writeTextToClipboard(target.value, target.clipboardTarget).then( | |
| 4218 | − (didCopy) => { | |
| 4219 | − if (!didCopy) return; | |
| 4220 | − toastManager.add({ | |
| 4221 | − type: "success", | |
| 4222 | − title: target.successTitle, | |
| 4223 | − description: target.value, | |
| 4224 | − }); | |
| 4225 | − }, | |
| 4226 | − (error) => { | |
| 4227 | − console.error(error); | |
| 4228 | − toastManager.add( | |
| 4229 | − stackedThreadToast({ | |
| 4230 | − type: "error", | |
| 4231 | − title: target.failureTitle, | |
| 4232 | − description: error instanceof Error ? error.message : "An error occurred.", | |
| 4233 | − }), | |
| 4234 | − ); | |
| 4235 | − }, | |
| 4236 | − ); | |
| 4237 | − }, [activeThreadReferenceCopyTarget]); | |
| 4238 | − // The right panel offers the thread's own change request, so it can only offer it once the | |
| 4239 | − // branch has one; until then the picker says so rather than opening an empty panel. | |
| 4240 | − const addPullRequestSurface = useCallback(() => { | |
| 4241 | − if (activeThreadPr === null) return; | |
| 4242 | − openThreadPullRequest(activeThreadPr.number); | |
| 4243 | − }, [activeThreadPr, openThreadPullRequest]); | |
| 4244 | − const pullRequestSurfaceAvailable = | |
| 4245 | − supportsPullRequests && activeThreadPr !== null && threadRepository !== null; | |
| 4246 | 4037 | const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; |
| 4247 | 4038 | const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; |
| 4248 | 4039 | const nowMinute = useNowMinute(); |
| @@ -4965,7 +4756,6 @@ function ChatViewContent(props: ChatViewProps) { · 716 unmodified lines | ||
| 4965 | 4756 | "This will discard newer messages and turn diffs in this thread.", |
| 4966 | 4757 | "This action cannot be undone.", |
| 4967 | 4758 | ].join("\n"), |
| 4968 | − { variant: "destructive" }, | |
| 4969 | 4759 | ); |
| 4970 | 4760 | if (!confirmed) { |
| 4971 | 4761 | return; |
| @@ -6313,47 +6103,6 @@ function ChatViewContent(props: ChatViewProps) { · 1341 unmodified lines | ||
| 6313 | 6103 | workspaceMutationId={workspaceMutationId} |
| 6314 | 6104 | /> |
| 6315 | 6105 | </Suspense> |
| 6316 | − ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( | |
| 6317 | − <PullRequestDetailGhost /> | |
| 6318 | − ) : activeRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( | |
| 6319 | − <PullRequestsUnavailableState | |
| 6320 | − title="Pull requests unavailable" | |
| 6321 | − error="Update this environment's T3 Code server to browse pull requests." | |
| 6322 | − /> | |
| 6323 | − ) : activeRightPanelSurface?.kind === "pull-request" ? ( | |
| 6324 | − // No onClose: the surface tab's own X owns closing here, and a second X in the header | |
| 6325 | − // would be the same action twice. The thread context also drops the checkout button, so it | |
| 6326 | − // is only right for the thread's own pull request, whose branch is already under the | |
| 6327 | − // reader's feet. A link the agent wrote can open any other one here, and that one has to be | |
| 6328 | − // checkable out like it is anywhere else. | |
| 6329 | − <PullRequestDetailPanel | |
| 6330 | − key={`${activeRightPanelSurface.repository}#${activeRightPanelSurface.number}`} | |
| 6331 | − environmentId={activeThread.environmentId} | |
| 6332 | − reference={{ | |
| 6333 | − projectId: activeRightPanelSurface.projectId as ProjectId, | |
| 6334 | − repository: activeRightPanelSurface.repository, | |
| 6335 | − number: activeRightPanelSurface.number, | |
| 6336 | − }} | |
| 6337 | − context={ | |
| 6338 | − isThreadOwnPullRequest( | |
| 6339 | − { | |
| 6340 | − projectId: activeProject?.id ?? null, | |
| 6341 | − repository: threadRepository, | |
| 6342 | − number: activeThreadPr?.number ?? null, | |
| 6343 | − }, | |
| 6344 | − { | |
| 6345 | − projectId: activeRightPanelSurface.projectId, | |
| 6346 | − repository: activeRightPanelSurface.repository, | |
| 6347 | − number: activeRightPanelSurface.number, | |
| 6348 | − }, | |
| 6349 | − ) | |
| 6350 | − ? "thread" | |
| 6351 | − : "page" | |
| 6352 | − } | |
| 6353 | − chromeVariant="collapse" | |
| 6354 | − composerDraftTarget={composerDraftTarget} | |
| 6355 | − onStateChange={handlePullRequestTabStatusChange} | |
| 6356 | − /> | |
| 6357 | 6106 | ) : activeRightPanelSurface?.kind === "agents" ? ( |
| 6358 | 6107 | <AgentsPanel |
| 6359 | 6108 | model={agentPanelModel} |
| @@ -6838,16 +6587,10 @@ function ChatViewContent(props: ChatViewProps) { · 478 unmodified lines | ||
| 6838 | 6587 | onAddTerminal={addTerminalSurface} |
| 6839 | 6588 | onAddDiff={addDiffSurface} |
| 6840 | 6589 | onAddFiles={addFilesSurface} |
| 6841 | − onAddPullRequest={addPullRequestSurface} | |
| 6842 | 6590 | onAddAgents={addAgentsSurface} |
| 6843 | 6591 | browserAvailable={isPreviewSupportedInRuntime()} |
| 6844 | − terminalAvailable={activeProject !== null} | |
| 6845 | 6592 | diffAvailable={isServerThread && isGitRepo} |
| 6846 | 6593 | filesAvailable={activeProject !== null} |
| 6847 | − pullRequestAvailable={pullRequestSurfaceAvailable} | |
| 6848 | − agentsAvailable | |
| 6849 | − pullRequestStatuses={pullRequestTabStatuses} | |
| 6850 | − liveAgentCount={agentPanelModel.liveCount} | |
| 6851 | 6594 | > |
| 6852 | 6595 | {rightPanelContent} |
| 6853 | 6596 | </RightPanelTabs> |
| @@ -6873,16 +6616,10 @@ function ChatViewContent(props: ChatViewProps) { · 19 unmodified lines | ||
| 6873 | 6616 | onAddTerminal={addTerminalSurface} |
| 6874 | 6617 | onAddDiff={addDiffSurface} |
| 6875 | 6618 | onAddFiles={addFilesSurface} |
| 6876 | − onAddPullRequest={addPullRequestSurface} | |
| 6877 | 6619 | onAddAgents={addAgentsSurface} |
| 6878 | 6620 | browserAvailable={isPreviewSupportedInRuntime()} |
| 6879 | − terminalAvailable={activeProject !== null} | |
| 6880 | 6621 | diffAvailable={isServerThread && isGitRepo} |
| 6881 | 6622 | filesAvailable={activeProject !== null} |
| 6882 | − pullRequestAvailable={pullRequestSurfaceAvailable} | |
| 6883 | − agentsAvailable | |
| 6884 | − pullRequestStatuses={pullRequestTabStatuses} | |
| 6885 | − liveAgentCount={agentPanelModel.liveCount} | |
| 6886 | 6623 | > |
| 6887 | 6624 | {rightPanelContent} |
| 6888 | 6625 | </RightPanelTabs> |
| @@ -1369,10 +1369,6 @@ function ProposedPlanTimelineRow({ · 1368 unmodified lines | ||
| 1369 | 1369 | |
| 1370 | 1370 | type V2EventTone = "muted" | "warning" | "danger" | "success"; |
| 1371 | 1371 | |
| 1372 | −function subagentDisplayTitle(title: string): string { | |
| 1373 | − return title.replace(/^Subagent:\s*/i, ""); | |
| 1374 | −} | |
| 1375 | − | |
| 1376 | 1372 | function v2EventPresentation(item: OrchestrationV2TurnItem): { |
| 1377 | 1373 | readonly label: string; |
| 1378 | 1374 | readonly detail: string | null; |
| @@ -1429,14 +1425,6 @@ function v2EventPresentation(item: OrchestrationV2TurnItem): { · 50 unmodified lines | ||
| 1429 | 1425 | icon: MinusIcon, |
| 1430 | 1426 | }; |
| 1431 | 1427 | } |
| 1432 | − case "subagent": | |
| 1433 | − return { | |
| 1434 | − label: subagentDisplayTitle(item.title ?? "Subagent"), | |
| 1435 | − detail: item.result ?? item.progress ?? item.prompt, | |
| 1436 | − tone: | |
| 1437 | − item.status === "failed" ? "danger" : item.status === "completed" ? "success" : "muted", | |
| 1438 | − icon: BotIcon, | |
| 1439 | − }; | |
| 1440 | 1428 | case "approval_request": |
| 1441 | 1429 | return { |
| 1442 | 1430 | label: "Approval requested", |
| @@ -1511,7 +1499,14 @@ function V2EventTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "event" · 68 unmodified lines | ||
| 1511 | 1499 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> |
| 1512 | 1500 | <span className="text-xs font-medium text-foreground/90">{presentation.label}</span> |
| 1513 | 1501 | {item.status !== "completed" ? ( |
| 1514 | − <span className="rounded-full border border-border/70 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"> | |
| 1502 | + <span | |
| 1503 | + className={cn( | |
| 1504 | + "rounded-full border px-1.5 py-0.5 font-mono text-[10px]", | |
| 1505 | + item.status === "failed" | |
| 1506 | + ? "border-destructive/40 text-destructive" | |
| 1507 | + : "border-border/70 text-muted-foreground", | |
| 1508 | + )} | |
| 1509 | + > | |
| 1515 | 1510 | {item.status} |
| 1516 | 1511 | </span> |
| 1517 | 1512 | ) : null} |
| @@ -2303,8 +2298,19 @@ function workEntryPreview( · 785 unmodified lines | ||
| 2303 | 2298 | workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">, |
| 2304 | 2299 | workspaceRoot: string | undefined, |
| 2305 | 2300 | ) { |
| 2301 | + // Prefer stdout/detail so completed shell/monitor results are visible collapsed | |
| 2302 | + // (command alone hid ls listings behind expand-only inspector JSON). | |
| 2303 | + if (workEntry.detail?.trim()) { | |
| 2304 | + const lines = workEntry.detail | |
| 2305 | + .trim() | |
| 2306 | + .split(/\r?\n/) | |
| 2307 | + .map((line) => line.trim()) | |
| 2308 | + .filter((line) => line.length > 0); | |
| 2309 | + if (lines.length > 0) { | |
| 2310 | + return lines.slice(0, 3).join(" · "); | |
| 2311 | + } | |
| 2312 | + } | |
| 2306 | 2313 | if (workEntry.command) return workEntry.command; |
| 2307 | − if (workEntry.detail) return workEntry.detail; | |
| 2308 | 2314 | if ((workEntry.changedFiles?.length ?? 0) === 0) return null; |
| 2309 | 2315 | const [firstPath] = workEntry.changedFiles ?? []; |
| 2310 | 2316 | if (!firstPath) return null; |
| @@ -594,6 +594,13 @@ describe("chat/editor shortcuts", () => { · 593 unmodified lines | ||
| 594 | 594 | platform: "Linux", |
| 595 | 595 | }), |
| 596 | 596 | ); |
| 597 | + assert.isFalse( | |
| 598 | + isOpenFavoriteEditorShortcut( | |
| 599 | + event({ key: "o", metaKey: true, repeat: true }), | |
| 600 | + DEFAULT_BINDINGS, | |
| 601 | + { platform: "MacIntel" }, | |
| 602 | + ), | |
| 603 | + ); | |
| 597 | 604 | }); |
| 598 | 605 | |
| 599 | 606 | it("matches commandPalette.toggle shortcut outside terminal focus", () => { |
| @@ -14,6 +14,7 @@ export interface ShortcutEventLike { · 13 unmodified lines | ||
| 14 | 14 | type?: string; |
| 15 | 15 | code?: string; |
| 16 | 16 | key: string; |
| 17 | + repeat?: boolean; | |
| 17 | 18 | metaKey: boolean; |
| 18 | 19 | ctrlKey: boolean; |
| 19 | 20 | shiftKey: boolean; |
| @@ -440,7 +441,10 @@ export function isOpenFavoriteEditorShortcut( · 420 unmodified lines | ||
| 440 | 441 | keybindings: ResolvedKeybindingsConfig, |
| 441 | 442 | options?: ShortcutMatchOptions, |
| 442 | 443 | ): boolean { |
| 443 | − return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options); | |
| 444 | + return ( | |
| 445 | + event.repeat !== true && | |
| 446 | + matchesCommandShortcut(event, keybindings, "editor.openFavorite", options) | |
| 447 | + ); | |
| 444 | 448 | } |
| 445 | 449 | |
| 446 | 450 | export function isTerminalClearShortcut( |
| @@ -39,6 +39,7 @@ function shouldShowLocalTurnItem( · 38 unmodified lines | ||
| 39 | 39 | item, |
| 40 | 40 | runs: projection.runs, |
| 41 | 41 | attempts: projection.attempts, |
| 42 | + items: projection.turnItems, | |
| 42 | 43 | }); |
| 43 | 44 | } |
| 44 | 45 | |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import * as Context from "effect/Context"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | +import * as Stdio from "effect/Stdio"; | |
| 3 | 4 | import * as Layer from "effect/Layer"; |
| 4 | 5 | import * as Schema from "effect/Schema"; |
| 5 | 6 | import * as Scope from "effect/Scope"; |
| @@ -26,16 +27,10 @@ export interface AcpClientOptions { · 20 unmodified lines | ||
| 26 | 27 | readonly logIncoming?: boolean; |
| 27 | 28 | readonly logOutgoing?: boolean; |
| 28 | 29 | readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>; |
| 29 | − /** Transforms child output before protocol logging and parsing. */ | |
| 30 | − readonly transformStdout?: ( | |
| 31 | − stdout: ChildProcessSpawner.ChildProcessHandle["stdout"], | |
| 32 | − ) => AcpProtocol.AcpStdio["stdin"]; | |
| 33 | − /** Transforms decoded session updates before buffering or delivery. */ | |
| 34 | − readonly transformSessionUpdate?: ( | |
| 35 | − notification: AcpSchema.SessionNotification, | |
| 36 | − ) => AcpSchema.SessionNotification; | |
| 37 | − /** Reports input failures and process exits, even between requests. */ | |
| 38 | − readonly onTermination?: (error: AcpError.AcpError) => Effect.Effect<void, never, never>; | |
| 30 | + readonly onIncomingRequest?: AcpProtocol.AcpPatchedProtocolOptions["onIncomingRequest"]; | |
| 31 | + readonly onTermination?: AcpProtocol.AcpPatchedProtocolOptions["onTermination"]; | |
| 32 | + readonly onOutgoingResponseFailure?: AcpProtocol.AcpPatchedProtocolOptions["onOutgoingResponseFailure"]; | |
| 33 | + readonly onOutgoingResponse?: AcpProtocol.AcpPatchedProtocolOptions["onOutgoingResponse"]; | |
| 39 | 34 | } |
| 40 | 35 | |
| 41 | 36 | type AcpClientRaw = { |
| @@ -316,7 +311,7 @@ interface BufferedNotificationHandler<A> { · 274 unmodified lines | ||
| 316 | 311 | } |
| 317 | 312 | |
| 318 | 313 | export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( |
| 319 | − stdio: AcpProtocol.AcpStdio, | |
| 314 | + stdio: Stdio.Stdio, | |
| 320 | 315 | options: AcpClientOptions = {}, |
| 321 | 316 | terminationError?: Effect.Effect<AcpError.AcpError>, |
| 322 | 317 | ): Effect.fn.Return<AcpClient["Service"], never, Scope.Scope> { |
| @@ -413,10 +408,12 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( · 90 unmodified lines | ||
| 413 | 408 | ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}), |
| 414 | 409 | ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}), |
| 415 | 410 | ...(options.logger ? { logger: options.logger } : {}), |
| 416 | − ...(options.transformSessionUpdate | |
| 417 | − ? { transformSessionUpdate: options.transformSessionUpdate } | |
| 418 | − : {}), | |
| 411 | + ...(options.onIncomingRequest ? { onIncomingRequest: options.onIncomingRequest } : {}), | |
| 419 | 412 | ...(options.onTermination ? { onTermination: options.onTermination } : {}), |
| 413 | + ...(options.onOutgoingResponseFailure | |
| 414 | + ? { onOutgoingResponseFailure: options.onOutgoingResponseFailure } | |
| 415 | + : {}), | |
| 416 | + ...(options.onOutgoingResponse ? { onOutgoingResponse: options.onOutgoingResponse } : {}), | |
| 420 | 417 | onNotification: dispatchNotification, |
| 421 | 418 | onExtRequest: dispatchExtRequest, |
| 422 | 419 | }); |
| @@ -585,19 +582,14 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( · 162 unmodified lines | ||
| 585 | 582 | }); |
| 586 | 583 | }); |
| 587 | 584 | |
| 588 | −export const layer = ( | |
| 589 | − stdio: AcpProtocol.AcpStdio, | |
| 590 | − options: AcpClientOptions = {}, | |
| 591 | −): Layer.Layer<AcpClient> => Layer.effect(AcpClient, make(stdio, options)); | |
| 585 | +export const layer = (stdio: Stdio.Stdio, options: AcpClientOptions = {}): Layer.Layer<AcpClient> => | |
| 586 | + Layer.effect(AcpClient, make(stdio, options)); | |
| 592 | 587 | |
| 593 | 588 | export const layerChildProcess = ( |
| 594 | 589 | handle: ChildProcessSpawner.ChildProcessHandle, |
| 595 | 590 | options: AcpClientOptions = {}, |
| 596 | 591 | ): Layer.Layer<AcpClient> => { |
| 597 | − const stdio = { | |
| 598 | − ...makeChildStdio(handle), | |
| 599 | − stdin: options.transformStdout?.(handle.stdout) ?? handle.stdout, | |
| 600 | − }; | |
| 592 | + const stdio = makeChildStdio(handle); | |
| 601 | 593 | const terminationError = makeTerminationError(handle); |
| 602 | 594 | return Layer.effect(AcpClient, make(stdio, options, terminationError)); |
| 603 | 595 | }; |
| @@ -3,8 +3,12 @@ import * as AcpError from "./errors.ts"; · 2 unmodified lines | ||
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Deferred from "effect/Deferred"; |
| 5 | 5 | import * as Fiber from "effect/Fiber"; |
| 6 | +import * as Exit from "effect/Exit"; | |
| 6 | 7 | import * as Queue from "effect/Queue"; |
| 7 | 8 | import * as Schema from "effect/Schema"; |
| 9 | +import * as Scope from "effect/Scope"; | |
| 10 | +import * as Sink from "effect/Sink"; | |
| 11 | +import * as Stdio from "effect/Stdio"; | |
| 8 | 12 | import * as Stream from "effect/Stream"; |
| 9 | 13 | import * as Ref from "effect/Ref"; |
| 10 | 14 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| @@ -533,6 +537,177 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { · 522 unmodified lines | ||
| 533 | 537 | }), |
| 534 | 538 | ); |
| 535 | 539 | |
| 540 | + it.effect("acknowledges outgoing responses and reports encoding failures by request id", () => | |
| 541 | + Effect.gen(function* () { | |
| 542 | + const { stdio } = yield* makeInMemoryStdio(); | |
| 543 | + const acknowledged = yield* Deferred.make<string>(); | |
| 544 | + const failed = yield* Deferred.make<readonly [string, AcpError.AcpError]>(); | |
| 545 | + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ | |
| 546 | + stdio, | |
| 547 | + serverRequestMethods: new Set(), | |
| 548 | + onOutgoingResponse: (requestId) => | |
| 549 | + Deferred.succeed(acknowledged, requestId).pipe(Effect.asVoid), | |
| 550 | + onOutgoingResponseFailure: (requestId, error) => | |
| 551 | + Deferred.succeed(failed, [requestId, error]).pipe(Effect.asVoid), | |
| 552 | + }); | |
| 553 | + | |
| 554 | + yield* transport.serverProtocol.send(0, { | |
| 555 | + _tag: "Exit", | |
| 556 | + requestId: "success-1", | |
| 557 | + exit: { _tag: "Success", value: { ok: true } }, | |
| 558 | + }); | |
| 559 | + assert.equal(yield* Deferred.await(acknowledged), "success-1"); | |
| 560 | + | |
| 561 | + const circular: { self?: unknown } = {}; | |
| 562 | + circular.self = circular; | |
| 563 | + yield* transport.serverProtocol | |
| 564 | + .send(0, { | |
| 565 | + _tag: "Exit", | |
| 566 | + requestId: "failure-1", | |
| 567 | + exit: { _tag: "Success", value: circular }, | |
| 568 | + }) | |
| 569 | + .pipe(Effect.exit); | |
| 570 | + const [requestId, error] = yield* Deferred.await(failed); | |
| 571 | + assert.equal(requestId, "failure-1"); | |
| 572 | + assert.instanceOf(error, AcpError.AcpProtocolParseError); | |
| 573 | + }), | |
| 574 | + ); | |
| 575 | + | |
| 576 | + it.effect("acknowledges a response only after the stdout write completes", () => | |
| 577 | + Effect.gen(function* () { | |
| 578 | + const writeStarted = yield* Deferred.make<void>(); | |
| 579 | + const releaseWrite = yield* Deferred.make<void>(); | |
| 580 | + const acknowledged = yield* Deferred.make<string>(); | |
| 581 | + const stdio = Stdio.make({ | |
| 582 | + args: Effect.succeed([]), | |
| 583 | + stdin: Stream.never, | |
| 584 | + stdout: () => | |
| 585 | + Sink.forEach(() => | |
| 586 | + Deferred.succeed(writeStarted, undefined).pipe( | |
| 587 | + Effect.andThen(Deferred.await(releaseWrite)), | |
| 588 | + ), | |
| 589 | + ), | |
| 590 | + stderr: () => Sink.drain, | |
| 591 | + }); | |
| 592 | + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ | |
| 593 | + stdio, | |
| 594 | + serverRequestMethods: new Set(), | |
| 595 | + onOutgoingResponse: (requestId) => | |
| 596 | + Deferred.succeed(acknowledged, requestId).pipe(Effect.asVoid), | |
| 597 | + }); | |
| 598 | + const sendFiber = yield* transport.serverProtocol | |
| 599 | + .send(0, { | |
| 600 | + _tag: "Exit", | |
| 601 | + requestId: "held-write", | |
| 602 | + exit: { _tag: "Success", value: { ok: true } }, | |
| 603 | + }) | |
| 604 | + .pipe(Effect.forkScoped); | |
| 605 | + | |
| 606 | + yield* Deferred.await(writeStarted); | |
| 607 | + assert.isUndefined(sendFiber.pollUnsafe()); | |
| 608 | + assert.isFalse(yield* Deferred.isDone(acknowledged)); | |
| 609 | + yield* Deferred.succeed(releaseWrite, undefined); | |
| 610 | + yield* Fiber.join(sendFiber); | |
| 611 | + assert.equal(yield* Deferred.await(acknowledged), "held-write"); | |
| 612 | + }), | |
| 613 | + ); | |
| 614 | + | |
| 615 | + it.effect("fails current, queued, and future responses when the stdout writer fails", () => | |
| 616 | + Effect.gen(function* () { | |
| 617 | + const writeStarted = yield* Deferred.make<void>(); | |
| 618 | + const releaseFailure = yield* Deferred.make<void>(); | |
| 619 | + const failures = yield* Queue.unbounded<string>(); | |
| 620 | + const terminated = yield* Deferred.make<AcpError.AcpError>(); | |
| 621 | + const stdio = Stdio.make({ | |
| 622 | + args: Effect.succeed([]), | |
| 623 | + stdin: Stream.never, | |
| 624 | + stdout: () => | |
| 625 | + Sink.forEach(() => | |
| 626 | + Deferred.succeed(writeStarted, undefined).pipe( | |
| 627 | + Effect.andThen(Deferred.await(releaseFailure)), | |
| 628 | + Effect.andThen(Effect.die("stdout failed")), | |
| 629 | + ), | |
| 630 | + ), | |
| 631 | + stderr: () => Sink.drain, | |
| 632 | + }); | |
| 633 | + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ | |
| 634 | + stdio, | |
| 635 | + serverRequestMethods: new Set(), | |
| 636 | + onOutgoingResponseFailure: (requestId) => Queue.offer(failures, requestId), | |
| 637 | + onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid), | |
| 638 | + }); | |
| 639 | + const send = (requestId: string) => | |
| 640 | + transport.serverProtocol | |
| 641 | + .send(0, { | |
| 642 | + _tag: "Exit", | |
| 643 | + requestId, | |
| 644 | + exit: { _tag: "Success", value: { ok: true } }, | |
| 645 | + }) | |
| 646 | + .pipe(Effect.exit); | |
| 647 | + const current = yield* send("current").pipe(Effect.forkScoped); | |
| 648 | + yield* Deferred.await(writeStarted); | |
| 649 | + const queued = yield* send("queued").pipe(Effect.forkScoped); | |
| 650 | + yield* Effect.yieldNow; | |
| 651 | + assert.isUndefined(current.pollUnsafe()); | |
| 652 | + assert.isUndefined(queued.pollUnsafe()); | |
| 653 | + yield* Deferred.succeed(releaseFailure, undefined); | |
| 654 | + assert.instanceOf(yield* Deferred.await(terminated), AcpError.AcpTransportError); | |
| 655 | + yield* Fiber.join(current); | |
| 656 | + yield* Fiber.join(queued); | |
| 657 | + yield* send("future"); | |
| 658 | + | |
| 659 | + assert.deepEqual( | |
| 660 | + new Set(yield* Queue.takeN(failures, 3)), | |
| 661 | + new Set(["current", "queued", "future"]), | |
| 662 | + ); | |
| 663 | + }), | |
| 664 | + ); | |
| 665 | + | |
| 666 | + it.effect( | |
| 667 | + "atomically rejects an admitted response when the writer closes before queue offer", | |
| 668 | + () => | |
| 669 | + Effect.gen(function* () { | |
| 670 | + const admitted = yield* Deferred.make<void>(); | |
| 671 | + const releaseAdmission = yield* Deferred.make<void>(); | |
| 672 | + const failures = yield* Queue.unbounded<string>(); | |
| 673 | + const protocolScope = yield* Scope.make(); | |
| 674 | + const stdio = Stdio.make({ | |
| 675 | + args: Effect.succeed([]), | |
| 676 | + stdin: Stream.never, | |
| 677 | + stdout: () => Sink.drain, | |
| 678 | + stderr: () => Sink.drain, | |
| 679 | + }); | |
| 680 | + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ | |
| 681 | + stdio, | |
| 682 | + serverRequestMethods: new Set(), | |
| 683 | + onOutgoingResponseFailure: (requestId) => Queue.offer(failures, requestId), | |
| 684 | + testHooks: { | |
| 685 | + onOutgoingWriteAdmitted: () => | |
| 686 | + Deferred.succeed(admitted, undefined).pipe( | |
| 687 | + Effect.andThen(Deferred.await(releaseAdmission)), | |
| 688 | + ), | |
| 689 | + }, | |
| 690 | + }).pipe(Effect.provideService(Scope.Scope, protocolScope)); | |
| 691 | + const send = (requestId: string) => | |
| 692 | + transport.serverProtocol | |
| 693 | + .send(0, { | |
| 694 | + _tag: "Exit", | |
| 695 | + requestId, | |
| 696 | + exit: { _tag: "Success", value: { ok: true } }, | |
| 697 | + }) | |
| 698 | + .pipe(Effect.exit); | |
| 699 | + const admittedSend = yield* send("admitted").pipe(Effect.forkScoped); | |
| 700 | + | |
| 701 | + yield* Deferred.await(admitted); | |
| 702 | + yield* Scope.close(protocolScope, Exit.void); | |
| 703 | + yield* Deferred.succeed(releaseAdmission, undefined); | |
| 704 | + yield* Fiber.join(admittedSend); | |
| 705 | + yield* send("future"); | |
| 706 | + | |
| 707 | + assert.deepEqual(yield* Queue.takeN(failures, 2), ["admitted", "future"]); | |
| 708 | + }), | |
| 709 | + ); | |
| 710 | + | |
| 536 | 711 | it.effect("cleans up interrupted extension requests before a late response arrives", () => |
| 537 | 712 | Effect.gen(function* () { |
| 538 | 713 | const { stdio, input, output } = yield* makeInMemoryStdio(); |
| @@ -638,6 +813,31 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { · 99 unmodified lines | ||
| 638 | 813 | }), |
| 639 | 814 | ); |
| 640 | 815 | |
| 816 | + it.effect("does not treat intentional serverProtocol.end as a transport failure", () => | |
| 817 | + Effect.gen(function* () { | |
| 818 | + const { stdio, input } = yield* makeInMemoryStdio(); | |
| 819 | + const terminations = yield* Ref.make(0); | |
| 820 | + const writerExit = yield* Deferred.make<{ readonly intentionalEnd: boolean }>(); | |
| 821 | + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ | |
| 822 | + stdio, | |
| 823 | + serverRequestMethods: new Set(), | |
| 824 | + onTermination: () => Ref.update(terminations, (count) => count + 1), | |
| 825 | + testHooks: { | |
| 826 | + onOutgoingWriterExit: (detail) => | |
| 827 | + Deferred.succeed(writerExit, detail).pipe(Effect.asVoid), | |
| 828 | + }, | |
| 829 | + }); | |
| 830 | + | |
| 831 | + yield* transport.serverProtocol.end(0); | |
| 832 | + const exitDetail = yield* Deferred.await(writerExit); | |
| 833 | + assert.isTrue(exitDetail.intentionalEnd); | |
| 834 | + assert.equal(yield* Ref.get(terminations), 0); | |
| 835 | + | |
| 836 | + // Tear down the forever input reader so the scoped test can finish. | |
| 837 | + yield* Queue.end(input); | |
| 838 | + }), | |
| 839 | + ); | |
| 840 | + | |
| 641 | 841 | it.effect("does not emit a second process-exit error after a decode failure", () => |
| 642 | 842 | Effect.gen(function* () { |
| 643 | 843 | const handle = yield* makeHandle({ |
| @@ -4,7 +4,6 @@ import * as Deferred from "effect/Deferred"; · 3 unmodified lines | ||
| 4 | 4 | import * as Exit from "effect/Exit"; |
| 5 | 5 | import * as Queue from "effect/Queue"; |
| 6 | 6 | import * as Ref from "effect/Ref"; |
| 7 | −import type * as PlatformError from "effect/PlatformError"; | |
| 8 | 7 | import * as Schema from "effect/Schema"; |
| 9 | 8 | import * as Scope from "effect/Scope"; |
| 10 | 9 | import * as Stream from "effect/Stream"; |
| @@ -43,21 +42,18 @@ export type AcpIncomingNotification = · 32 unmodified lines | ||
| 43 | 42 | readonly params: unknown; |
| 44 | 43 | }; |
| 45 | 44 | |
| 46 | −/** Standard I/O whose input can report provider-specific ACP failures. */ | |
| 47 | −export interface AcpStdio extends Omit<Stdio.Stdio, "stdin"> { | |
| 48 | − readonly stdin: Stream.Stream<Uint8Array, PlatformError.PlatformError | AcpError.AcpError>; | |
| 49 | −} | |
| 50 | − | |
| 51 | 45 | export interface AcpPatchedProtocolOptions { |
| 52 | − readonly stdio: AcpStdio; | |
| 46 | + readonly stdio: Stdio.Stdio; | |
| 53 | 47 | readonly terminationError?: Effect.Effect<AcpError.AcpError>; |
| 54 | 48 | readonly serverRequestMethods: ReadonlySet<string>; |
| 55 | 49 | readonly logIncoming?: boolean; |
| 56 | 50 | readonly logOutgoing?: boolean; |
| 57 | 51 | readonly logger?: (event: AcpProtocolLogEvent) => Effect.Effect<void, never>; |
| 58 | − readonly transformSessionUpdate?: ( | |
| 59 | − notification: AcpSchema.SessionNotification, | |
| 60 | − ) => AcpSchema.SessionNotification; | |
| 52 | + readonly onIncomingRequest?: ( | |
| 53 | + requestId: string, | |
| 54 | + method: string, | |
| 55 | + payload: unknown, | |
| 56 | + ) => Effect.Effect<void, never>; | |
| 61 | 57 | readonly onNotification?: ( |
| 62 | 58 | notification: AcpIncomingNotification, |
| 63 | 59 | ) => Effect.Effect<void, AcpError.AcpError, never>; |
| @@ -66,6 +62,18 @@ export interface AcpPatchedProtocolOptions { · 2 unmodified lines | ||
| 66 | 62 | params: unknown, |
| 67 | 63 | ) => Effect.Effect<unknown, AcpError.AcpError, never>; |
| 68 | 64 | readonly onTermination?: (error: AcpError.AcpError) => Effect.Effect<void, never, never>; |
| 65 | + readonly onOutgoingResponseFailure?: ( | |
| 66 | + requestId: string, | |
| 67 | + error: AcpError.AcpError, | |
| 68 | + ) => Effect.Effect<void, never>; | |
| 69 | + readonly onOutgoingResponse?: (requestId: string) => Effect.Effect<void, never>; | |
| 70 | + readonly testHooks?: { | |
| 71 | + readonly onOutgoingWriteAdmitted?: () => Effect.Effect<void>; | |
| 72 | + /** Invoked from the stdout writer exit path (after intentional/unexpected handling). */ | |
| 73 | + readonly onOutgoingWriterExit?: (input: { | |
| 74 | + readonly intentionalEnd: boolean; | |
| 75 | + }) => Effect.Effect<void>; | |
| 76 | + }; | |
| 69 | 77 | } |
| 70 | 78 | |
| 71 | 79 | export interface AcpPatchedProtocol { |
| @@ -81,6 +89,17 @@ interface AcpPendingRequest { · 9 unmodified lines | ||
| 81 | 89 | readonly method: string; |
| 82 | 90 | } |
| 83 | 91 | |
| 92 | +interface AcpOutgoingWrite { | |
| 93 | + readonly acknowledgement?: Deferred.Deferred<void, AcpError.AcpError>; | |
| 94 | + readonly payload: string | Uint8Array; | |
| 95 | +} | |
| 96 | + | |
| 97 | +interface AcpOutgoingWriterState { | |
| 98 | + readonly outstandingAcknowledgements: ReadonlySet<Deferred.Deferred<void, AcpError.AcpError>>; | |
| 99 | + readonly intentionalEnd: boolean; | |
| 100 | + readonly terminalError?: AcpError.AcpError; | |
| 101 | +} | |
| 102 | + | |
| 84 | 103 | const decodeSessionUpdate = Schema.decodeUnknownEffect(AcpSchema.SessionNotification); |
| 85 | 104 | const decodeElicitationComplete = Schema.decodeUnknownEffect( |
| 86 | 105 | AcpSchema.ElicitationCompleteNotification, |
| @@ -108,16 +127,15 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 21 unmodified lines | ||
| 108 | 127 | MAX_BUFFERED_RAW_NOTIFICATIONS, |
| 109 | 128 | ); |
| 110 | 129 | const disconnects = yield* Queue.unbounded<number>(); |
| 111 | − const outgoing = yield* Queue.unbounded<string | Uint8Array, Cause.Done<void>>(); | |
| 112 | − const nextRequestId = yield* Ref.make(1); | |
| 130 | + const outgoing = yield* Queue.unbounded<AcpOutgoingWrite, AcpError.AcpError | Cause.Done<void>>(); | |
| 131 | + const outgoingWriterState = yield* Ref.make<AcpOutgoingWriterState>({ | |
| 132 | + intentionalEnd: false, | |
| 133 | + outstandingAcknowledgements: new Set(), | |
| 134 | + }); | |
| 135 | + const nextRequestId = yield* Ref.make(1n); | |
| 113 | 136 | const terminationHandled = yield* Ref.make(false); |
| 114 | − const terminationFailure = yield* Deferred.make<never, AcpError.AcpError>(); | |
| 115 | 137 | const extPending = yield* Ref.make(new Map<string, AcpPendingRequest>()); |
| 116 | 138 | |
| 117 | − const ensureActive = Ref.get(terminationHandled).pipe( | |
| 118 | − Effect.flatMap((terminated) => (terminated ? Deferred.await(terminationFailure) : Effect.void)), | |
| 119 | − ); | |
| 120 | − | |
| 121 | 139 | const logProtocol = (event: AcpProtocolLogEvent) => { |
| 122 | 140 | if (event.direction === "incoming" && !options.logIncoming) { |
| 123 | 141 | return Effect.void; |
| @@ -139,7 +157,6 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 15 unmodified lines | ||
| 139 | 157 | if (message._tag === "Interrupt") { |
| 140 | 158 | return; |
| 141 | 159 | } |
| 142 | − yield* ensureActive; | |
| 143 | 160 | yield* logProtocol({ |
| 144 | 161 | direction: "outgoing", |
| 145 | 162 | stage: "decoded", |
| @@ -166,8 +183,66 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 20 unmodified lines | ||
| 166 | 183 | payload: typeof encoded === "string" ? encoded : new TextDecoder().decode(encoded), |
| 167 | 184 | }); |
| 168 | 185 | |
| 169 | − yield* ensureActive; | |
| 170 | − yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid); | |
| 186 | + const acknowledgement = | |
| 187 | + message._tag === "Exit" ? yield* Deferred.make<void, AcpError.AcpError>() : undefined; | |
| 188 | + const admissionError = yield* Ref.modify(outgoingWriterState, (state) => { | |
| 189 | + if (state.terminalError !== undefined) { | |
| 190 | + return [state.terminalError, state] as const; | |
| 191 | + } | |
| 192 | + if (acknowledgement === undefined) { | |
| 193 | + return [undefined, state] as const; | |
| 194 | + } | |
| 195 | + return [ | |
| 196 | + undefined, | |
| 197 | + { | |
| 198 | + ...state, | |
| 199 | + outstandingAcknowledgements: new Set(state.outstandingAcknowledgements).add( | |
| 200 | + acknowledgement, | |
| 201 | + ), | |
| 202 | + }, | |
| 203 | + ] as const; | |
| 204 | + }); | |
| 205 | + if (admissionError !== undefined) { | |
| 206 | + return yield* admissionError; | |
| 207 | + } | |
| 208 | + if (options.testHooks?.onOutgoingWriteAdmitted !== undefined) { | |
| 209 | + yield* options.testHooks.onOutgoingWriteAdmitted(); | |
| 210 | + } | |
| 211 | + yield* Queue.offer(outgoing, { | |
| 212 | + payload: encoded, | |
| 213 | + ...(acknowledgement === undefined ? {} : { acknowledgement }), | |
| 214 | + }).pipe( | |
| 215 | + Effect.mapError( | |
| 216 | + (cause) => | |
| 217 | + new AcpError.AcpTransportError({ | |
| 218 | + detail: "Failed to queue an outgoing ACP message", | |
| 219 | + cause, | |
| 220 | + }), | |
| 221 | + ), | |
| 222 | + Effect.filterOrFail( | |
| 223 | + (accepted) => accepted, | |
| 224 | + () => | |
| 225 | + new AcpError.AcpTransportError({ | |
| 226 | + detail: "The ACP output queue was closed before accepting a message", | |
| 227 | + cause: "Output queue closed", | |
| 228 | + }), | |
| 229 | + ), | |
| 230 | + Effect.tapError((error) => | |
| 231 | + acknowledgement === undefined | |
| 232 | + ? Effect.void | |
| 233 | + : Ref.update(outgoingWriterState, (state) => { | |
| 234 | + const updated = new Set(state.outstandingAcknowledgements); | |
| 235 | + updated.delete(acknowledgement); | |
| 236 | + return { ...state, outstandingAcknowledgements: updated }; | |
| 237 | + }).pipe(Effect.andThen(Deferred.fail(acknowledgement, error)), Effect.asVoid), | |
| 238 | + ), | |
| 239 | + ); | |
| 240 | + if (acknowledgement !== undefined) { | |
| 241 | + yield* Deferred.await(acknowledgement); | |
| 242 | + } | |
| 243 | + if (message._tag === "Exit" && options.onOutgoingResponse !== undefined) { | |
| 244 | + yield* options.onOutgoingResponse(message.requestId); | |
| 245 | + } | |
| 171 | 246 | } |
| 172 | 247 | }); |
| 173 | 248 | |
| @@ -233,7 +308,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 59 unmodified lines | ||
| 233 | 308 | }), |
| 234 | 309 | }).pipe(Effect.asVoid); |
| 235 | 310 | |
| 236 | − const handleTermination = (classify: () => Effect.Effect<AcpError.AcpError>) => | |
| 311 | + const handleTermination = (classify: () => Effect.Effect<AcpError.AcpError | undefined>) => | |
| 237 | 312 | Ref.modify(terminationHandled, (handled) => { |
| 238 | 313 | if (handled) { |
| 239 | 314 | return [Effect.void, true] as const; |
| @@ -242,7 +317,9 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 2 unmodified lines | ||
| 242 | 317 | Effect.gen(function* () { |
| 243 | 318 | yield* Queue.offer(disconnects, 0); |
| 244 | 319 | const error = yield* classify(); |
| 245 | − yield* Deferred.fail(terminationFailure, error); | |
| 320 | + if (!error) { | |
| 321 | + return; | |
| 322 | + } | |
| 246 | 323 | yield* failAllExtPending(error); |
| 247 | 324 | yield* emitClientProtocolError(error); |
| 248 | 325 | if (options.onTermination) { |
| @@ -303,7 +380,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 54 unmodified lines | ||
| 303 | 380 | ({ |
| 304 | 381 | _tag: "SessionUpdate", |
| 305 | 382 | method: CLIENT_METHODS.session_update, |
| 306 | − params: options.transformSessionUpdate?.(params) ?? params, | |
| 383 | + params, | |
| 307 | 384 | }) satisfies AcpIncomingNotification, |
| 308 | 385 | ), |
| 309 | 386 | Effect.mapError((cause) => |
| @@ -343,8 +420,11 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 33 unmodified lines | ||
| 343 | 420 | }); |
| 344 | 421 | } |
| 345 | 422 | |
| 423 | + const observeIncoming = | |
| 424 | + options.onIncomingRequest?.(message.id, message.tag, message.payload) ?? Effect.void; | |
| 425 | + | |
| 346 | 426 | if (!options.serverRequestMethods.has(message.tag)) { |
| 347 | − return handleExtRequest(message).pipe( | |
| 427 | + return observeIncoming.pipe(Effect.andThen(handleExtRequest(message))).pipe( | |
| 348 | 428 | Effect.catchTags({ |
| 349 | 429 | AcpProtocolParseError: (error) => |
| 350 | 430 | Effect.logWarning(error).pipe( |
| @@ -369,7 +449,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 18 unmodified lines | ||
| 369 | 449 | ); |
| 370 | 450 | } |
| 371 | 451 | |
| 372 | − return Queue.offer(serverQueue, message).pipe(Effect.asVoid); | |
| 452 | + return observeIncoming.pipe(Effect.andThen(Queue.offer(serverQueue, message)), Effect.asVoid); | |
| 373 | 453 | }; |
| 374 | 454 | |
| 375 | 455 | const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) => |
| @@ -511,7 +591,112 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 135 unmodified lines | ||
| 511 | 591 | Effect.forkScoped, |
| 512 | 592 | ); |
| 513 | 593 | |
| 514 | − yield* Stream.fromQueue(outgoing).pipe(Stream.run(options.stdio.stdout()), Effect.forkScoped); | |
| 594 | + const failOutgoingWrites = (error: AcpError.AcpError) => | |
| 595 | + Ref.modify(outgoingWriterState, (state) => { | |
| 596 | + const terminalError = state.terminalError ?? error; | |
| 597 | + return [ | |
| 598 | + [terminalError, state.outstandingAcknowledgements] as const, | |
| 599 | + { | |
| 600 | + ...state, | |
| 601 | + terminalError, | |
| 602 | + outstandingAcknowledgements: new Set<Deferred.Deferred<void, AcpError.AcpError>>(), | |
| 603 | + }, | |
| 604 | + ] as const; | |
| 605 | + }).pipe( | |
| 606 | + Effect.flatMap((acknowledgements) => | |
| 607 | + Effect.forEach( | |
| 608 | + acknowledgements[1], | |
| 609 | + (acknowledgement) => Deferred.fail(acknowledgement, acknowledgements[0]), | |
| 610 | + { concurrency: "unbounded", discard: true }, | |
| 611 | + ), | |
| 612 | + ), | |
| 613 | + Effect.andThen(Queue.fail(outgoing, error)), | |
| 614 | + Effect.asVoid, | |
| 615 | + ); | |
| 616 | + const completeOutgoingWrites = () => | |
| 617 | + Ref.modify(outgoingWriterState, (state) => { | |
| 618 | + return [ | |
| 619 | + state.outstandingAcknowledgements, | |
| 620 | + { | |
| 621 | + ...state, | |
| 622 | + outstandingAcknowledgements: new Set<Deferred.Deferred<void, AcpError.AcpError>>(), | |
| 623 | + }, | |
| 624 | + ] as const; | |
| 625 | + }).pipe( | |
| 626 | + Effect.flatMap((acknowledgements) => | |
| 627 | + Effect.forEach( | |
| 628 | + acknowledgements, | |
| 629 | + (acknowledgement) => Deferred.succeed(acknowledgement, undefined), | |
| 630 | + { concurrency: "unbounded", discard: true }, | |
| 631 | + ), | |
| 632 | + ), | |
| 633 | + Effect.asVoid, | |
| 634 | + ); | |
| 635 | + const acknowledgeOutgoingWrite = (acknowledgement: Deferred.Deferred<void, AcpError.AcpError>) => | |
| 636 | + Ref.update(outgoingWriterState, (state) => { | |
| 637 | + const updated = new Set(state.outstandingAcknowledgements); | |
| 638 | + updated.delete(acknowledgement); | |
| 639 | + return { ...state, outstandingAcknowledgements: updated }; | |
| 640 | + }).pipe(Effect.andThen(Deferred.succeed(acknowledgement, undefined)), Effect.asVoid); | |
| 641 | + yield* Stream.fromQueue(outgoing).pipe( | |
| 642 | + Stream.flatMap((write) => { | |
| 643 | + const acknowledgement = write.acknowledgement; | |
| 644 | + const completion = | |
| 645 | + acknowledgement === undefined | |
| 646 | + ? Stream.empty | |
| 647 | + : Stream.fromEffect(acknowledgeOutgoingWrite(acknowledgement)).pipe(Stream.drain); | |
| 648 | + return Stream.make(write.payload).pipe(Stream.concat(completion)); | |
| 649 | + }), | |
| 650 | + Stream.run(options.stdio.stdout()), | |
| 651 | + Effect.onExit((exit) => | |
| 652 | + Exit.match(exit, { | |
| 653 | + onFailure: (cause) => { | |
| 654 | + const error = new AcpError.AcpTransportError({ | |
| 655 | + detail: Cause.hasInterruptsOnly(cause) | |
| 656 | + ? "The ACP output writer was interrupted while closing" | |
| 657 | + : "Failed to write an outgoing ACP message", | |
| 658 | + cause, | |
| 659 | + }); | |
| 660 | + return failOutgoingWrites(error).pipe( | |
| 661 | + Effect.andThen( | |
| 662 | + Cause.hasInterruptsOnly(cause) | |
| 663 | + ? Effect.void | |
| 664 | + : handleTermination(() => Effect.succeed(error)), | |
| 665 | + ), | |
| 666 | + ); | |
| 667 | + }, | |
| 668 | + onSuccess: () => | |
| 669 | + Ref.get(outgoingWriterState).pipe( | |
| 670 | + Effect.flatMap((state) => { | |
| 671 | + // Intentional Queue.end (serverProtocol.end) completes the writer | |
| 672 | + // successfully. Do not invent a transport failure or client protocol | |
| 673 | + // error for a clean RPC shutdown. | |
| 674 | + const handled = state.intentionalEnd | |
| 675 | + ? completeOutgoingWrites() | |
| 676 | + : (() => { | |
| 677 | + const error = new AcpError.AcpTransportError({ | |
| 678 | + detail: "ACP output writer ended before the protocol closed", | |
| 679 | + cause: "Output writer ended", | |
| 680 | + }); | |
| 681 | + return failOutgoingWrites(error).pipe( | |
| 682 | + Effect.andThen(handleTermination(() => Effect.succeed(error))), | |
| 683 | + ); | |
| 684 | + })(); | |
| 685 | + return handled.pipe( | |
| 686 | + Effect.andThen( | |
| 687 | + options.testHooks?.onOutgoingWriterExit === undefined | |
| 688 | + ? Effect.void | |
| 689 | + : options.testHooks.onOutgoingWriterExit({ | |
| 690 | + intentionalEnd: state.intentionalEnd, | |
| 691 | + }), | |
| 692 | + ), | |
| 693 | + ); | |
| 694 | + }), | |
| 695 | + ), | |
| 696 | + }), | |
| 697 | + ), | |
| 698 | + Effect.forkScoped, | |
| 699 | + ); | |
| 515 | 700 | |
| 516 | 701 | const clientProtocol = RpcClient.Protocol.of({ |
| 517 | 702 | run: (_clientId, f) => |
| @@ -542,8 +727,19 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 24 unmodified lines | ||
| 542 | 727 | Effect.forever, |
| 543 | 728 | ), |
| 544 | 729 | disconnects, |
| 545 | − send: (_clientId, response) => offerOutgoing(response).pipe(Effect.orDie), | |
| 546 | − end: (_clientId) => Queue.end(outgoing), | |
| 730 | + send: (_clientId, response) => | |
| 731 | + offerOutgoing(response).pipe( | |
| 732 | + Effect.tapError((error) => | |
| 733 | + response._tag === "Exit" && options.onOutgoingResponseFailure !== undefined | |
| 734 | + ? options.onOutgoingResponseFailure(response.requestId, error) | |
| 735 | + : Effect.void, | |
| 736 | + ), | |
| 737 | + Effect.orDie, | |
| 738 | + ), | |
| 739 | + end: (_clientId) => | |
| 740 | + Ref.update(outgoingWriterState, (state) => ({ ...state, intentionalEnd: true })).pipe( | |
| 741 | + Effect.andThen(Queue.end(outgoing)), | |
| 742 | + ), | |
| 547 | 743 | clientIds: Effect.succeed(new Set([0])), |
| 548 | 744 | initialMessage: Effect.succeedNone, |
| 549 | 745 | supportsAck: true, |
| @@ -558,7 +754,6 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 8 unmodified lines | ||
| 558 | 754 | method: string, |
| 559 | 755 | payload: unknown, |
| 560 | 756 | ) { |
| 561 | − yield* ensureActive; | |
| 562 | 757 | yield* logProtocol({ |
| 563 | 758 | direction: "outgoing", |
| 564 | 759 | stage: "decoded", |
| @@ -574,12 +769,10 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 9 unmodified lines | ||
| 574 | 769 | } |
| 575 | 770 | const encoded = `${exit.value}\n`; |
| 576 | 771 | yield* logProtocol({ direction: "outgoing", stage: "raw", payload: encoded }); |
| 577 | − yield* ensureActive; | |
| 578 | 772 | yield* Queue.offer(outgoing, encoded); |
| 579 | 773 | }); |
| 580 | 774 | |
| 581 | 775 | const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) { |
| 582 | − yield* ensureActive; | |
| 583 | 776 | const requestId = yield* Ref.modify( |
| 584 | 777 | nextRequestId, |
| 585 | 778 | (current) => [current, current + 1] as const, |
| @@ -56,4 +56,20 @@ describe("projectThreadAwarenessV2", () => { · 55 unmodified lines | ||
| 56 | 56 | }), |
| 57 | 57 | ).toMatchObject({ phase: "waiting_for_input", headline: "Waiting for input" }); |
| 58 | 58 | }); |
| 59 | + | |
| 60 | + it("does not present authentication refreshes as user approvals", () => { | |
| 61 | + expect( | |
| 62 | + projectThreadAwarenessV2({ | |
| 63 | + environmentId: "env-1" as EnvironmentId, | |
| 64 | + project, | |
| 65 | + thread: v2Thread({ | |
| 66 | + pendingRuntimeRequest: { | |
| 67 | + id: RuntimeRequestId.make("request-auth-refresh"), | |
| 68 | + kind: "auth_refresh", | |
| 69 | + createdAt: updatedAt, | |
| 70 | + }, | |
| 71 | + }), | |
| 72 | + }), | |
| 73 | + ).toMatchObject({ phase: "running", headline: "Agent is working" }); | |
| 74 | + }); | |
| 59 | 75 | }); |
| @@ -87,7 +87,10 @@ function resolveThreadAwarenessPhaseV2( · 86 unmodified lines | ||
| 87 | 87 | if (thread.pendingRuntimeRequest?.kind === "user_input") { |
| 88 | 88 | return "waiting_for_input"; |
| 89 | 89 | } |
| 90 | − if (thread.pendingRuntimeRequest !== null) { | |
| 90 | + if ( | |
| 91 | + thread.pendingRuntimeRequest !== null && | |
| 92 | + thread.pendingRuntimeRequest.kind !== "auth_refresh" | |
| 93 | + ) { | |
| 91 | 94 | return "waiting_for_approval"; |
| 92 | 95 | } |
| 93 | 96 | switch (thread.status) { |
| @@ -7,22 +7,52 @@ const runId = RunId.make("run:timeline-visibility"); · 6 unmodified lines | ||
| 7 | 7 | const nodeId = NodeId.make("node:timeline-visibility"); |
| 8 | 8 | |
| 9 | 9 | describe("isOrchestrationV2TurnItemVisible", () => { |
| 10 | − it("hides interruption results from superseded attempts", () => { | |
| 10 | + it("hides unpaired interruption results from superseded attempts", () => { | |
| 11 | 11 | expect( |
| 12 | 12 | isOrchestrationV2TurnItemVisible({ |
| 13 | 13 | item: { type: "run_interrupt_result", runId, nodeId }, |
| 14 | 14 | runs: [{ id: runId, status: "running" }], |
| 15 | 15 | attempts: [{ runId, rootNodeId: nodeId, status: "superseded" }], |
| 16 | + items: [{ type: "run_interrupt_result", runId, nodeId }], | |
| 16 | 17 | }), |
| 17 | 18 | ).toBe(false); |
| 18 | 19 | }); |
| 19 | 20 | |
| 20 | − it("keeps interruption results from terminal attempts", () => { | |
| 21 | + it("keeps paired interruption results from superseded attempts", () => { | |
| 22 | + expect( | |
| 23 | + isOrchestrationV2TurnItemVisible({ | |
| 24 | + item: { type: "run_interrupt_result", runId, nodeId }, | |
| 25 | + runs: [{ id: runId, status: "running" }], | |
| 26 | + attempts: [{ runId, rootNodeId: nodeId, status: "superseded" }], | |
| 27 | + items: [ | |
| 28 | + { type: "run_interrupt_request", runId, nodeId }, | |
| 29 | + { type: "run_interrupt_result", runId, nodeId }, | |
| 30 | + ], | |
| 31 | + }), | |
| 32 | + ).toBe(true); | |
| 33 | + }); | |
| 34 | + | |
| 35 | + it("keeps interruption results from terminal attempts without a request", () => { | |
| 21 | 36 | expect( |
| 22 | 37 | isOrchestrationV2TurnItemVisible({ |
| 23 | 38 | item: { type: "run_interrupt_result", runId, nodeId }, |
| 24 | 39 | runs: [{ id: runId, status: "interrupted" }], |
| 25 | 40 | attempts: [{ runId, rootNodeId: nodeId, status: "interrupted" }], |
| 41 | + items: [{ type: "run_interrupt_result", runId, nodeId }], | |
| 42 | + }), | |
| 43 | + ).toBe(true); | |
| 44 | + }); | |
| 45 | + | |
| 46 | + it("keeps interruption results from terminal attempts with a request", () => { | |
| 47 | + expect( | |
| 48 | + isOrchestrationV2TurnItemVisible({ | |
| 49 | + item: { type: "run_interrupt_result", runId, nodeId }, | |
| 50 | + runs: [{ id: runId, status: "interrupted" }], | |
| 51 | + attempts: [{ runId, rootNodeId: nodeId, status: "interrupted" }], | |
| 52 | + items: [ | |
| 53 | + { type: "run_interrupt_request", runId, nodeId }, | |
| 54 | + { type: "run_interrupt_result", runId, nodeId }, | |
| 55 | + ], | |
| 26 | 56 | }), |
| 27 | 57 | ).toBe(true); |
| 28 | 58 | }); |
| @@ -40,6 +70,7 @@ describe("isOrchestrationV2TurnItemVisible", () => { · 11 unmodified lines | ||
| 40 | 70 | }, |
| 41 | 71 | { runId, rootNodeId: nodeId, status: "interrupted" }, |
| 42 | 72 | ], |
| 73 | + items: [{ type: "run_interrupt_result", runId, nodeId }], | |
| 43 | 74 | }), |
| 44 | 75 | ).toBe(true); |
| 45 | 76 | }); |
| @@ -11,24 +11,36 @@ type TimelineTurnItem = Pick<OrchestrationV2TurnItem, "type" | "runId" | "nodeId · 10 unmodified lines | ||
| 11 | 11 | export function isOrchestrationV2SupersededInterrupt(input: { |
| 12 | 12 | readonly item: TimelineTurnItem; |
| 13 | 13 | readonly attempts: ReadonlyArray<TimelineRunAttempt>; |
| 14 | + readonly items: ReadonlyArray<TimelineTurnItem>; | |
| 14 | 15 | }): boolean { |
| 15 | 16 | const { item } = input; |
| 16 | 17 | if (item.type !== "run_interrupt_result" || item.runId === null || item.nodeId === null) { |
| 17 | 18 | return false; |
| 18 | 19 | } |
| 19 | 20 | |
| 20 | − return input.attempts.some( | |
| 21 | + const isSuperseded = input.attempts.some( | |
| 21 | 22 | (attempt) => |
| 22 | 23 | attempt.runId === item.runId && |
| 23 | 24 | attempt.rootNodeId === item.nodeId && |
| 24 | 25 | attempt.status === "superseded", |
| 25 | 26 | ); |
| 27 | + if (!isSuperseded) { | |
| 28 | + return false; | |
| 29 | + } | |
| 30 | + | |
| 31 | + // Paired stop-then-steer results have a matching request on the same run and | |
| 32 | + // must stay visible. Legacy plain-steer results have no request and stay hidden. | |
| 33 | + const hasMatchingRequest = input.items.some( | |
| 34 | + (candidate) => candidate.type === "run_interrupt_request" && candidate.runId === item.runId, | |
| 35 | + ); | |
| 36 | + return !hasMatchingRequest; | |
| 26 | 37 | } |
| 27 | 38 | |
| 28 | 39 | export function isOrchestrationV2TurnItemVisible(input: { |
| 29 | 40 | readonly item: TimelineTurnItem; |
| 30 | 41 | readonly runs: ReadonlyArray<TimelineRun>; |
| 31 | 42 | readonly attempts: ReadonlyArray<TimelineRunAttempt>; |
| 43 | + readonly items: ReadonlyArray<TimelineTurnItem>; | |
| 32 | 44 | }): boolean { |
| 33 | 45 | const { item } = input; |
| 34 | 46 | if ( |
| @@ -38,5 +50,9 @@ export function isOrchestrationV2TurnItemVisible(input: { · 3 unmodified lines | ||
| 38 | 50 | return false; |
| 39 | 51 | } |
| 40 | 52 | |
| 41 | − return !isOrchestrationV2SupersededInterrupt({ item, attempts: input.attempts }); | |
| 53 | + return !isOrchestrationV2SupersededInterrupt({ | |
| 54 | + item, | |
| 55 | + attempts: input.attempts, | |
| 56 | + items: input.items, | |
| 57 | + }); | |
| 42 | 58 | } |