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 | !/.repos/ | 82 | !/.repos/ |
| 83 | sparse-checkout-cone-mode: false | 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 | - name: Setup Vite+ | 95 | - name: Setup Vite+ |
| 86 | uses: voidzero-dev/setup-vp@v1 | 96 | uses: voidzero-dev/setup-vp@v1 |
| 87 | with: | 97 | with: |
| @@ -16,6 +16,7 @@ import { useV2ItemSupport } from "../../state/v2-item-support"; · 15 unmodified lines | |||
| 16 | 16 | ||
| 17 | export function ThreadActivityInspector(props: { | 17 | export function ThreadActivityInspector(props: { |
| 18 | readonly activity: ThreadFeedActivity; | 18 | readonly activity: ThreadFeedActivity; |
| 19 | readonly currentThreadId: ThreadId; | ||
| 19 | readonly environmentId: EnvironmentId; | 20 | readonly environmentId: EnvironmentId; |
| 20 | readonly iconColor: ColorValue; | 21 | readonly iconColor: ColorValue; |
| 21 | readonly workspaceRoot?: string | null; | 22 | readonly workspaceRoot?: string | null; |
| @@ -28,8 +29,8 @@ export function ThreadActivityInspector(props: { · 6 unmodified lines | |||
| 28 | sourceItemId: row.sourceItemId, | 29 | sourceItemId: row.sourceItemId, |
| 29 | }); | 30 | }); |
| 30 | const model = useMemo( | 31 | const model = useMemo( |
| 31 | () => buildThreadActivityInspector(props.activity, support), | 32 | () => buildThreadActivityInspector(props.activity, support, props.currentThreadId), |
| 32 | [props.activity, support], | 33 | [props.activity, props.currentThreadId, support], |
| 33 | ); | 34 | ); |
| 34 | const revertCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, { | 35 | const revertCheckpoint = useAtomCommand(threadEnvironment.revertCheckpoint, { |
| 35 | label: "checkpoint rollback", | 36 | label: "checkpoint rollback", |
| @@ -1055,7 +1055,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe · 1054 unmodified lines | |||
| 1055 | 1055 | ||
| 1056 | function renderFeedEntry( | 1056 | function renderFeedEntry( |
| 1057 | info: { item: ThreadFeedEntry; index: number }, | 1057 | info: { item: ThreadFeedEntry; index: number }, |
| 1058 | props: Pick<ThreadFeedProps, "environmentId" | "skills" | "workspaceRoot"> & { | 1058 | props: Pick<ThreadFeedProps, "environmentId" | "skills" | "threadId" | "workspaceRoot"> & { |
| 1059 | readonly copiedRowId: string | null; | 1059 | readonly copiedRowId: string | null; |
| 1060 | readonly expandedWorkRows: Record<string, boolean>; | 1060 | readonly expandedWorkRows: Record<string, boolean>; |
| 1061 | readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; | 1061 | readonly workRowSizing: ReturnType<typeof deriveThreadWorkLogSizing>; |
| @@ -1298,6 +1298,7 @@ function renderFeedEntry( · 236 unmodified lines | |||
| 1298 | activities={entry.activities} | 1298 | activities={entry.activities} |
| 1299 | anchorKey={entry.id} | 1299 | anchorKey={entry.id} |
| 1300 | copiedRowId={props.copiedRowId} | 1300 | copiedRowId={props.copiedRowId} |
| 1301 | currentThreadId={props.threadId} | ||
| 1301 | environmentId={props.environmentId} | 1302 | environmentId={props.environmentId} |
| 1302 | expanded={props.expandedWorkGroups[entry.id] ?? false} | 1303 | expanded={props.expandedWorkGroups[entry.id] ?? false} |
| 1303 | expandedRows={props.expandedWorkRows} | 1304 | expandedRows={props.expandedWorkRows} |
| @@ -2117,6 +2118,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 813 unmodified lines | |||
| 2117 | (info: { item: ThreadFeedEntry; index: number }) => | 2118 | (info: { item: ThreadFeedEntry; index: number }) => |
| 2118 | renderFeedEntry(info, { | 2119 | renderFeedEntry(info, { |
| 2119 | environmentId: props.environmentId, | 2120 | environmentId: props.environmentId, |
| 2121 | threadId: props.threadId, | ||
| 2120 | copiedRowId, | 2122 | copiedRowId, |
| 2121 | expandedWorkRows, | 2123 | expandedWorkRows, |
| 2122 | terminalAssistantMessageIds, | 2124 | terminalAssistantMessageIds, |
| @@ -2158,6 +2160,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { · 35 unmodified lines | |||
| 2158 | onToggleWorkGroup, | 2160 | onToggleWorkGroup, |
| 2159 | onToggleWorkRow, | 2161 | onToggleWorkRow, |
| 2160 | props.environmentId, | 2162 | props.environmentId, |
| 2163 | props.threadId, | ||
| 2161 | props.threadTitle, | 2164 | props.threadTitle, |
| 2162 | props.skills, | 2165 | props.skills, |
| 2163 | props.workspaceRoot, | 2166 | props.workspaceRoot, |
| @@ -93,7 +93,7 @@ describe("buildThreadActivityInspector", () => { · 92 unmodified lines | |||
| 93 | } as never, | 93 | } as never, |
| 94 | }; | 94 | }; |
| 95 | 95 | ||
| 96 | const model = buildThreadActivityInspector(activityFor(item), support); | 96 | const model = buildThreadActivityInspector(activityFor(item), support, sourceThreadId); |
| 97 | expect(model.fields).toEqual( | 97 | expect(model.fields).toEqual( |
| 98 | expect.arrayContaining([ | 98 | expect.arrayContaining([ |
| 99 | { label: "Duration", value: "2.0s" }, | 99 | { label: "Duration", value: "2.0s" }, |
| @@ -119,7 +119,11 @@ describe("buildThreadActivityInspector", () => { · 19 unmodified lines | |||
| 119 | pattern: "resolveSession", | 119 | pattern: "resolveSession", |
| 120 | results: [{ fileName: "src/session.ts", line: 42, preview: "function resolveSession()" }], | 120 | results: [{ fileName: "src/session.ts", line: 42, preview: "function resolveSession()" }], |
| 121 | }; | 121 | }; |
| 122 | const fileModel = buildThreadActivityInspector(activityFor(fileSearch), EMPTY_V2_ITEM_SUPPORT); | 122 | const fileModel = buildThreadActivityInspector( |
| 123 | activityFor(fileSearch), | ||
| 124 | EMPTY_V2_ITEM_SUPPORT, | ||
| 125 | sourceThreadId, | ||
| 126 | ); | ||
| 123 | expect(fileModel.fileLinks).toEqual([ | 127 | expect(fileModel.fileLinks).toEqual([ |
| 124 | { | 128 | { |
| 125 | label: "src/session.ts — function resolveSession()", | 129 | label: "src/session.ts — function resolveSession()", |
| @@ -136,7 +140,11 @@ describe("buildThreadActivityInspector", () => { · 10 unmodified lines | |||
| 136 | { title: "Schema", url: "https://effect.website/docs/schema", snippet: "Typed schemas" }, | 140 | { title: "Schema", url: "https://effect.website/docs/schema", snippet: "Typed schemas" }, |
| 137 | ], | 141 | ], |
| 138 | }; | 142 | }; |
| 139 | const webModel = buildThreadActivityInspector(activityFor(webSearch), EMPTY_V2_ITEM_SUPPORT); | 143 | const webModel = buildThreadActivityInspector( |
| 144 | activityFor(webSearch), | ||
| 145 | EMPTY_V2_ITEM_SUPPORT, | ||
| 146 | sourceThreadId, | ||
| 147 | ); | ||
| 140 | expect(webModel.webLinks).toEqual([ | 148 | expect(webModel.webLinks).toEqual([ |
| 141 | { label: "Schema", url: "https://effect.website/docs/schema" }, | 149 | { label: "Schema", url: "https://effect.website/docs/schema" }, |
| 142 | ]); | 150 | ]); |
| @@ -156,6 +164,7 @@ describe("buildThreadActivityInspector", () => { · 13 unmodified lines | |||
| 156 | const dynamicModel = buildThreadActivityInspector( | 164 | const dynamicModel = buildThreadActivityInspector( |
| 157 | activityFor(dynamicTool), | 165 | activityFor(dynamicTool), |
| 158 | EMPTY_V2_ITEM_SUPPORT, | 166 | EMPTY_V2_ITEM_SUPPORT, |
| 167 | sourceThreadId, | ||
| 159 | ); | 168 | ); |
| 160 | expect(dynamicModel.blocks).toEqual( | 169 | expect(dynamicModel.blocks).toEqual( |
| 161 | expect.arrayContaining([ | 170 | expect.arrayContaining([ |
| @@ -177,11 +186,15 @@ describe("buildThreadActivityInspector", () => { · 15 unmodified lines | |||
| 177 | files: [{ path: "src/main.ts", kind: "modified", additions: 2, deletions: 1 }], | 186 | files: [{ path: "src/main.ts", kind: "modified", additions: 2, deletions: 1 }], |
| 178 | }; | 187 | }; |
| 179 | const checkpoint = { id: checkpointId, scopeId, status: "ready" } as never; | 188 | const checkpoint = { id: checkpointId, scopeId, status: "ready" } as never; |
| 180 | const model = buildThreadActivityInspector(activityFor(item), { | 189 | const model = buildThreadActivityInspector( |
| 181 | ...EMPTY_V2_ITEM_SUPPORT, | 190 | activityFor(item), |
| 182 | item, | 191 | { |
| 183 | checkpoint, | 192 | ...EMPTY_V2_ITEM_SUPPORT, |
| 184 | }); | 193 | item, |
| 194 | checkpoint, | ||
| 195 | }, | ||
| 196 | sourceThreadId, | ||
| 197 | ); | ||
| 185 | 198 | ||
| 186 | expect(model.canRollback).toBe(true); | 199 | expect(model.canRollback).toBe(true); |
| 187 | expect(model.rollbackTarget).toEqual({ | 200 | expect(model.rollbackTarget).toEqual({ |
| @@ -189,6 +202,18 @@ describe("buildThreadActivityInspector", () => { · 1 unmodified line | |||
| 189 | checkpointId, | 202 | checkpointId, |
| 190 | scopeId, | 203 | scopeId, |
| 191 | }); | 204 | }); |
| 205 | |||
| 206 | const inheritedModel = buildThreadActivityInspector( | ||
| 207 | activityFor(item), | ||
| 208 | { | ||
| 209 | ...EMPTY_V2_ITEM_SUPPORT, | ||
| 210 | item, | ||
| 211 | checkpoint, | ||
| 212 | }, | ||
| 213 | ThreadId.make("child-thread"), | ||
| 214 | ); | ||
| 215 | expect(inheritedModel.canRollback).toBe(false); | ||
| 216 | expect(inheritedModel.rollbackTarget).toBeNull(); | ||
| 192 | }); | 217 | }); |
| 193 | 218 | ||
| 194 | it("prefers live subagent progress from supporting state", () => { | 219 | it("prefers live subagent progress from supporting state", () => { |
| @@ -204,16 +229,20 @@ describe("buildThreadActivityInspector", () => { · 9 unmodified lines | |||
| 204 | progress: "Starting", | 229 | progress: "Starting", |
| 205 | result: null, | 230 | result: null, |
| 206 | }; | 231 | }; |
| 207 | const model = buildThreadActivityInspector(activityFor(item), { | 232 | const model = buildThreadActivityInspector( |
| 208 | ...EMPTY_V2_ITEM_SUPPORT, | 233 | activityFor(item), |
| 209 | item, | 234 | { |
| 210 | subagent: { | 235 | ...EMPTY_V2_ITEM_SUPPORT, |
| 211 | origin: "provider_native", | 236 | item, |
| 212 | status: "running", | 237 | subagent: { |
| 213 | progress: "Reading projection tests", | 238 | origin: "provider_native", |
| 214 | result: null, | 239 | status: "running", |
| 215 | } as never, | 240 | progress: "Reading projection tests", |
| 216 | }); | 241 | result: null, |
| 242 | } as never, | ||
| 243 | }, | ||
| 244 | sourceThreadId, | ||
| 245 | ); | ||
| 217 | 246 | ||
| 218 | expect(model.fields).toContainEqual({ | 247 | expect(model.fields).toContainEqual({ |
| 219 | label: "Delegated task", | 248 | label: "Delegated task", |
| @@ -73,6 +73,7 @@ function addBlock( · 72 unmodified lines | |||
| 73 | export function buildThreadActivityInspector( | 73 | export function buildThreadActivityInspector( |
| 74 | activity: ThreadFeedActivity, | 74 | activity: ThreadFeedActivity, |
| 75 | support: V2ItemSupport, | 75 | support: V2ItemSupport, |
| 76 | currentThreadId: ThreadId, | ||
| 76 | ): ThreadActivityInspectorModel { | 77 | ): ThreadActivityInspectorModel { |
| 77 | const row = activity.projectedItem; | 78 | const row = activity.projectedItem; |
| 78 | const item = row.item; | 79 | const item = row.item; |
| @@ -279,7 +280,10 @@ export function buildThreadActivityInspector( · 200 unmodified lines | |||
| 279 | break; | 280 | break; |
| 280 | } | 281 | } |
| 281 | 282 | ||
| 282 | const checkpoint = item.type === "checkpoint" ? support.checkpoint : null; | 283 | const checkpoint = |
| 284 | item.type === "checkpoint" && row.sourceThreadId === currentThreadId | ||
| 285 | ? support.checkpoint | ||
| 286 | : null; | ||
| 283 | return { | 287 | return { |
| 284 | fields, | 288 | fields, |
| 285 | blocks, | 289 | blocks, |
| @@ -1,5 +1,6 @@ | |||
| 1 | #!/usr/bin/env node | 1 | #!/usr/bin/env node |
| 2 | // @effect-diagnostics nodeBuiltinImport:off | 2 | // @effect-diagnostics nodeBuiltinImport:off |
| 3 | import * as NodeChildProcess from "node:child_process"; | ||
| 3 | import * as NodeFS from "node:fs"; | 4 | import * as NodeFS from "node:fs"; |
| 4 | 5 | ||
| 5 | import * as Effect from "effect/Effect"; | 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 | const emitInterleavedAssistantToolCalls = | 18 | const emitInterleavedAssistantToolCalls = |
| 18 | process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; | 19 | process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; |
| 19 | const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; | 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 | const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; | 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 | const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; | 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 | const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; | 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 | const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; | 30 | const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; |
| 32 | const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; | 31 | const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; |
| 32 | const hangAfterPermission = process.env.T3_ACP_HANG_AFTER_PERMISSION === "1"; | ||
| 33 | const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; | 33 | const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; |
| 34 | const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1"; | 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 | const omitXAiPromptCompleteStopReason = | 51 | const omitXAiPromptCompleteStopReason = |
| 36 | process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1"; | 52 | process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1"; |
| 37 | const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1"; | 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 | const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; | 63 | const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; |
| 48 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; | 64 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; |
| 49 | const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; | 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 | const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); | 66 | const promptDelayMs = Number(process.env.T3_ACP_PROMPT_DELAY_MS ?? "0"); |
| 53 | const supportsSessionLifecycle = process.env.T3_ACP_SESSION_LIFECYCLE === "1"; | 67 | const supportsSessionLifecycle = process.env.T3_ACP_SESSION_LIFECYCLE === "1"; |
| 54 | const advertisedAuthMethodId = process.env.T3_ACP_AUTH_METHOD_ID?.trim(); | 68 | const advertisedAuthMethodId = process.env.T3_ACP_AUTH_METHOD_ID?.trim(); |
| @@ -58,11 +72,6 @@ const permissionOptionIds = { · 3 unmodified lines | |||
| 58 | allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always", | 72 | allowAlways: process.env.T3_ACP_ALLOW_ALWAYS_OPTION_ID ?? "allow-always", |
| 59 | rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once", | 73 | rejectOnce: process.env.T3_ACP_REJECT_ONCE_OPTION_ID ?? "reject-once", |
| 60 | }; | 74 | }; |
| 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 | const sessionId = "mock-session-1"; | 75 | const sessionId = "mock-session-1"; |
| 67 | 76 | ||
| 68 | let currentModeId = "ask"; | 77 | let currentModeId = "ask"; |
| @@ -81,6 +90,11 @@ function logExit(reason: string): void { · 12 unmodified lines | |||
| 81 | appendFileSync(exitLogPath, `${reason}\n`, "utf8"); | 90 | appendFileSync(exitLogPath, `${reason}\n`, "utf8"); |
| 82 | } | 91 | } |
| 83 | 92 | ||
| 93 | function logResidualCallbackResponse(kind: string): void { | ||
| 94 | if (!residualCallbackResponseLogPath) return; | ||
| 95 | NodeFS.appendFileSync(residualCallbackResponseLogPath, `${kind}\n`, "utf8"); | ||
| 96 | } | ||
| 97 | |||
| 84 | process.once("SIGTERM", () => { | 98 | process.once("SIGTERM", () => { |
| 85 | logExit("SIGTERM"); | 99 | logExit("SIGTERM"); |
| 86 | process.exit(0); | 100 | process.exit(0); |
| @@ -254,20 +268,7 @@ function modeState(): AcpSchema.SessionModeState { · 167 unmodified lines | |||
| 254 | // Mirrors the real Grok ACP: it advertises versioned model ids, never the CLI's own | 268 | // Mirrors the real Grok ACP: it advertises versioned model ids, never the CLI's own |
| 255 | // "grok-build" product name, and it rejects unknown ids in session/set_model. | 269 | // "grok-build" product name, and it rejects unknown ids in session/set_model. |
| 256 | const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [ | 270 | const grokAcpModels: ReadonlyArray<AcpSchema.ModelInfo> = [ |
| 257 | { | 271 | { modelId: "grok-build", name: "Grok Build" }, |
| 258 | modelId: "grok-4.6", | ||
| 259 | name: "Grok 4.6", | ||
| 260 | _meta: { | ||
| 261 | totalContextTokens: 500_000, | ||
| 262 | supportsReasoningEffort: true, | ||
| 263 | reasoningEffort: initialGrokReasoningEffort ?? "high", | ||
| 264 | reasoningEfforts: [ | ||
| 265 | { id: "xhigh", value: "xhigh", label: "Extra High Effort", default: false }, | ||
| 266 | { id: "high", value: "high", label: "High Effort", default: true }, | ||
| 267 | { id: "low", value: "low", label: "Low Effort", default: false }, | ||
| 268 | ], | ||
| 269 | }, | ||
| 270 | }, | ||
| 271 | { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, | 272 | { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, |
| 272 | ]; | 273 | ]; |
| 273 | 274 | ||
| @@ -491,8 +492,54 @@ const program = Effect.gen(function* () { · 217 unmodified lines | |||
| 491 | ); | 492 | ); |
| 492 | 493 | ||
| 493 | yield* agent.handleCancel(({ sessionId }) => | 494 | yield* agent.handleCancel(({ sessionId }) => |
| 494 | Effect.sync(() => { | 495 | Effect.gen(function* () { |
| 495 | cancelledSessions.add(String(sessionId ?? "mock-session-1")); | 496 | const cancelledSessionId = String(sessionId ?? "mock-session-1"); |
| 497 | cancelledSessions.add(cancelledSessionId); | ||
| 498 | if (exitOnCancel) { | ||
| 499 | return yield* Effect.sync(() => process.exit(0)); | ||
| 500 | } | ||
| 501 | if (emitLateUpdateAfterCancel) { | ||
| 502 | yield* Effect.sleep("50 millis"); | ||
| 503 | yield* Effect.sync(() => { | ||
| 504 | writeJsonRpcNotification("session/update", { | ||
| 505 | sessionId: cancelledSessionId, | ||
| 506 | update: { | ||
| 507 | sessionUpdate: "agent_message_chunk", | ||
| 508 | content: { type: "text", text: "late after cancel" }, | ||
| 509 | }, | ||
| 510 | }); | ||
| 511 | }); | ||
| 512 | } | ||
| 513 | if (emitTaskBackgroundedAfterCancel) { | ||
| 514 | // Grok cancel-as-detach: the foreground command is re-run as a | ||
| 515 | // background task that later completes on its own. | ||
| 516 | yield* Effect.sync(() => { | ||
| 517 | writeJsonRpcNotification("_x.ai/task_backgrounded", { | ||
| 518 | sessionId: cancelledSessionId, | ||
| 519 | update: { | ||
| 520 | sessionUpdate: "task_backgrounded", | ||
| 521 | tool_call_id: "task-bg-1", | ||
| 522 | task_id: "task-bg-1", | ||
| 523 | command: "sleep 30", | ||
| 524 | }, | ||
| 525 | }); | ||
| 526 | }); | ||
| 527 | yield* Effect.sleep("1200 millis") | ||
| 528 | .pipe( | ||
| 529 | Effect.andThen( | ||
| 530 | Effect.sync(() => { | ||
| 531 | writeJsonRpcNotification("_x.ai/task_completed", { | ||
| 532 | sessionId: cancelledSessionId, | ||
| 533 | update: { | ||
| 534 | sessionUpdate: "task_completed", | ||
| 535 | task_snapshot: { task_id: "task-bg-1", command: "sleep 30" }, | ||
| 536 | }, | ||
| 537 | }); | ||
| 538 | }), | ||
| 539 | ), | ||
| 540 | ) | ||
| 541 | .pipe(Effect.forkDetach); | ||
| 542 | } | ||
| 496 | }), | 543 | }), |
| 497 | ); | 544 | ); |
| 498 | 545 | ||
| @@ -501,6 +548,80 @@ const program = Effect.gen(function* () { · 2 unmodified lines | |||
| 501 | const requestedSessionId = String(request.sessionId ?? sessionId); | 548 | const requestedSessionId = String(request.sessionId ?? sessionId); |
| 502 | promptCount += 1; | 549 | promptCount += 1; |
| 503 | 550 | ||
| 551 | if (residualCallbackTriggerPath !== undefined) { | ||
| 552 | yield* Effect.gen(function* () { | ||
| 553 | while (!(yield* Effect.sync(() => NodeFS.existsSync(residualCallbackTriggerPath)))) { | ||
| 554 | yield* Effect.sleep("20 millis"); | ||
| 555 | } | ||
| 556 | yield* Effect.sync(() => { | ||
| 557 | writeJsonRpcNotification("session/update", { | ||
| 558 | sessionId: requestedSessionId, | ||
| 559 | update: { | ||
| 560 | sessionUpdate: "agent_message_chunk", | ||
| 561 | content: { type: "text", text: "residual assistant callback" }, | ||
| 562 | }, | ||
| 563 | }); | ||
| 564 | writeJsonRpcNotification("session/update", { | ||
| 565 | sessionId: requestedSessionId, | ||
| 566 | update: { | ||
| 567 | sessionUpdate: "tool_call", | ||
| 568 | toolCallId: "residual-tool-call", | ||
| 569 | title: "Residual tool callback", | ||
| 570 | kind: "other", | ||
| 571 | status: "pending", | ||
| 572 | rawInput: {}, | ||
| 573 | }, | ||
| 574 | }); | ||
| 575 | writeJsonRpcNotification("session/update", { | ||
| 576 | sessionId: requestedSessionId, | ||
| 577 | update: { | ||
| 578 | sessionUpdate: "plan", | ||
| 579 | entries: [ | ||
| 580 | { content: "Residual plan callback", priority: "high", status: "pending" }, | ||
| 581 | ], | ||
| 582 | }, | ||
| 583 | }); | ||
| 584 | }); | ||
| 585 | yield* agent.client | ||
| 586 | .requestPermission({ | ||
| 587 | sessionId: requestedSessionId, | ||
| 588 | toolCall: { | ||
| 589 | toolCallId: "residual-permission", | ||
| 590 | title: "Residual permission callback", | ||
| 591 | }, | ||
| 592 | options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], | ||
| 593 | }) | ||
| 594 | .pipe( | ||
| 595 | Effect.exit, | ||
| 596 | Effect.tap(() => Effect.sync(() => logResidualCallbackResponse("permission"))), | ||
| 597 | Effect.ignore, | ||
| 598 | Effect.forkDetach, | ||
| 599 | ); | ||
| 600 | yield* agent.client | ||
| 601 | .elicit({ | ||
| 602 | sessionId: requestedSessionId, | ||
| 603 | message: "Residual elicitation callback", | ||
| 604 | mode: "form", | ||
| 605 | requestedSchema: { | ||
| 606 | type: "object", | ||
| 607 | properties: { | ||
| 608 | approved: { type: "boolean", title: "Approved" }, | ||
| 609 | }, | ||
| 610 | }, | ||
| 611 | }) | ||
| 612 | .pipe( | ||
| 613 | Effect.exit, | ||
| 614 | Effect.tap(() => Effect.sync(() => logResidualCallbackResponse("elicitation"))), | ||
| 615 | Effect.ignore, | ||
| 616 | Effect.forkDetach, | ||
| 617 | ); | ||
| 618 | if (exitAfterResidualCallbacks) { | ||
| 619 | yield* Effect.sleep("100 millis"); | ||
| 620 | return yield* Effect.sync(() => process.exit(0)); | ||
| 621 | } | ||
| 622 | }).pipe(Effect.forkDetach); | ||
| 623 | } | ||
| 624 | |||
| 504 | if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) { | 625 | if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) { |
| 505 | yield* Effect.sleep(`${promptDelayMs} millis`); | 626 | yield* Effect.sleep(`${promptDelayMs} millis`); |
| 506 | } | 627 | } |
| @@ -562,70 +683,124 @@ const program = Effect.gen(function* () { · 55 unmodified lines | |||
| 562 | return yield* Effect.never; | 683 | return yield* Effect.never; |
| 563 | } | 684 | } |
| 564 | 685 | ||
| 565 | if (hangPromptForever || (hangFirstPromptForever && promptCount === 1)) { | 686 | if ( |
| 687 | hangPromptForever || | ||
| 688 | (hangFirstPromptForever && promptCount === 1) || | ||
| 689 | (emitEmptySuccessfulBashThenHang && promptCount === 2) | ||
| 690 | ) { | ||
| 566 | return yield* Effect.never; | 691 | return yield* Effect.never; |
| 567 | } | 692 | } |
| 568 | 693 | ||
| 569 | if (emitXAiRateLimitThenHang) { | 694 | if ( |
| 570 | writeJsonRpcNotification("_x.ai/session/prompt_complete", { | 695 | emitRunningCommandThenHang || |
| 571 | sessionId: requestedSessionId, | 696 | (emitRunningCommandThenHangOnFirstPrompt && promptCount === 1) |
| 572 | promptId: promptIdFromRequestMeta(request) ?? "mock-xai-rate-limit-prompt-1", | 697 | ) { |
| 573 | stopReason: "rate_limit", | 698 | const toolCallId = "tool-call-running-1"; |
| 574 | agentResult: null, | 699 | if (runningCommandPidPath !== undefined) { |
| 575 | }); | 700 | const command = runningCommandIgnoresTerm |
| 576 | return yield* Effect.never; | 701 | ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s\\n" "$$" "$child" > "$1"; wait "$child"' |
| 577 | } | 702 | : 'sleep 120 & child=$!; printf "%s %s\\n" "$$" "$child" > "$1"; wait "$child"'; |
| 578 | 703 | if (runningCommandSeparateSession) { | |
| 579 | if (emitContentThenHang) { | 704 | const launcher = [ |
| 705 | 'const { spawn } = require("node:child_process");', | ||
| 706 | "const child = spawn(process.argv[1], process.argv.slice(2), { stdio: 'ignore' });", | ||
| 707 | "child.once('exit', (code, signal) => process.exitCode = code ?? (signal ? 1 : 0));", | ||
| 708 | ].join(" "); | ||
| 709 | const detachedCommand = runningCommandIgnoresTerm | ||
| 710 | ? 'trap "" TERM; bash -c \'trap "" TERM; while :; do sleep 1; done\' & child=$!; printf "%s %s %s\\n" "$PPID" "$$" "$child" > "$1"; wait "$child"' | ||
| 711 | : 'sleep 120 & child=$!; printf "%s %s %s\\n" "$PPID" "$$" "$child" > "$1"; wait "$child"'; | ||
| 712 | // Nested bash publishes "$PPID $$ $child" once it starts. Do not | ||
| 713 | // write the launcher PID alone here: that races with bash and can | ||
| 714 | // clobber the triple that interrupt tests wait for. | ||
| 715 | const detachedLauncher = NodeChildProcess.spawn( | ||
| 716 | process.execPath, | ||
| 717 | ["-e", launcher, "bash", "-c", detachedCommand, "bash", runningCommandPidPath], | ||
| 718 | { detached: true, stdio: "ignore" }, | ||
| 719 | ); | ||
| 720 | detachedLauncher.unref(); | ||
| 721 | } else { | ||
| 722 | NodeChildProcess.spawn("bash", ["-c", command, "bash", runningCommandPidPath], { | ||
| 723 | stdio: "ignore", | ||
| 724 | }); | ||
| 725 | } | ||
| 726 | } | ||
| 580 | yield* agent.client.sessionUpdate({ | 727 | yield* agent.client.sessionUpdate({ |
| 581 | sessionId: requestedSessionId, | 728 | sessionId: requestedSessionId, |
| 582 | update: { | 729 | update: { |
| 583 | sessionUpdate: "agent_message_chunk", | 730 | sessionUpdate: "tool_call", |
| 584 | content: { type: "text", text: "partial before stall" }, | 731 | toolCallId, |
| 732 | title: "Terminal", | ||
| 733 | kind: "execute", | ||
| 734 | status: "pending", | ||
| 735 | rawInput: { | ||
| 736 | command: ["sleep", "120"], | ||
| 737 | }, | ||
| 585 | }, | 738 | }, |
| 586 | }); | 739 | }); |
| 587 | return yield* Effect.never; | ||
| 588 | } | ||
| 589 | |||
| 590 | if (emitPlanThenHang) { | ||
| 591 | yield* agent.client.sessionUpdate({ | 740 | yield* agent.client.sessionUpdate({ |
| 592 | sessionId: requestedSessionId, | 741 | sessionId: requestedSessionId, |
| 593 | update: { | 742 | update: { |
| 594 | sessionUpdate: "plan", | 743 | sessionUpdate: "tool_call_update", |
| 595 | entries: [ | 744 | toolCallId, |
| 596 | { | 745 | title: "Terminal", |
| 597 | content: "Wait for more ACP progress", | 746 | kind: "execute", |
| 598 | priority: "high", | 747 | status: "in_progress", |
| 599 | status: "in_progress", | 748 | rawInput: { |
| 600 | }, | 749 | command: ["sleep", "120"], |
| 601 | ], | 750 | }, |
| 751 | // Grok-like mid-stream Bash re-report: exit_code 0 while still running. | ||
| 752 | rawOutput: { type: "Bash", exit_code: 0 }, | ||
| 602 | }, | 753 | }, |
| 603 | }); | 754 | }); |
| 604 | return yield* Effect.never; | ||
| 605 | } | ||
| 606 | |||
| 607 | if (emitActiveToolThenHang) { | ||
| 608 | const toolCallId = "tool-call-long-running-1"; | ||
| 609 | yield* agent.client.sessionUpdate({ | 755 | yield* agent.client.sessionUpdate({ |
| 610 | sessionId: requestedSessionId, | 756 | sessionId: requestedSessionId, |
| 611 | update: { | 757 | update: { |
| 612 | sessionUpdate: "tool_call", | 758 | sessionUpdate: "tool_call", |
| 613 | toolCallId, | 759 | toolCallId: "tool-call-output-1", |
| 614 | title: "Long-running tool", | 760 | title: "get_command_or_subagent_output", |
| 615 | kind: "execute", | 761 | kind: "other", |
| 616 | status: "pending", | 762 | status: "pending", |
| 617 | rawInput: { command: ["long-running-tool"] }, | 763 | rawInput: { task_id: "task-running-1" }, |
| 618 | }, | 764 | }, |
| 619 | }); | 765 | }); |
| 620 | yield* agent.client.sessionUpdate({ | 766 | yield* agent.client.sessionUpdate({ |
| 621 | sessionId: requestedSessionId, | 767 | sessionId: requestedSessionId, |
| 622 | update: { | 768 | update: { |
| 623 | sessionUpdate: "tool_call_update", | 769 | sessionUpdate: "tool_call_update", |
| 624 | toolCallId, | 770 | toolCallId: "tool-call-output-1", |
| 625 | status: "in_progress", | 771 | status: "in_progress", |
| 772 | rawOutput: { task_id: "task-running-1", status: "running" }, | ||
| 626 | }, | 773 | }, |
| 627 | }); | 774 | }); |
| 628 | return yield* Effect.never; | 775 | if (exitAfterRunningCommandLaunch) { |
| 776 | yield* Effect.sleep("100 millis"); | ||
| 777 | return yield* Effect.sync(() => process.exit(0)); | ||
| 778 | } | ||
| 779 | // Stay open until session/cancel so interrupt tests can observe a running tool. | ||
| 780 | while (!cancelledSessions.has(requestedSessionId)) { | ||
| 781 | yield* Effect.sleep("25 millis"); | ||
| 782 | } | ||
| 783 | cancelledSessions.delete(requestedSessionId); | ||
| 784 | return { stopReason: "cancelled" }; | ||
| 785 | } | ||
| 786 | |||
| 787 | if (emitEmptySuccessfulBash || (emitEmptySuccessfulBashThenHang && promptCount === 1)) { | ||
| 788 | const update = { | ||
| 789 | sessionId: requestedSessionId, | ||
| 790 | update: { | ||
| 791 | sessionUpdate: "tool_call_update", | ||
| 792 | toolCallId: "tool-call-empty-success-1", | ||
| 793 | title: "Terminal", | ||
| 794 | kind: "execute", | ||
| 795 | status: "completed", | ||
| 796 | rawInput: { command: "true" }, | ||
| 797 | rawOutput: { type: "Bash", exit_code: 0 }, | ||
| 798 | }, | ||
| 799 | } as const; | ||
| 800 | yield* agent.client.sessionUpdate(update); | ||
| 801 | yield* Effect.sleep("25 millis"); | ||
| 802 | yield* agent.client.sessionUpdate(update); | ||
| 803 | return { stopReason: "end_turn" }; | ||
| 629 | } | 804 | } |
| 630 | 805 | ||
| 631 | if (emitXAiPromptCompleteThenHang) { | 806 | if (emitXAiPromptCompleteThenHang) { |
| @@ -736,6 +911,32 @@ const program = Effect.gen(function* () { · 104 unmodified lines | |||
| 736 | return { stopReason: "end_turn" }; | 911 | return { stopReason: "end_turn" }; |
| 737 | } | 912 | } |
| 738 | 913 | ||
| 914 | if (emitElicitation) { | ||
| 915 | yield* agent.client.elicit({ | ||
| 916 | sessionId: requestedSessionId, | ||
| 917 | message: "Approve this request?", | ||
| 918 | mode: "form", | ||
| 919 | requestedSchema: { | ||
| 920 | type: "object", | ||
| 921 | properties: { | ||
| 922 | approved: { type: "boolean", title: "Approved" }, | ||
| 923 | }, | ||
| 924 | }, | ||
| 925 | }); | ||
| 926 | return { stopReason: "end_turn" }; | ||
| 927 | } | ||
| 928 | |||
| 929 | if (emitUrlElicitation) { | ||
| 930 | yield* agent.client.elicit({ | ||
| 931 | sessionId: requestedSessionId, | ||
| 932 | message: "Open authentication page", | ||
| 933 | mode: "url", | ||
| 934 | url: "https://example.com/auth", | ||
| 935 | elicitationId: "url-elicitation-1", | ||
| 936 | }); | ||
| 937 | return { stopReason: "end_turn" }; | ||
| 938 | } | ||
| 939 | |||
| 739 | if (emitToolCalls) { | 940 | if (emitToolCalls) { |
| 740 | const toolCallId = "tool-call-1"; | 941 | const toolCallId = "tool-call-1"; |
| 741 | 942 | ||
| @@ -762,57 +963,40 @@ const program = Effect.gen(function* () { · 20 unmodified lines | |||
| 762 | }, | 963 | }, |
| 763 | }); | 964 | }); |
| 764 | 965 | ||
| 765 | const permissionOptions: Array<AcpSchema.PermissionOption> = [ | 966 | const permission = yield* agent.client.requestPermission({ |
| 766 | { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" }, | 967 | sessionId: requestedSessionId, |
| 767 | ...(omitAllowAlways | 968 | toolCall: { |
| 768 | ? [] | 969 | toolCallId, |
| 769 | : [ | 970 | title: "`cat server/package.json`", |
| 770 | { | 971 | kind: "execute", |
| 771 | optionId: permissionOptionIds.allowAlways, | 972 | status: "pending", |
| 772 | name: "Allow always", | 973 | content: [ |
| 773 | kind: "allow_always" as const, | 974 | { |
| 975 | type: "content", | ||
| 976 | content: { | ||
| 977 | type: "text", | ||
| 978 | text: "Not in allowlist: cat server/package.json", | ||
| 774 | }, | 979 | }, |
| 775 | ]), | ||
| 776 | { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" }, | ||
| 777 | ]; | ||
| 778 | |||
| 779 | let cancelled = cancelledSessions.delete(requestedSessionId); | ||
| 780 | for (let index = 0; index < permissionRequestCount; index++) { | ||
| 781 | const command = | ||
| 782 | index > 0 | ||
| 783 | ? (process.env.T3_ACP_SECOND_PERMISSION_COMMAND ?? "cat server/package.json") | ||
| 784 | : "cat server/package.json"; | ||
| 785 | const permission = yield* agent.client.requestPermission({ | ||
| 786 | sessionId: requestedSessionId, | ||
| 787 | toolCall: { | ||
| 788 | toolCallId: index === 0 ? toolCallId : `${toolCallId}-${index + 1}`, | ||
| 789 | title: process.env.T3_ACP_PERMISSION_TITLE ?? `\`${command}\``, | ||
| 790 | kind: "execute", | ||
| 791 | status: "pending", | ||
| 792 | rawInput: { | ||
| 793 | variant: "Bash", | ||
| 794 | command, | ||
| 795 | description: index === 0 ? "Read package metadata" : "Read it again", | ||
| 796 | }, | 980 | }, |
| 797 | content: [ | 981 | ], |
| 798 | { | 982 | }, |
| 799 | type: "content", | 983 | options: [ |
| 800 | content: { | 984 | { optionId: permissionOptionIds.allowOnce, name: "Allow once", kind: "allow_once" }, |
| 801 | type: "text", | 985 | { |
| 802 | text: `Not in allowlist: ${command}`, | 986 | optionId: permissionOptionIds.allowAlways, |
| 803 | }, | 987 | name: "Allow always", |
| 804 | }, | 988 | kind: "allow_always", |
| 805 | ], | ||
| 806 | }, | 989 | }, |
| 807 | options: permissionOptions, | 990 | { optionId: permissionOptionIds.rejectOnce, name: "Reject", kind: "reject_once" }, |
| 808 | }); | 991 | ], |
| 809 | cancelled = | 992 | }); |
| 810 | cancelled || | 993 | |
| 811 | cancelledSessions.delete(requestedSessionId) || | 994 | const cancelled = |
| 812 | permission.outcome.outcome === "cancelled"; | 995 | cancelledSessions.delete(requestedSessionId) || |
| 813 | if (cancelled) { | 996 | permission.outcome.outcome === "cancelled"; |
| 814 | break; | 997 | |
| 815 | } | 998 | if (hangAfterPermission) { |
| 999 | return yield* Effect.never; | ||
| 816 | } | 1000 | } |
| 817 | 1001 | ||
| 818 | yield* agent.client.sessionUpdate({ | 1002 | yield* agent.client.sessionUpdate({ |
| @@ -881,6 +1065,159 @@ const program = Effect.gen(function* () { · 62 unmodified lines | |||
| 881 | return { stopReason: "end_turn" }; | 1065 | return { stopReason: "end_turn" }; |
| 882 | } | 1066 | } |
| 883 | 1067 | ||
| 1068 | // In-turn monitor + TaskOutput hydrate, then a late post-finalize | ||
| 1069 | // duplicate terminal TaskOutput for the same task. Exercises the | ||
| 1070 | // already-handled short-circuit: must not pin hasPendingBackgroundWork. | ||
| 1071 | if (emitInTurnTaskOutputThenLateDuplicate) { | ||
| 1072 | const monitorToolCallId = "tool-call-monitor-1"; | ||
| 1073 | yield* agent.client.sessionUpdate({ | ||
| 1074 | sessionId: requestedSessionId, | ||
| 1075 | update: { | ||
| 1076 | sessionUpdate: "tool_call", | ||
| 1077 | toolCallId: monitorToolCallId, | ||
| 1078 | title: "Monitor: mock background task", | ||
| 1079 | kind: "execute", | ||
| 1080 | status: "pending", | ||
| 1081 | rawInput: {}, | ||
| 1082 | }, | ||
| 1083 | }); | ||
| 1084 | yield* agent.client.sessionUpdate({ | ||
| 1085 | sessionId: requestedSessionId, | ||
| 1086 | update: { | ||
| 1087 | sessionUpdate: "tool_call_update", | ||
| 1088 | toolCallId: monitorToolCallId, | ||
| 1089 | status: "in_progress", | ||
| 1090 | }, | ||
| 1091 | }); | ||
| 1092 | yield* agent.client.sessionUpdate({ | ||
| 1093 | sessionId: requestedSessionId, | ||
| 1094 | update: { | ||
| 1095 | sessionUpdate: "tool_call", | ||
| 1096 | toolCallId: "tool-call-fetch-1", | ||
| 1097 | title: "get_command_or_subagent_output", | ||
| 1098 | kind: "other", | ||
| 1099 | status: "pending", | ||
| 1100 | rawInput: { task_id: "task-monitor-1" }, | ||
| 1101 | }, | ||
| 1102 | }); | ||
| 1103 | yield* agent.client.sessionUpdate({ | ||
| 1104 | sessionId: requestedSessionId, | ||
| 1105 | update: { | ||
| 1106 | sessionUpdate: "tool_call_update", | ||
| 1107 | toolCallId: "tool-call-fetch-1", | ||
| 1108 | status: "completed", | ||
| 1109 | rawOutput: { output: "MONITOR_LISTING_TOKEN" }, | ||
| 1110 | }, | ||
| 1111 | }); | ||
| 1112 | yield* agent.client.sessionUpdate({ | ||
| 1113 | sessionId: requestedSessionId, | ||
| 1114 | update: { | ||
| 1115 | sessionUpdate: "agent_message_chunk", | ||
| 1116 | content: { type: "text", text: "Monitor listing ready in-turn." }, | ||
| 1117 | }, | ||
| 1118 | }); | ||
| 1119 | // After deferred finalize (~2s) clears activeTurn, re-emit a terminal | ||
| 1120 | // TaskOutput for the same task so bufferPostSettleWake sees | ||
| 1121 | // alreadyHandledToolUpdate with a non-empty wake path. | ||
| 1122 | yield* Effect.gen(function* () { | ||
| 1123 | yield* Effect.sleep("2500 millis"); | ||
| 1124 | yield* Effect.sync(() => { | ||
| 1125 | writeJsonRpcNotification("session/update", { | ||
| 1126 | sessionId: requestedSessionId, | ||
| 1127 | update: { | ||
| 1128 | sessionUpdate: "tool_call_update", | ||
| 1129 | toolCallId: "tool-call-fetch-1", | ||
| 1130 | title: "get_command_or_subagent_output", | ||
| 1131 | kind: "other", | ||
| 1132 | status: "completed", | ||
| 1133 | rawOutput: { output: "MONITOR_LISTING_TOKEN_LATE" }, | ||
| 1134 | }, | ||
| 1135 | }); | ||
| 1136 | }); | ||
| 1137 | }).pipe(Effect.forkDetach); | ||
| 1138 | return { stopReason: "end_turn" }; | ||
| 1139 | } | ||
| 1140 | |||
| 1141 | if (emitPostSettleMonitorFlow) { | ||
| 1142 | const monitorToolCallId = "tool-call-monitor-1"; | ||
| 1143 | |||
| 1144 | yield* agent.client.sessionUpdate({ | ||
| 1145 | sessionId: requestedSessionId, | ||
| 1146 | update: { | ||
| 1147 | sessionUpdate: "tool_call", | ||
| 1148 | toolCallId: monitorToolCallId, | ||
| 1149 | title: "Monitor: mock background task", | ||
| 1150 | kind: "execute", | ||
| 1151 | status: "pending", | ||
| 1152 | rawInput: {}, | ||
| 1153 | }, | ||
| 1154 | }); | ||
| 1155 | |||
| 1156 | yield* agent.client.sessionUpdate({ | ||
| 1157 | sessionId: requestedSessionId, | ||
| 1158 | update: { | ||
| 1159 | sessionUpdate: "tool_call_update", | ||
| 1160 | toolCallId: monitorToolCallId, | ||
| 1161 | status: "in_progress", | ||
| 1162 | }, | ||
| 1163 | }); | ||
| 1164 | |||
| 1165 | // After the prompt settles, replay the CLI-injected monitor-event | ||
| 1166 | // turn: end notice, TaskOutput hydration, then (once the trigger | ||
| 1167 | // file exists) the report chunk. Detached fiber on the real clock; | ||
| 1168 | // it outlives the prompt handler. | ||
| 1169 | yield* Effect.gen(function* () { | ||
| 1170 | yield* Effect.sleep("150 millis"); | ||
| 1171 | yield* Effect.sync(() => { | ||
| 1172 | writeJsonRpcNotification("session/update", { | ||
| 1173 | sessionId: requestedSessionId, | ||
| 1174 | update: { | ||
| 1175 | sessionUpdate: "user_message_chunk", | ||
| 1176 | content: { | ||
| 1177 | type: "text", | ||
| 1178 | text: 'Monitor "task-monitor-1" ended: [monitor ended: exit 0]', | ||
| 1179 | }, | ||
| 1180 | }, | ||
| 1181 | }); | ||
| 1182 | writeJsonRpcNotification("session/update", { | ||
| 1183 | sessionId: requestedSessionId, | ||
| 1184 | update: { | ||
| 1185 | sessionUpdate: "tool_call", | ||
| 1186 | toolCallId: "tool-call-fetch-1", | ||
| 1187 | title: "get_command_or_subagent_output", | ||
| 1188 | kind: "other", | ||
| 1189 | status: "pending", | ||
| 1190 | rawInput: { task_id: "task-monitor-1" }, | ||
| 1191 | }, | ||
| 1192 | }); | ||
| 1193 | writeJsonRpcNotification("session/update", { | ||
| 1194 | sessionId: requestedSessionId, | ||
| 1195 | update: { | ||
| 1196 | sessionUpdate: "tool_call_update", | ||
| 1197 | toolCallId: "tool-call-fetch-1", | ||
| 1198 | status: "completed", | ||
| 1199 | rawOutput: { output: "MONITOR_LISTING_TOKEN" }, | ||
| 1200 | }, | ||
| 1201 | }); | ||
| 1202 | }); | ||
| 1203 | if (injectedReportTriggerPath === undefined) return; | ||
| 1204 | while (!(yield* Effect.sync(() => NodeFS.existsSync(injectedReportTriggerPath)))) { | ||
| 1205 | yield* Effect.sleep("20 millis"); | ||
| 1206 | } | ||
| 1207 | yield* Effect.sync(() => { | ||
| 1208 | writeJsonRpcNotification("session/update", { | ||
| 1209 | sessionId: requestedSessionId, | ||
| 1210 | update: { | ||
| 1211 | sessionUpdate: "agent_message_chunk", | ||
| 1212 | content: { type: "text", text: "Monitor finished. MONITOR_REPORT_TOKEN" }, | ||
| 1213 | }, | ||
| 1214 | }); | ||
| 1215 | }); | ||
| 1216 | }).pipe(Effect.forkDetach); | ||
| 1217 | |||
| 1218 | return { stopReason: "end_turn" }; | ||
| 1219 | } | ||
| 1220 | |||
| 884 | if (emitAskQuestion) { | 1221 | if (emitAskQuestion) { |
| 885 | yield* agent.client.extRequest("cursor/ask_question", { | 1222 | yield* agent.client.extRequest("cursor/ask_question", { |
| 886 | toolCallId: "ask-question-tool-call-1", | 1223 | toolCallId: "ask-question-tool-call-1", |
| @@ -900,7 +1237,7 @@ const program = Effect.gen(function* () { · 13 unmodified lines | |||
| 900 | return { stopReason: "end_turn" }; | 1237 | return { stopReason: "end_turn" }; |
| 901 | } | 1238 | } |
| 902 | 1239 | ||
| 903 | if (emitXAiAskUserQuestion || emitXAiAskUserQuestionThenHang) { | 1240 | if (emitXAiAskUserQuestion) { |
| 904 | const result = yield* agent.client.extRequest("_x.ai/ask_user_question", { | 1241 | const result = yield* agent.client.extRequest("_x.ai/ask_user_question", { |
| 905 | method: "x.ai/ask_user_question", | 1242 | method: "x.ai/ask_user_question", |
| 906 | params: { | 1243 | params: { |
| @@ -934,84 +1271,6 @@ const program = Effect.gen(function* () { · 27 unmodified lines | |||
| 934 | throw new Error("Expected accepted _x.ai/ask_user_question response answers."); | 1271 | throw new Error("Expected accepted _x.ai/ask_user_question response answers."); |
| 935 | } | 1272 | } |
| 936 | 1273 | ||
| 937 | if (emitXAiAskUserQuestionThenHang) { | ||
| 938 | return yield* Effect.never; | ||
| 939 | } | ||
| 940 | |||
| 941 | return { stopReason: "end_turn" }; | ||
| 942 | } | ||
| 943 | |||
| 944 | if (emitXAiPlanMdWrite) { | ||
| 945 | // Match Grok's real session layout so isGrokPlanMarkdownPath accepts it. | ||
| 946 | const planRoot = process.env.T3_ACP_PLAN_ROOT ?? "/tmp/mock-home/.grok"; | ||
| 947 | const planPath = `${planRoot}/sessions/${requestedSessionId}/plan.md`; | ||
| 948 | const planBody = "# Mock plan\n\n- Write the feature\n- Add a test\n- Ship it\n"; | ||
| 949 | // enter_plan_mode first so the adapter arms planModeActive. | ||
| 950 | yield* agent.client.sessionUpdate({ | ||
| 951 | sessionId: requestedSessionId, | ||
| 952 | update: { | ||
| 953 | sessionUpdate: "tool_call", | ||
| 954 | toolCallId: "enter-plan-mode-1", | ||
| 955 | title: "enter_plan_mode", | ||
| 956 | kind: "other", | ||
| 957 | status: "completed", | ||
| 958 | rawInput: { variant: "EnterPlanMode" }, | ||
| 959 | }, | ||
| 960 | }); | ||
| 961 | yield* agent.client.sessionUpdate({ | ||
| 962 | sessionId: requestedSessionId, | ||
| 963 | update: { | ||
| 964 | sessionUpdate: "tool_call", | ||
| 965 | toolCallId: "plan-md-write-1", | ||
| 966 | title: "write", | ||
| 967 | kind: "edit", | ||
| 968 | status: "pending", | ||
| 969 | rawInput: { file_path: planPath, content: planBody }, | ||
| 970 | }, | ||
| 971 | }); | ||
| 972 | yield* agent.client.sessionUpdate({ | ||
| 973 | sessionId: requestedSessionId, | ||
| 974 | update: { | ||
| 975 | sessionUpdate: "tool_call_update", | ||
| 976 | toolCallId: "plan-md-write-1", | ||
| 977 | kind: "edit", | ||
| 978 | status: "completed", | ||
| 979 | title: `Write \`${planPath}\``, | ||
| 980 | rawInput: { file_path: planPath, content: planBody }, | ||
| 981 | content: [ | ||
| 982 | { | ||
| 983 | type: "diff", | ||
| 984 | path: planPath, | ||
| 985 | oldText: "", | ||
| 986 | newText: planBody, | ||
| 987 | }, | ||
| 988 | ], | ||
| 989 | }, | ||
| 990 | }); | ||
| 991 | return { stopReason: "end_turn" }; | ||
| 992 | } | ||
| 993 | |||
| 994 | if (emitXAiExitPlanMode) { | ||
| 995 | const result = yield* agent.client.extRequest("_x.ai/exit_plan_mode", { | ||
| 996 | method: "x.ai/exit_plan_mode", | ||
| 997 | params: { | ||
| 998 | sessionId: requestedSessionId, | ||
| 999 | toolCallId: "exit-plan-mode-tool-call-1", | ||
| 1000 | planContent: "# Exit plan\n\n- Step one\n- Step two\n", | ||
| 1001 | }, | ||
| 1002 | }); | ||
| 1003 | if (typeof result !== "object" || result === null || !("outcome" in result)) { | ||
| 1004 | throw new Error("Expected _x.ai/exit_plan_mode response outcome."); | ||
| 1005 | } | ||
| 1006 | if ( | ||
| 1007 | result.outcome !== "abandoned" && | ||
| 1008 | result.outcome !== "approved" && | ||
| 1009 | result.outcome !== "request_changes" | ||
| 1010 | ) { | ||
| 1011 | throw new Error( | ||
| 1012 | `Expected exit_plan_mode outcome abandoned|approved|request_changes, got ${String(result.outcome)}`, | ||
| 1013 | ); | ||
| 1014 | } | ||
| 1015 | return { stopReason: "end_turn" }; | 1274 | return { stopReason: "end_turn" }; |
| 1016 | } | 1275 | } |
| 1017 | 1276 | ||
| @@ -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 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, describe, it } from "@effect/vitest"; | 6 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | import { | 7 | import { |
| @@ -16,31 +20,58 @@ import { · 12 unmodified lines | |||
| 16 | type OrchestrationV2ProviderThread, | 20 | type OrchestrationV2ProviderThread, |
| 17 | } from "@t3tools/contracts"; | 21 | } from "@t3tools/contracts"; |
| 18 | import * as DateTime from "effect/DateTime"; | 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 | import * as Effect from "effect/Effect"; | 28 | import * as Effect from "effect/Effect"; |
| 29 | import * as Exit from "effect/Exit"; | ||
| 20 | import * as FileSystem from "effect/FileSystem"; | 30 | import * as FileSystem from "effect/FileSystem"; |
| 21 | import * as Fiber from "effect/Fiber"; | 31 | import * as Fiber from "effect/Fiber"; |
| 22 | import * as Layer from "effect/Layer"; | 32 | import * as Layer from "effect/Layer"; |
| 23 | import * as Option from "effect/Option"; | 33 | import * as Option from "effect/Option"; |
| 24 | import * as Path from "effect/Path"; | 34 | import * as Path from "effect/Path"; |
| 35 | import * as PlatformError from "effect/PlatformError"; | ||
| 25 | import * as Queue from "effect/Queue"; | 36 | import * as Queue from "effect/Queue"; |
| 26 | import * as Schema from "effect/Schema"; | 37 | import * as Schema from "effect/Schema"; |
| 38 | import * as Sink from "effect/Sink"; | ||
| 27 | import * as Stream from "effect/Stream"; | 39 | import * as Stream from "effect/Stream"; |
| 40 | import * as Scope from "effect/Scope"; | ||
| 28 | import * as TestClock from "effect/testing/TestClock"; | 41 | import * as TestClock from "effect/testing/TestClock"; |
| 29 | import { ChildProcessSpawner } from "effect/unstable/process"; | 42 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 43 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 30 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | 44 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 45 | import type * as EffectAcpSchema from "effect-acp/schema"; | ||
| 31 | 46 | ||
| 32 | import { ServerConfig } from "../../config.ts"; | 47 | import { ServerConfig } from "../../config.ts"; |
| 33 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | 48 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 34 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; | 49 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; |
| 50 | import { | ||
| 51 | normalizeXAiAcpToolCallState, | ||
| 52 | registerXAiBackgroundTaskTracking, | ||
| 53 | } from "../../provider/acp/XAiAcpExtension.ts"; | ||
| 35 | import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; | 54 | import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; |
| 36 | import { | 55 | import { |
| 37 | ProviderAdapterV2RuntimePolicy, | 56 | ProviderAdapterV2RuntimePolicy, |
| 57 | type ProviderAdapterV2Event, | ||
| 38 | type ProviderAdapterV2TurnInput, | 58 | type ProviderAdapterV2TurnInput, |
| 39 | } from "../ProviderAdapter.ts"; | 59 | } from "../ProviderAdapter.ts"; |
| 60 | import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; | ||
| 40 | import { | 61 | import { |
| 41 | AcpProviderCapabilitiesV2, | 62 | AcpProviderCapabilitiesV2, |
| 63 | acpCanonicalJson, | ||
| 64 | acpClaimNativeTransportRequest, | ||
| 65 | acpNativeUserInputRequestMatches, | ||
| 66 | acpPostSettleContinuationOfferEvidence, | ||
| 67 | acpPostSettleMonitorPromptShouldSuppress, | ||
| 68 | acpPostSettleWakeEvidence, | ||
| 69 | acpPostSettleWakeShouldBuffer, | ||
| 70 | acpProjectedCommandExitCode, | ||
| 42 | makeAcpAdapterV2, | 71 | makeAcpAdapterV2, |
| 72 | type AcpAdapterV2ExtensionContext, | ||
| 43 | type AcpAdapterV2Flavor, | 73 | type AcpAdapterV2Flavor, |
| 74 | type AcpAdapterV2RuntimeInput, | ||
| 44 | } from "./AcpAdapterV2.ts"; | 75 | } from "./AcpAdapterV2.ts"; |
| 45 | 76 | ||
| 46 | const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { | 77 | const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { |
| @@ -51,14 +82,202 @@ const testLayer = Layer.mergeAll(NodeServices.layer, idAllocatorLayer, serverCon · 4 unmodified lines | |||
| 51 | const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); | 82 | const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); |
| 52 | const decodeUnknownJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString); | 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 | function makeMockRuntime(input: { | 249 | function makeMockRuntime(input: { |
| 55 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | 250 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; |
| 56 | readonly mockAgentPath: string; | 251 | readonly mockAgentPath: string; |
| 57 | readonly environment?: Readonly<Record<string, string>>; | 252 | readonly environment?: |
| 253 | | Readonly<Record<string, string>> | ||
| 254 | | ((runtimeOrdinal: number) => Readonly<Record<string, string>>); | ||
| 58 | readonly protocolEvents?: Queue.Queue<EffectAcpProtocol.AcpProtocolLogEvent>; | 255 | readonly protocolEvents?: Queue.Queue<EffectAcpProtocol.AcpProtocolLogEvent>; |
| 256 | readonly ownDescendantProcessGroups?: boolean; | ||
| 257 | readonly ownDetachedProcessGroup?: boolean; | ||
| 258 | readonly processGroupPlatform?: NodeJS.Platform; | ||
| 259 | readonly processGroupTerminationGrace?: Duration.Input; | ||
| 260 | readonly linuxCgroupController?: AcpSessionRuntime.AcpSessionRuntimeOptions["linuxCgroupController"]; | ||
| 261 | readonly posixProcessTreeController?: AcpSessionRuntime.AcpSessionRuntimeOptions["posixProcessTreeController"]; | ||
| 262 | readonly windowsProcessTreeTerminator?: AcpSessionRuntime.AcpSessionRuntimeOptions["windowsProcessTreeTerminator"]; | ||
| 263 | readonly wrapCancel?: ( | ||
| 264 | cancel: AcpSessionRuntime.AcpSessionRuntime["Service"]["cancel"], | ||
| 265 | ) => AcpSessionRuntime.AcpSessionRuntime["Service"]["cancel"]; | ||
| 266 | readonly wrapOutgoingResponse?: ( | ||
| 267 | onOutgoingResponse: NonNullable<AcpAdapterV2RuntimeInput["onOutgoingResponse"]>, | ||
| 268 | ) => NonNullable<AcpAdapterV2RuntimeInput["onOutgoingResponse"]>; | ||
| 269 | readonly wrapIncomingRequest?: ( | ||
| 270 | onIncomingRequest: NonNullable<AcpAdapterV2RuntimeInput["onIncomingRequest"]>, | ||
| 271 | ) => NonNullable<AcpAdapterV2RuntimeInput["onIncomingRequest"]>; | ||
| 272 | readonly wrapRuntime?: ( | ||
| 273 | runtime: AcpSessionRuntime.AcpSessionRuntime["Service"], | ||
| 274 | runtimeOrdinal: number, | ||
| 275 | ) => AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 59 | }): AcpAdapterV2Flavor["makeRuntime"] { | 276 | }): AcpAdapterV2Flavor["makeRuntime"] { |
| 277 | let runtimeOrdinal = 0; | ||
| 60 | return (runtimeInput) => | 278 | return (runtimeInput) => |
| 61 | Effect.gen(function* () { | 279 | Effect.gen(function* () { |
| 280 | runtimeOrdinal += 1; | ||
| 62 | const protocolEvents = input.protocolEvents; | 281 | const protocolEvents = input.protocolEvents; |
| 63 | const protocolLogging = | 282 | const protocolLogging = |
| 64 | protocolEvents === undefined | 283 | protocolEvents === undefined |
| @@ -74,23 +293,66 @@ function makeMockRuntime(input: { · 9 unmodified lines | |||
| 74 | const context = yield* Layer.build( | 293 | const context = yield* Layer.build( |
| 75 | AcpSessionRuntime.layer({ | 294 | AcpSessionRuntime.layer({ |
| 76 | ...runtimeInput, | 295 | ...runtimeInput, |
| 296 | ...(input.ownDetachedProcessGroup === undefined | ||
| 297 | ? {} | ||
| 298 | : { ownDetachedProcessGroup: input.ownDetachedProcessGroup }), | ||
| 299 | ...(input.ownDescendantProcessGroups === undefined | ||
| 300 | ? {} | ||
| 301 | : { ownDescendantProcessGroups: input.ownDescendantProcessGroups }), | ||
| 302 | ...(input.ownDetachedProcessGroup === true | ||
| 303 | ? { processGroupPlatform: input.processGroupPlatform ?? "linux" } | ||
| 304 | : {}), | ||
| 305 | ...(input.processGroupTerminationGrace === undefined | ||
| 306 | ? {} | ||
| 307 | : { processGroupTerminationGrace: input.processGroupTerminationGrace }), | ||
| 308 | ...(input.linuxCgroupController === undefined | ||
| 309 | ? {} | ||
| 310 | : { linuxCgroupController: input.linuxCgroupController }), | ||
| 311 | ...(input.posixProcessTreeController === undefined | ||
| 312 | ? {} | ||
| 313 | : { posixProcessTreeController: input.posixProcessTreeController }), | ||
| 314 | ...(input.windowsProcessTreeTerminator === undefined | ||
| 315 | ? {} | ||
| 316 | : { windowsProcessTreeTerminator: input.windowsProcessTreeTerminator }), | ||
| 77 | protocolLogging, | 317 | protocolLogging, |
| 78 | spawn: { | 318 | spawn: { |
| 79 | command: process.execPath, | 319 | command: process.execPath, |
| 80 | args: [input.mockAgentPath], | 320 | args: [input.mockAgentPath], |
| 81 | cwd: runtimeInput.cwd, | 321 | cwd: runtimeInput.cwd, |
| 82 | env: { T3_ACP_SESSION_LIFECYCLE: "1", ...input.environment }, | 322 | env: { |
| 323 | T3_ACP_SESSION_LIFECYCLE: "1", | ||
| 324 | ...(typeof input.environment === "function" | ||
| 325 | ? input.environment(runtimeOrdinal) | ||
| 326 | : input.environment), | ||
| 327 | }, | ||
| 83 | }, | 328 | }, |
| 84 | authMethodId: "test", | 329 | authMethodId: "test", |
| 330 | ...(input.wrapIncomingRequest === undefined || | ||
| 331 | runtimeInput.onIncomingRequest === undefined | ||
| 332 | ? {} | ||
| 333 | : { | ||
| 334 | onIncomingRequest: input.wrapIncomingRequest(runtimeInput.onIncomingRequest), | ||
| 335 | }), | ||
| 336 | ...(input.wrapOutgoingResponse === undefined || | ||
| 337 | runtimeInput.onOutgoingResponse === undefined | ||
| 338 | ? {} | ||
| 339 | : { | ||
| 340 | onOutgoingResponse: input.wrapOutgoingResponse(runtimeInput.onOutgoingResponse), | ||
| 341 | }), | ||
| 85 | }).pipe( | 342 | }).pipe( |
| 86 | Layer.provide( | 343 | Layer.provide( |
| 87 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), | 344 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), |
| 88 | ), | 345 | ), |
| 89 | ), | 346 | ), |
| 90 | ); | 347 | ); |
| 91 | return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | 348 | const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( |
| 92 | Effect.provide(context), | 349 | Effect.provide(context), |
| 93 | ); | 350 | ); |
| 351 | const wrapped = { | ||
| 352 | ...runtime, | ||
| 353 | ...(input.wrapCancel === undefined ? {} : { cancel: input.wrapCancel(runtime.cancel) }), | ||
| 354 | }; | ||
| 355 | return input.wrapRuntime?.(wrapped, runtimeOrdinal) ?? wrapped; | ||
| 94 | }); | 356 | }); |
| 95 | } | 357 | } |
| 96 | 358 | ||
| @@ -123,6 +385,20 @@ function rawProtocolRequest( · 26 unmodified lines | |||
| 123 | return undefined; | 385 | return undefined; |
| 124 | } | 386 | } |
| 125 | 387 | ||
| 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 | function makeTurnInput(input: { | 402 | function makeTurnInput(input: { |
| 127 | readonly threadId: ThreadId; | 403 | readonly threadId: ThreadId; |
| 128 | readonly providerThread: OrchestrationV2ProviderThread; | 404 | readonly providerThread: OrchestrationV2ProviderThread; |
| @@ -181,6 +457,418 @@ function makeTurnInput(input: { · 52 unmodified lines | |||
| 181 | } | 457 | } |
| 182 | 458 | ||
| 183 | describe("AcpAdapterV2", () => { | 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 | it.effect("negotiates and executes optional native session forks through the ACP runtime", () => | 872 | it.effect("negotiates and executes optional native session forks through the ACP runtime", () => |
| 185 | Effect.gen(function* () { | 873 | Effect.gen(function* () { |
| 186 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 874 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| @@ -244,16 +932,18 @@ describe("AcpAdapterV2", () => { · 57 unmodified lines | |||
| 244 | sourceProviderThread, | 932 | sourceProviderThread, |
| 245 | targetThreadId, | 933 | targetThreadId, |
| 246 | }); | 934 | }); |
| 247 | const forkRequest = Option.getOrThrow( | 935 | const forkRequestEvent = Option.getOrThrow( |
| 248 | yield* Stream.fromQueue(protocolEvents).pipe( | 936 | yield* Stream.fromQueue(protocolEvents).pipe( |
| 249 | Stream.filter( | 937 | Stream.filter( |
| 250 | (event) => | 938 | (event) => |
| 251 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/fork", | 939 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/fork", |
| 252 | ), | 940 | ), |
| 253 | Stream.map(rawProtocolRequest), | ||
| 254 | Stream.runHead, | 941 | Stream.runHead, |
| 255 | ), | 942 | ), |
| 256 | ); | 943 | ); |
| 944 | const forkRequest = Option.getOrThrow( | ||
| 945 | Option.fromNullishOr(rawProtocolRequest(forkRequestEvent)), | ||
| 946 | ); | ||
| 257 | 947 | ||
| 258 | assert.equal(sourceProviderThread.nativeThreadRef?.nativeId, "mock-session-1"); | 948 | assert.equal(sourceProviderThread.nativeThreadRef?.nativeId, "mock-session-1"); |
| 259 | assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, "mock-session-1-fork"); | 949 | assert.equal(forkedProviderThread.nativeThreadRef?.nativeId, "mock-session-1-fork"); |
| @@ -274,7 +964,7 @@ describe("AcpAdapterV2", () => { · 14 unmodified lines | |||
| 274 | }).pipe(Effect.provide(testLayer), Effect.scoped), | 964 | }).pipe(Effect.provide(testLayer), Effect.scoped), |
| 275 | ); | 965 | ); |
| 276 | 966 | ||
| 277 | it.effect("rejects requested options that the active ACP session does not expose", () => | 967 | it.effect("closes an idle ACP session exactly once through the transition permit", () => |
| 278 | Effect.gen(function* () { | 968 | Effect.gen(function* () { |
| 279 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 969 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 280 | const fileSystem = yield* FileSystem.FileSystem; | 970 | const fileSystem = yield* FileSystem.FileSystem; |
| @@ -284,44 +974,43 @@ describe("AcpAdapterV2", () => { · 3 unmodified lines | |||
| 284 | const mockAgentPath = yield* path.fromFileUrl( | 974 | const mockAgentPath = yield* path.fromFileUrl( |
| 285 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 975 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| 286 | ); | 976 | ); |
| 977 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 287 | const instanceId = ProviderInstanceId.make("acp-test"); | 978 | const instanceId = ProviderInstanceId.make("acp-test"); |
| 288 | const adapter = makeAcpAdapterV2({ | 979 | const adapter = makeAcpAdapterV2({ |
| 289 | instanceId, | 980 | instanceId, |
| 290 | flavor: { | 981 | flavor: { |
| 291 | driver: ACP_TEST_DRIVER, | 982 | driver: ACP_TEST_DRIVER, |
| 292 | capabilities: AcpProviderCapabilitiesV2, | 983 | capabilities: AcpProviderCapabilitiesV2, |
| 293 | makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath }), | 984 | makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }), |
| 294 | }, | 985 | }, |
| 295 | fileSystem, | 986 | fileSystem, |
| 296 | idAllocator, | 987 | idAllocator, |
| 297 | serverConfig, | 988 | serverConfig, |
| 298 | }); | 989 | }); |
| 299 | const threadId = ThreadId.make("thread-acp-unsupported-option"); | 990 | const threadId = ThreadId.make("thread-acp-idle-finalizer"); |
| 300 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | 991 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ |
| 301 | runtimeMode: "full-access", | 992 | runtimeMode: "full-access", |
| 302 | interactionMode: "default", | 993 | interactionMode: "default", |
| 303 | cwd: process.cwd(), | 994 | cwd: process.cwd(), |
| 304 | }); | 995 | }); |
| 305 | const error = yield* adapter | 996 | const modelSelection = { instanceId, model: "default" } as const; |
| 997 | const sessionScope = yield* Scope.make(); | ||
| 998 | yield* adapter | ||
| 306 | .openSession({ | 999 | .openSession({ |
| 307 | threadId, | 1000 | threadId, |
| 308 | providerSessionId: ProviderSessionId.make("provider-session-acp-unsupported-option"), | 1001 | providerSessionId: ProviderSessionId.make("provider-session-acp-idle-finalizer"), |
| 309 | modelSelection: { | 1002 | modelSelection, |
| 310 | instanceId, | ||
| 311 | model: "default", | ||
| 312 | options: [{ id: "missing-option", value: "high" }], | ||
| 313 | }, | ||
| 314 | runtimePolicy, | 1003 | runtimePolicy, |
| 315 | }) | 1004 | }) |
| 316 | .pipe(Effect.flip); | 1005 | .pipe(Effect.provideService(Scope.Scope, sessionScope)); |
| 317 | 1006 | yield* pollProtocolMethods(protocolEvents); | |
| 318 | assert.equal(error._tag, "ProviderAdapterOpenSessionError"); | 1007 | yield* Scope.close(sessionScope, Exit.void); |
| 319 | assert.include(String(error.cause), "does not expose requested configuration option(s)"); | 1008 | const finalizerMethods = yield* pollProtocolMethods(protocolEvents); |
| 320 | assert.include(String(error.cause), "missing-option"); | 1009 | assert.equal(finalizerMethods.filter((method) => method === "session/close").length, 1); |
| 321 | }).pipe(Effect.provide(testLayer), Effect.scoped), | 1010 | }).pipe(Effect.provide(testLayer)), |
| 322 | ); | 1011 | ); |
| 323 | 1012 | ||
| 324 | it.effect("reconfigures a loaded ACP session from its own active setup metadata", () => | 1013 | it.effect("rejects requested options that the active ACP session does not expose", () => |
| 325 | Effect.gen(function* () { | 1014 | Effect.gen(function* () { |
| 326 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 1015 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 327 | const fileSystem = yield* FileSystem.FileSystem; | 1016 | const fileSystem = yield* FileSystem.FileSystem; |
| @@ -331,20 +1020,67 @@ describe("AcpAdapterV2", () => { · 3 unmodified lines | |||
| 331 | const mockAgentPath = yield* path.fromFileUrl( | 1020 | const mockAgentPath = yield* path.fromFileUrl( |
| 332 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 1021 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| 333 | ); | 1022 | ); |
| 334 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 335 | const instanceId = ProviderInstanceId.make("acp-test"); | 1023 | const instanceId = ProviderInstanceId.make("acp-test"); |
| 336 | const adapter = makeAcpAdapterV2({ | 1024 | const adapter = makeAcpAdapterV2({ |
| 337 | instanceId, | 1025 | instanceId, |
| 338 | flavor: { | 1026 | flavor: { |
| 339 | driver: ACP_TEST_DRIVER, | 1027 | driver: ACP_TEST_DRIVER, |
| 340 | capabilities: AcpProviderCapabilitiesV2, | 1028 | capabilities: AcpProviderCapabilitiesV2, |
| 341 | makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }), | 1029 | makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath }), |
| 342 | }, | 1030 | }, |
| 343 | fileSystem, | 1031 | fileSystem, |
| 344 | idAllocator, | 1032 | idAllocator, |
| 345 | serverConfig, | 1033 | serverConfig, |
| 346 | }); | 1034 | }); |
| 347 | const firstThreadId = ThreadId.make("thread-acp-active-setup:first"); | 1035 | const threadId = ThreadId.make("thread-acp-unsupported-option"); |
| 1036 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 1037 | runtimeMode: "full-access", | ||
| 1038 | interactionMode: "default", | ||
| 1039 | cwd: process.cwd(), | ||
| 1040 | }); | ||
| 1041 | const error = yield* adapter | ||
| 1042 | .openSession({ | ||
| 1043 | threadId, | ||
| 1044 | providerSessionId: ProviderSessionId.make("provider-session-acp-unsupported-option"), | ||
| 1045 | modelSelection: { | ||
| 1046 | instanceId, | ||
| 1047 | model: "default", | ||
| 1048 | options: [{ id: "missing-option", value: "high" }], | ||
| 1049 | }, | ||
| 1050 | runtimePolicy, | ||
| 1051 | }) | ||
| 1052 | .pipe(Effect.flip); | ||
| 1053 | |||
| 1054 | assert.equal(error._tag, "ProviderAdapterOpenSessionError"); | ||
| 1055 | assert.include(String(error.cause), "does not expose requested configuration option(s)"); | ||
| 1056 | assert.include(String(error.cause), "missing-option"); | ||
| 1057 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 1058 | ); | ||
| 1059 | |||
| 1060 | it.effect("reconfigures a loaded ACP session from its own active setup metadata", () => | ||
| 1061 | Effect.gen(function* () { | ||
| 1062 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 1063 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1064 | const idAllocator = yield* IdAllocatorV2; | ||
| 1065 | const path = yield* Path.Path; | ||
| 1066 | const serverConfig = yield* ServerConfig; | ||
| 1067 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 1068 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 1069 | ); | ||
| 1070 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 1071 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 1072 | const adapter = makeAcpAdapterV2({ | ||
| 1073 | instanceId, | ||
| 1074 | flavor: { | ||
| 1075 | driver: ACP_TEST_DRIVER, | ||
| 1076 | capabilities: AcpProviderCapabilitiesV2, | ||
| 1077 | makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }), | ||
| 1078 | }, | ||
| 1079 | fileSystem, | ||
| 1080 | idAllocator, | ||
| 1081 | serverConfig, | ||
| 1082 | }); | ||
| 1083 | const firstThreadId = ThreadId.make("thread-acp-active-setup:first"); | ||
| 348 | const secondThreadId = ThreadId.make("thread-acp-active-setup:second"); | 1084 | const secondThreadId = ThreadId.make("thread-acp-active-setup:second"); |
| 349 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | 1085 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ |
| 350 | runtimeMode: "full-access", | 1086 | runtimeMode: "full-access", |
| @@ -424,6 +1160,149 @@ describe("AcpAdapterV2", () => { · 73 unmodified lines | |||
| 424 | }).pipe(Effect.provide(testLayer), Effect.scoped), | 1160 | }).pipe(Effect.provide(testLayer), Effect.scoped), |
| 425 | ); | 1161 | ); |
| 426 | 1162 | ||
| 1163 | it.live("terminalizes an empty successful foreground Bash tool when the turn completes", () => | ||
| 1164 | Effect.gen(function* () { | ||
| 1165 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 1166 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1167 | const idAllocator = yield* IdAllocatorV2; | ||
| 1168 | const path = yield* Path.Path; | ||
| 1169 | const serverConfig = yield* ServerConfig; | ||
| 1170 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 1171 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 1172 | ); | ||
| 1173 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 1174 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 1175 | const adapter = makeAcpAdapterV2({ | ||
| 1176 | instanceId, | ||
| 1177 | flavor: { | ||
| 1178 | driver: ACP_TEST_DRIVER, | ||
| 1179 | capabilities: AcpProviderCapabilitiesV2, | ||
| 1180 | makeRuntime: makeMockRuntime({ | ||
| 1181 | childProcessSpawner, | ||
| 1182 | mockAgentPath, | ||
| 1183 | environment: (runtimeOrdinal) => | ||
| 1184 | runtimeOrdinal === 1 ? { T3_ACP_EMIT_EMPTY_SUCCESSFUL_BASH_THEN_HANG: "1" } : {}, | ||
| 1185 | ownDetachedProcessGroup: true, | ||
| 1186 | protocolEvents, | ||
| 1187 | }), | ||
| 1188 | normalizeToolCall: normalizeXAiAcpToolCallState, | ||
| 1189 | restartRuntimeAfterInterrupt: true, | ||
| 1190 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 1191 | }, | ||
| 1192 | fileSystem, | ||
| 1193 | idAllocator, | ||
| 1194 | serverConfig, | ||
| 1195 | }); | ||
| 1196 | const threadId = ThreadId.make("thread-acp-empty-successful-bash"); | ||
| 1197 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 1198 | runtimeMode: "full-access", | ||
| 1199 | interactionMode: "default", | ||
| 1200 | cwd: process.cwd(), | ||
| 1201 | }); | ||
| 1202 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 1203 | const runtime = yield* adapter.openSession({ | ||
| 1204 | threadId, | ||
| 1205 | providerSessionId: ProviderSessionId.make("provider-session-acp-empty-successful-bash"), | ||
| 1206 | modelSelection, | ||
| 1207 | runtimePolicy, | ||
| 1208 | }); | ||
| 1209 | const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | ||
| 1210 | yield* runtime.events.pipe( | ||
| 1211 | Stream.runForEach((event) => Queue.offer(events, event)), | ||
| 1212 | Effect.forkScoped, | ||
| 1213 | ); | ||
| 1214 | const providerThread = yield* runtime.ensureThread({ | ||
| 1215 | threadId, | ||
| 1216 | modelSelection, | ||
| 1217 | runtimePolicy, | ||
| 1218 | }); | ||
| 1219 | const now = yield* DateTime.now; | ||
| 1220 | yield* runtime.startTurn( | ||
| 1221 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | ||
| 1222 | ); | ||
| 1223 | |||
| 1224 | const statuses: string[] = []; | ||
| 1225 | let runningStartedAt: DateTime.Utc | null = null; | ||
| 1226 | let completedStartedAt: DateTime.Utc | null = null; | ||
| 1227 | let completedAt: DateTime.Utc | null = null; | ||
| 1228 | let completedInput: string | null = null; | ||
| 1229 | let completedOutput: string | null | undefined; | ||
| 1230 | let completedExitCode: number | null | undefined; | ||
| 1231 | let runningProjectedExitCode: number | undefined = undefined; | ||
| 1232 | let terminal = false; | ||
| 1233 | while (!terminal) { | ||
| 1234 | const event = yield* Queue.take(events); | ||
| 1235 | if ( | ||
| 1236 | event.type === "turn_item.updated" && | ||
| 1237 | event.turnItem.nativeItemRef?.nativeId === "tool-call-empty-success-1" | ||
| 1238 | ) { | ||
| 1239 | statuses.push(event.turnItem.status); | ||
| 1240 | if (event.turnItem.status === "running") { | ||
| 1241 | runningStartedAt ??= event.turnItem.startedAt; | ||
| 1242 | if (event.turnItem.type === "command_execution") { | ||
| 1243 | runningProjectedExitCode = event.turnItem.exitCode; | ||
| 1244 | } | ||
| 1245 | } | ||
| 1246 | if (event.turnItem.status === "completed") { | ||
| 1247 | completedStartedAt = event.turnItem.startedAt; | ||
| 1248 | completedAt = event.turnItem.completedAt; | ||
| 1249 | if (event.turnItem.type === "command_execution") { | ||
| 1250 | completedInput = event.turnItem.input; | ||
| 1251 | completedOutput = event.turnItem.output; | ||
| 1252 | completedExitCode = event.turnItem.exitCode; | ||
| 1253 | } | ||
| 1254 | } | ||
| 1255 | } | ||
| 1256 | if (event.type === "turn.terminal") terminal = true; | ||
| 1257 | } | ||
| 1258 | |||
| 1259 | assert.deepEqual(statuses, ["running", "running", "completed"]); | ||
| 1260 | assert.deepEqual(completedStartedAt, runningStartedAt); | ||
| 1261 | assert.isNotNull(completedAt); | ||
| 1262 | assert.equal(completedInput, "true"); | ||
| 1263 | assert.equal(completedOutput, undefined); | ||
| 1264 | assert.equal( | ||
| 1265 | runningProjectedExitCode, | ||
| 1266 | undefined, | ||
| 1267 | "mid-stream exit_code must not project until the tool is terminal", | ||
| 1268 | ); | ||
| 1269 | assert.equal(completedExitCode, 0); | ||
| 1270 | |||
| 1271 | yield* Queue.takeAll(protocolEvents); | ||
| 1272 | yield* runtime | ||
| 1273 | .startTurn( | ||
| 1274 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), | ||
| 1275 | ) | ||
| 1276 | .pipe(Effect.forkScoped); | ||
| 1277 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 1278 | Stream.filter( | ||
| 1279 | (event) => | ||
| 1280 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | ||
| 1281 | ), | ||
| 1282 | Stream.runHead, | ||
| 1283 | ); | ||
| 1284 | const secondProviderTurnId = idAllocator.derive.providerTurn({ | ||
| 1285 | driver: ACP_TEST_DRIVER, | ||
| 1286 | nativeTurnId: "mock-session-1:turn:2", | ||
| 1287 | }); | ||
| 1288 | yield* runtime.interruptTurn({ | ||
| 1289 | providerThread, | ||
| 1290 | providerTurnId: secondProviderTurnId, | ||
| 1291 | requestRuntimeRestart: true, | ||
| 1292 | }); | ||
| 1293 | yield* runtime.startTurn( | ||
| 1294 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 3 }), | ||
| 1295 | ); | ||
| 1296 | const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 1297 | Stream.filter( | ||
| 1298 | (event) => event.direction === "outgoing" && rawProtocolMethod(event) === "session/load", | ||
| 1299 | ), | ||
| 1300 | Stream.runHead, | ||
| 1301 | ); | ||
| 1302 | assert.isTrue(Option.isSome(loadAfterRestart)); | ||
| 1303 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 1304 | ); | ||
| 1305 | |||
| 427 | it.effect("cancels pending permission requests while interrupting an ACP turn", () => | 1306 | it.effect("cancels pending permission requests while interrupting an ACP turn", () => |
| 428 | Effect.gen(function* () { | 1307 | Effect.gen(function* () { |
| 429 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 1308 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| @@ -434,6 +1313,7 @@ describe("AcpAdapterV2", () => { · 4 unmodified lines | |||
| 434 | const mockAgentPath = yield* path.fromFileUrl( | 1313 | const mockAgentPath = yield* path.fromFileUrl( |
| 435 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 1314 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| 436 | ); | 1315 | ); |
| 1316 | const releaseCancel = yield* Deferred.make<void>(); | ||
| 437 | const instanceId = ProviderInstanceId.make("acp-test"); | 1317 | const instanceId = ProviderInstanceId.make("acp-test"); |
| 438 | const adapter = makeAcpAdapterV2({ | 1318 | const adapter = makeAcpAdapterV2({ |
| 439 | instanceId, | 1319 | instanceId, |
| @@ -444,6 +1324,7 @@ describe("AcpAdapterV2", () => { · 4 unmodified lines | |||
| 444 | childProcessSpawner, | 1324 | childProcessSpawner, |
| 445 | mockAgentPath, | 1325 | mockAgentPath, |
| 446 | environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, | 1326 | environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, |
| 1327 | wrapCancel: (cancel) => Deferred.await(releaseCancel).pipe(Effect.andThen(cancel)), | ||
| 447 | }), | 1328 | }), |
| 448 | }, | 1329 | }, |
| 449 | fileSystem, | 1330 | fileSystem, |
| @@ -489,10 +1370,12 @@ describe("AcpAdapterV2", () => { · 39 unmodified lines | |||
| 489 | return yield* Effect.die("Expected a pending ACP permission request with a provider turn"); | 1370 | return yield* Effect.die("Expected a pending ACP permission request with a provider turn"); |
| 490 | } | 1371 | } |
| 491 | 1372 | ||
| 492 | yield* runtime.interruptTurn({ | 1373 | const interruptFiber = yield* runtime |
| 493 | providerThread, | 1374 | .interruptTurn({ |
| 494 | providerTurnId: pendingRequest.runtimeRequest.providerTurnId, | 1375 | providerThread, |
| 495 | }); | 1376 | providerTurnId: pendingRequest.runtimeRequest.providerTurnId, |
| 1377 | }) | ||
| 1378 | .pipe(Effect.forkScoped); | ||
| 496 | 1379 | ||
| 497 | const cancelledRequest = Option.getOrThrow( | 1380 | const cancelledRequest = Option.getOrThrow( |
| 498 | yield* runtime.events.pipe( | 1381 | yield* runtime.events.pipe( |
| @@ -506,10 +1389,19 @@ describe("AcpAdapterV2", () => { · 7 unmodified lines | |||
| 506 | ), | 1389 | ), |
| 507 | ); | 1390 | ); |
| 508 | assert.equal(cancelledRequest.type, "runtime_request.updated"); | 1391 | assert.equal(cancelledRequest.type, "runtime_request.updated"); |
| 1392 | yield* Deferred.succeed(releaseCancel, undefined); | ||
| 1393 | yield* Fiber.join(interruptFiber); | ||
| 1394 | const terminal = Option.getOrThrow( | ||
| 1395 | yield* runtime.events.pipe( | ||
| 1396 | Stream.filter((event) => event.type === "turn.terminal"), | ||
| 1397 | Stream.runHead, | ||
| 1398 | ), | ||
| 1399 | ); | ||
| 1400 | assert.equal(terminal.type, "turn.terminal"); | ||
| 509 | }).pipe(Effect.provide(testLayer), Effect.scoped), | 1401 | }).pipe(Effect.provide(testLayer), Effect.scoped), |
| 510 | ); | 1402 | ); |
| 511 | 1403 | ||
| 512 | it.effect("releases an ACP turn when cancellation times out", () => | 1404 | it.live("keeps hard teardown excluded until a permission response is enqueued", () => |
| 513 | Effect.gen(function* () { | 1405 | Effect.gen(function* () { |
| 514 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 1406 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 515 | const fileSystem = yield* FileSystem.FileSystem; | 1407 | const fileSystem = yield* FileSystem.FileSystem; |
| @@ -519,8 +1411,9 @@ describe("AcpAdapterV2", () => { · 3 unmodified lines | |||
| 519 | const mockAgentPath = yield* path.fromFileUrl( | 1411 | const mockAgentPath = yield* path.fromFileUrl( |
| 520 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | 1412 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), |
| 521 | ); | 1413 | ); |
| 1414 | const responseEnqueued = yield* Deferred.make<void>(); | ||
| 1415 | const releaseResponseAcknowledgement = yield* Deferred.make<void>(); | ||
| 522 | const instanceId = ProviderInstanceId.make("acp-test"); | 1416 | const instanceId = ProviderInstanceId.make("acp-test"); |
| 523 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 524 | const adapter = makeAcpAdapterV2({ | 1417 | const adapter = makeAcpAdapterV2({ |
| 525 | instanceId, | 1418 | instanceId, |
| 526 | flavor: { | 1419 | flavor: { |
| @@ -529,24 +1422,28 @@ describe("AcpAdapterV2", () => { · 2 unmodified lines | |||
| 529 | makeRuntime: makeMockRuntime({ | 1422 | makeRuntime: makeMockRuntime({ |
| 530 | childProcessSpawner, | 1423 | childProcessSpawner, |
| 531 | mockAgentPath, | 1424 | mockAgentPath, |
| 532 | environment: { T3_ACP_PROMPT_DELAY_MS: "5000" }, | 1425 | environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, |
| 533 | protocolEvents, | 1426 | wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => |
| 1427 | Deferred.succeed(responseEnqueued, undefined).pipe( | ||
| 1428 | Effect.andThen(Deferred.await(releaseResponseAcknowledgement)), | ||
| 1429 | Effect.andThen(onOutgoingResponse(requestId)), | ||
| 1430 | ), | ||
| 534 | }), | 1431 | }), |
| 535 | }, | 1432 | }, |
| 536 | fileSystem, | 1433 | fileSystem, |
| 537 | idAllocator, | 1434 | idAllocator, |
| 538 | serverConfig, | 1435 | serverConfig, |
| 539 | }); | 1436 | }); |
| 540 | const threadId = ThreadId.make("thread-acp-cancel-timeout"); | 1437 | const threadId = ThreadId.make("thread-acp-response-wins-permission"); |
| 541 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | 1438 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ |
| 542 | runtimeMode: "full-access", | 1439 | runtimeMode: "approval-required", |
| 543 | interactionMode: "default", | 1440 | interactionMode: "default", |
| 544 | cwd: process.cwd(), | 1441 | cwd: process.cwd(), |
| 545 | }); | 1442 | }); |
| 546 | const modelSelection = { instanceId, model: "default" } as const; | 1443 | const modelSelection = { instanceId, model: "default" } as const; |
| 547 | const runtime = yield* adapter.openSession({ | 1444 | const runtime = yield* adapter.openSession({ |
| 548 | threadId, | 1445 | threadId, |
| 549 | providerSessionId: ProviderSessionId.make("provider-session-acp-cancel-timeout"), | 1446 | providerSessionId: ProviderSessionId.make("provider-session-acp-response-wins-permission"), |
| 550 | modelSelection, | 1447 | modelSelection, |
| 551 | runtimePolicy, | 1448 | runtimePolicy, |
| 552 | }); | 1449 | }); |
| @@ -555,57 +1452,5168 @@ describe("AcpAdapterV2", () => { · 2 unmodified lines | |||
| 555 | modelSelection, | 1452 | modelSelection, |
| 556 | runtimePolicy, | 1453 | runtimePolicy, |
| 557 | }); | 1454 | }); |
| 558 | const now = yield* DateTime.now; | 1455 | yield* runtime.startTurn( |
| 559 | const firstTurn = makeTurnInput({ | 1456 | makeTurnInput({ |
| 560 | threadId, | 1457 | threadId, |
| 561 | providerThread, | 1458 | providerThread, |
| 562 | instanceId, | 1459 | instanceId, |
| 563 | runtimePolicy, | 1460 | runtimePolicy, |
| 564 | now, | 1461 | now: yield* DateTime.now, |
| 565 | }); | 1462 | }), |
| 566 | yield* runtime.startTurn(firstTurn); | ||
| 567 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 568 | Stream.filter( | ||
| 569 | (event) => | ||
| 570 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | ||
| 571 | ), | ||
| 572 | Stream.runHead, | ||
| 573 | ); | 1463 | ); |
| 574 | const providerTurnId = idAllocator.derive.providerTurn({ | 1464 | const pending = Option.getOrThrow( |
| 575 | driver: ACP_TEST_DRIVER, | 1465 | yield* runtime.events.pipe( |
| 576 | nativeTurnId: "mock-session-1:turn:1", | 1466 | Stream.filter( |
| 577 | }); | 1467 | (event) => |
| 578 | const interruptFiber = yield* runtime | 1468 | event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", |
| 579 | .interruptTurn({ providerThread, providerTurnId }) | 1469 | ), |
| 580 | .pipe(Effect.flip, Effect.forkScoped); | 1470 | Stream.runHead, |
| 581 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 582 | Stream.filter( | ||
| 583 | (event) => | ||
| 584 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/cancel", | ||
| 585 | ), | 1471 | ), |
| 586 | Stream.runHead, | ||
| 587 | ); | 1472 | ); |
| 588 | yield* TestClock.adjust("10 seconds"); | 1473 | if ( |
| 589 | const interruptError = yield* Fiber.join(interruptFiber); | 1474 | pending.type !== "runtime_request.updated" || |
| 590 | assert.equal(interruptError._tag, "ProviderAdapterInterruptError"); | 1475 | pending.runtimeRequest.providerTurnId === null |
| 1476 | ) { | ||
| 1477 | return yield* Effect.die("Expected a pending permission request"); | ||
| 1478 | } | ||
| 1479 | const responseFiber = yield* runtime | ||
| 1480 | .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" }) | ||
| 1481 | .pipe(Effect.forkScoped); | ||
| 1482 | yield* Deferred.await(responseEnqueued); | ||
| 1483 | const interruptFiber = yield* runtime | ||
| 1484 | .interruptTurn({ | ||
| 1485 | providerThread, | ||
| 1486 | providerTurnId: pending.runtimeRequest.providerTurnId, | ||
| 1487 | requestRuntimeRestart: true, | ||
| 1488 | }) | ||
| 1489 | .pipe(Effect.forkScoped); | ||
| 1490 | yield* Effect.yieldNow; | ||
| 1491 | assert.isUndefined(responseFiber.pollUnsafe()); | ||
| 1492 | assert.isUndefined(interruptFiber.pollUnsafe()); | ||
| 1493 | |||
| 1494 | yield* Deferred.succeed(releaseResponseAcknowledgement, undefined); | ||
| 1495 | yield* Fiber.join(responseFiber); | ||
| 1496 | yield* Fiber.join(interruptFiber); | ||
| 1497 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 1498 | ); | ||
| 591 | 1499 | ||
| 1500 | it.live("correlates reordered elicitation schemas through the completed stdout write", () => | ||
| 1501 | Effect.gen(function* () { | ||
| 1502 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 1503 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1504 | const idAllocator = yield* IdAllocatorV2; | ||
| 1505 | const path = yield* Path.Path; | ||
| 1506 | const serverConfig = yield* ServerConfig; | ||
| 1507 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 1508 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 1509 | ); | ||
| 1510 | const responseWritten = yield* Deferred.make<void>(); | ||
| 1511 | const releaseResponseAcknowledgement = yield* Deferred.make<void>(); | ||
| 1512 | const instanceId = ProviderInstanceId.make("acp-test-reordered-elicitation"); | ||
| 1513 | const adapter = makeAcpAdapterV2({ | ||
| 1514 | instanceId, | ||
| 1515 | flavor: { | ||
| 1516 | driver: ACP_TEST_DRIVER, | ||
| 1517 | capabilities: AcpProviderCapabilitiesV2, | ||
| 1518 | makeRuntime: makeMockRuntime({ | ||
| 1519 | childProcessSpawner, | ||
| 1520 | mockAgentPath, | ||
| 1521 | environment: { T3_ACP_EMIT_ELICITATION: "1" }, | ||
| 1522 | wrapIncomingRequest: (onIncomingRequest) => (requestId, method, payload) => { | ||
| 1523 | if (method !== "session/elicitation") { | ||
| 1524 | return onIncomingRequest(requestId, method, payload); | ||
| 1525 | } | ||
| 1526 | const record = payload as Record<string, unknown>; | ||
| 1527 | return onIncomingRequest(requestId, method, { | ||
| 1528 | ...record, | ||
| 1529 | requestedSchema: { | ||
| 1530 | properties: { | ||
| 1531 | approved: { title: "Approved", type: "boolean" }, | ||
| 1532 | }, | ||
| 1533 | type: "object", | ||
| 1534 | }, | ||
| 1535 | }); | ||
| 1536 | }, | ||
| 1537 | wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => | ||
| 1538 | Deferred.succeed(responseWritten, undefined).pipe( | ||
| 1539 | Effect.andThen(Deferred.await(releaseResponseAcknowledgement)), | ||
| 1540 | Effect.andThen(onOutgoingResponse(requestId)), | ||
| 1541 | ), | ||
| 1542 | }), | ||
| 1543 | }, | ||
| 1544 | fileSystem, | ||
| 1545 | idAllocator, | ||
| 1546 | serverConfig, | ||
| 1547 | }); | ||
| 1548 | const threadId = ThreadId.make("thread-acp-reordered-elicitation"); | ||
| 1549 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 1550 | runtimeMode: "approval-required", | ||
| 1551 | interactionMode: "default", | ||
| 1552 | cwd: process.cwd(), | ||
| 1553 | }); | ||
| 1554 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 1555 | const runtime = yield* adapter.openSession({ | ||
| 1556 | threadId, | ||
| 1557 | providerSessionId: ProviderSessionId.make("provider-session-acp-reordered-elicitation"), | ||
| 1558 | modelSelection, | ||
| 1559 | runtimePolicy, | ||
| 1560 | }); | ||
| 1561 | const providerThread = yield* runtime.ensureThread({ | ||
| 1562 | threadId, | ||
| 1563 | modelSelection, | ||
| 1564 | runtimePolicy, | ||
| 1565 | }); | ||
| 592 | yield* runtime.startTurn( | 1566 | yield* runtime.startTurn( |
| 593 | makeTurnInput({ | 1567 | makeTurnInput({ |
| 594 | threadId, | 1568 | threadId, |
| 595 | providerThread, | 1569 | providerThread, |
| 596 | instanceId, | 1570 | instanceId, |
| 597 | runtimePolicy, | 1571 | runtimePolicy, |
| 598 | now, | 1572 | now: yield* DateTime.now, |
| 599 | ordinal: 2, | ||
| 600 | }), | 1573 | }), |
| 601 | ); | 1574 | ); |
| 602 | yield* Stream.fromQueue(protocolEvents).pipe( | 1575 | const pending = Option.getOrThrow( |
| 603 | Stream.filter( | 1576 | yield* runtime.events.pipe( |
| 604 | (event) => | 1577 | Stream.filter( |
| 605 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | 1578 | (event) => |
| 1579 | event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | ||
| 1580 | ), | ||
| 1581 | Stream.runHead, | ||
| 606 | ), | 1582 | ), |
| 607 | Stream.runHead, | ||
| 608 | ); | 1583 | ); |
| 1584 | if (pending.type !== "runtime_request.updated") { | ||
| 1585 | return yield* Effect.die("Expected a pending elicitation request"); | ||
| 1586 | } | ||
| 1587 | const responseFiber = yield* runtime | ||
| 1588 | .respondToRuntimeRequest({ | ||
| 1589 | requestId: pending.runtimeRequest.id, | ||
| 1590 | answers: { approved: ["true"] }, | ||
| 1591 | }) | ||
| 1592 | .pipe(Effect.forkScoped); | ||
| 1593 | |||
| 1594 | yield* Deferred.await(responseWritten); | ||
| 1595 | assert.isUndefined(responseFiber.pollUnsafe()); | ||
| 1596 | yield* Deferred.succeed(releaseResponseAcknowledgement, undefined); | ||
| 1597 | yield* Fiber.join(responseFiber); | ||
| 609 | }).pipe(Effect.provide(testLayer), Effect.scoped), | 1598 | }).pipe(Effect.provide(testLayer), Effect.scoped), |
| 610 | ); | 1599 | ); |
| 1600 | |||
| 1601 | it.live("fails a held native response acknowledgement before normal session close", () => | ||
| 1602 | Effect.gen(function* () { | ||
| 1603 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 1604 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1605 | const idAllocator = yield* IdAllocatorV2; | ||
| 1606 | const path = yield* Path.Path; | ||
| 1607 | const serverConfig = yield* ServerConfig; | ||
| 1608 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 1609 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 1610 | ); | ||
| 1611 | const responseWritten = yield* Deferred.make<void>(); | ||
| 1612 | const releaseResponseAcknowledgement = yield* Deferred.make<void>(); | ||
| 1613 | const responseLifecycle: Array<string> = []; | ||
| 1614 | const instanceId = ProviderInstanceId.make("acp-test-normal-close-held-response"); | ||
| 1615 | const adapter = makeAcpAdapterV2({ | ||
| 1616 | instanceId, | ||
| 1617 | flavor: { | ||
| 1618 | driver: ACP_TEST_DRIVER, | ||
| 1619 | capabilities: AcpProviderCapabilitiesV2, | ||
| 1620 | makeRuntime: makeMockRuntime({ | ||
| 1621 | childProcessSpawner, | ||
| 1622 | mockAgentPath, | ||
| 1623 | environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, | ||
| 1624 | wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => | ||
| 1625 | Deferred.succeed(responseWritten, undefined).pipe( | ||
| 1626 | Effect.andThen(Deferred.await(releaseResponseAcknowledgement)), | ||
| 1627 | Effect.andThen(onOutgoingResponse(requestId)), | ||
| 1628 | ), | ||
| 1629 | }), | ||
| 1630 | }, | ||
| 1631 | fileSystem, | ||
| 1632 | idAllocator, | ||
| 1633 | serverConfig, | ||
| 1634 | testHooks: { | ||
| 1635 | onNativeResponseLifecycle: (event) => | ||
| 1636 | Effect.sync(() => { | ||
| 1637 | responseLifecycle.push(event.type); | ||
| 1638 | }), | ||
| 1639 | }, | ||
| 1640 | }); | ||
| 1641 | const threadId = ThreadId.make("thread-acp-normal-close-held-response"); | ||
| 1642 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 1643 | runtimeMode: "approval-required", | ||
| 1644 | interactionMode: "default", | ||
| 1645 | cwd: process.cwd(), | ||
| 1646 | }); | ||
| 1647 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 1648 | const sessionScope = yield* Scope.make(); | ||
| 1649 | const runtime = yield* adapter | ||
| 1650 | .openSession({ | ||
| 1651 | threadId, | ||
| 1652 | providerSessionId: ProviderSessionId.make( | ||
| 1653 | "provider-session-acp-normal-close-held-response", | ||
| 1654 | ), | ||
| 1655 | modelSelection, | ||
| 1656 | runtimePolicy, | ||
| 1657 | }) | ||
| 1658 | .pipe(Effect.provideService(Scope.Scope, sessionScope)); | ||
| 1659 | const providerThread = yield* runtime.ensureThread({ | ||
| 1660 | threadId, | ||
| 1661 | modelSelection, | ||
| 1662 | runtimePolicy, | ||
| 1663 | }); | ||
| 1664 | yield* runtime.startTurn( | ||
| 1665 | makeTurnInput({ | ||
| 1666 | threadId, | ||
| 1667 | providerThread, | ||
| 1668 | instanceId, | ||
| 1669 | runtimePolicy, | ||
| 1670 | now: yield* DateTime.now, | ||
| 1671 | }), | ||
| 1672 | ); | ||
| 1673 | const pending = Option.getOrThrow( | ||
| 1674 | yield* runtime.events.pipe( | ||
| 1675 | Stream.filter( | ||
| 1676 | (event) => | ||
| 1677 | event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | ||
| 1678 | ), | ||
| 1679 | Stream.runHead, | ||
| 1680 | ), | ||
| 1681 | ); | ||
| 1682 | if (pending.type !== "runtime_request.updated") { | ||
| 1683 | return yield* Effect.die("Expected a pending permission request"); | ||
| 1684 | } | ||
| 1685 | const responseFiber = yield* runtime | ||
| 1686 | .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" }) | ||
| 1687 | .pipe(Effect.exit, Effect.forkScoped); | ||
| 1688 | yield* Deferred.await(responseWritten); | ||
| 1689 | |||
| 1690 | const closeFiber = yield* Scope.close(sessionScope, Exit.void).pipe(Effect.forkScoped); | ||
| 1691 | while (!responseLifecycle.includes("failed")) { | ||
| 1692 | yield* Effect.yieldNow; | ||
| 1693 | } | ||
| 1694 | const responseExit = yield* Fiber.join(responseFiber); | ||
| 1695 | if (Exit.isSuccess(responseExit)) { | ||
| 1696 | assert.fail("normal close must fail a response whose transport acknowledgement is held"); | ||
| 1697 | } | ||
| 1698 | assert.include(Cause.pretty(responseExit.cause), "ACP session transport closed"); | ||
| 1699 | assert.include(responseLifecycle, "removed"); | ||
| 1700 | assert.include(responseLifecycle, "failed"); | ||
| 1701 | yield* Deferred.succeed(releaseResponseAcknowledgement, undefined); | ||
| 1702 | yield* Fiber.join(closeFiber); | ||
| 1703 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 1704 | ); | ||
| 1705 | |||
| 1706 | it.live("rejects delayed native response registration when normal close wins the permit", () => | ||
| 1707 | Effect.gen(function* () { | ||
| 1708 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 1709 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1710 | const idAllocator = yield* IdAllocatorV2; | ||
| 1711 | const path = yield* Path.Path; | ||
| 1712 | const serverConfig = yield* ServerConfig; | ||
| 1713 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 1714 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 1715 | ); | ||
| 1716 | const registrationStarted = yield* Deferred.make<void>(); | ||
| 1717 | const releaseRegistration = yield* Deferred.make<void>(); | ||
| 1718 | const transportClosed = yield* Deferred.make<void>(); | ||
| 1719 | const releaseTransportClose = yield* Deferred.make<void>(); | ||
| 1720 | const responseLifecycle: Array<string> = []; | ||
| 1721 | const instanceId = ProviderInstanceId.make("acp-test-close-wins-registration"); | ||
| 1722 | const adapter = makeAcpAdapterV2({ | ||
| 1723 | instanceId, | ||
| 1724 | flavor: { | ||
| 1725 | driver: ACP_TEST_DRIVER, | ||
| 1726 | capabilities: AcpProviderCapabilitiesV2, | ||
| 1727 | makeRuntime: makeMockRuntime({ | ||
| 1728 | childProcessSpawner, | ||
| 1729 | mockAgentPath, | ||
| 1730 | environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, | ||
| 1731 | }), | ||
| 1732 | }, | ||
| 1733 | fileSystem, | ||
| 1734 | idAllocator, | ||
| 1735 | serverConfig, | ||
| 1736 | testHooks: { | ||
| 1737 | afterNativeResponseTransportClosed: () => | ||
| 1738 | Deferred.succeed(transportClosed, undefined).pipe( | ||
| 1739 | Effect.andThen(Deferred.await(releaseTransportClose)), | ||
| 1740 | ), | ||
| 1741 | beforeNativeResponseAdmissionCheck: () => | ||
| 1742 | Deferred.succeed(registrationStarted, undefined).pipe( | ||
| 1743 | Effect.andThen(Deferred.await(releaseRegistration)), | ||
| 1744 | ), | ||
| 1745 | onNativeResponseLifecycle: (event) => | ||
| 1746 | Effect.sync(() => { | ||
| 1747 | responseLifecycle.push(event.type); | ||
| 1748 | }), | ||
| 1749 | }, | ||
| 1750 | }); | ||
| 1751 | const threadId = ThreadId.make("thread-acp-close-wins-registration"); | ||
| 1752 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 1753 | runtimeMode: "approval-required", | ||
| 1754 | interactionMode: "default", | ||
| 1755 | cwd: process.cwd(), | ||
| 1756 | }); | ||
| 1757 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 1758 | const sessionScope = yield* Scope.make(); | ||
| 1759 | const runtime = yield* adapter | ||
| 1760 | .openSession({ | ||
| 1761 | threadId, | ||
| 1762 | providerSessionId: ProviderSessionId.make("provider-session-acp-close-wins-registration"), | ||
| 1763 | modelSelection, | ||
| 1764 | runtimePolicy, | ||
| 1765 | }) | ||
| 1766 | .pipe(Effect.provideService(Scope.Scope, sessionScope)); | ||
| 1767 | const providerThread = yield* runtime.ensureThread({ | ||
| 1768 | threadId, | ||
| 1769 | modelSelection, | ||
| 1770 | runtimePolicy, | ||
| 1771 | }); | ||
| 1772 | yield* runtime.startTurn( | ||
| 1773 | makeTurnInput({ | ||
| 1774 | threadId, | ||
| 1775 | providerThread, | ||
| 1776 | instanceId, | ||
| 1777 | runtimePolicy, | ||
| 1778 | now: yield* DateTime.now, | ||
| 1779 | }), | ||
| 1780 | ); | ||
| 1781 | const pending = Option.getOrThrow( | ||
| 1782 | yield* runtime.events.pipe( | ||
| 1783 | Stream.filter( | ||
| 1784 | (event) => | ||
| 1785 | event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | ||
| 1786 | ), | ||
| 1787 | Stream.runHead, | ||
| 1788 | ), | ||
| 1789 | ); | ||
| 1790 | if (pending.type !== "runtime_request.updated") { | ||
| 1791 | return yield* Effect.die("Expected a pending permission request"); | ||
| 1792 | } | ||
| 1793 | const responseFiber = yield* runtime | ||
| 1794 | .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" }) | ||
| 1795 | .pipe(Effect.exit, Effect.forkScoped); | ||
| 1796 | yield* Deferred.await(registrationStarted); | ||
| 1797 | |||
| 1798 | const closeFiber = yield* Scope.close(sessionScope, Exit.void).pipe(Effect.forkScoped); | ||
| 1799 | yield* Deferred.await(transportClosed); | ||
| 1800 | yield* Deferred.succeed(releaseRegistration, undefined); | ||
| 1801 | while (!responseLifecycle.includes("admission_rejected")) { | ||
| 1802 | yield* Effect.yieldNow; | ||
| 1803 | } | ||
| 1804 | const responseExit = yield* Fiber.join(responseFiber); | ||
| 1805 | if (Exit.isSuccess(responseExit)) { | ||
| 1806 | assert.fail("normal close must reject a response delayed before transport registration"); | ||
| 1807 | } | ||
| 1808 | assert.include(Cause.pretty(responseExit.cause), "ACP session transport closed"); | ||
| 1809 | assert.equal(responseLifecycle.filter((event) => event === "registered").length, 1); | ||
| 1810 | assert.equal(responseLifecycle.filter((event) => event === "removed").length, 1); | ||
| 1811 | assert.equal(responseLifecycle.filter((event) => event === "admission_rejected").length, 1); | ||
| 1812 | yield* Deferred.succeed(releaseTransportClose, undefined); | ||
| 1813 | yield* Fiber.join(closeFiber); | ||
| 1814 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 1815 | ); | ||
| 1816 | |||
| 1817 | it.live("bounds a missing pending permission response acknowledgement", () => | ||
| 1818 | Effect.gen(function* () { | ||
| 1819 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 1820 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1821 | const idAllocator = yield* IdAllocatorV2; | ||
| 1822 | const path = yield* Path.Path; | ||
| 1823 | const serverConfig = yield* ServerConfig; | ||
| 1824 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 1825 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 1826 | ); | ||
| 1827 | const responseEnqueued = yield* Deferred.make<void>(); | ||
| 1828 | const releaseNativeHook = yield* Deferred.make<void>(); | ||
| 1829 | const responseLifecycle: Array<string> = []; | ||
| 1830 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 1831 | const instanceId = ProviderInstanceId.make("acp-test-pending-response-timeout"); | ||
| 1832 | const adapter = makeAcpAdapterV2({ | ||
| 1833 | instanceId, | ||
| 1834 | flavor: { | ||
| 1835 | driver: ACP_TEST_DRIVER, | ||
| 1836 | capabilities: AcpProviderCapabilitiesV2, | ||
| 1837 | restartRuntimeAfterInterrupt: true, | ||
| 1838 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 1839 | makeRuntime: makeMockRuntime({ | ||
| 1840 | childProcessSpawner, | ||
| 1841 | mockAgentPath, | ||
| 1842 | environment: { | ||
| 1843 | T3_ACP_EMIT_TOOL_CALLS: "1", | ||
| 1844 | T3_ACP_HANG_AFTER_PERMISSION: "1", | ||
| 1845 | }, | ||
| 1846 | ownDetachedProcessGroup: true, | ||
| 1847 | processGroupPlatform: "win32", | ||
| 1848 | protocolEvents, | ||
| 1849 | windowsProcessTreeTerminator: (pid) => | ||
| 1850 | Deferred.succeed(releaseNativeHook, undefined).pipe( | ||
| 1851 | Effect.andThen( | ||
| 1852 | Effect.sync(() => { | ||
| 1853 | process.kill(pid, "SIGTERM"); | ||
| 1854 | }), | ||
| 1855 | ), | ||
| 1856 | ), | ||
| 1857 | wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => | ||
| 1858 | Deferred.succeed(responseEnqueued, undefined).pipe( | ||
| 1859 | Effect.andThen(Deferred.await(releaseNativeHook)), | ||
| 1860 | Effect.andThen(onOutgoingResponse(requestId)), | ||
| 1861 | ), | ||
| 1862 | }), | ||
| 1863 | }, | ||
| 1864 | fileSystem, | ||
| 1865 | idAllocator, | ||
| 1866 | serverConfig, | ||
| 1867 | testHooks: { | ||
| 1868 | onNativeResponseLifecycle: (event) => | ||
| 1869 | Effect.sync(() => { | ||
| 1870 | responseLifecycle.push(event.type); | ||
| 1871 | }), | ||
| 1872 | }, | ||
| 1873 | }); | ||
| 1874 | const threadId = ThreadId.make("thread-acp-pending-response-timeout"); | ||
| 1875 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 1876 | runtimeMode: "approval-required", | ||
| 1877 | interactionMode: "default", | ||
| 1878 | cwd: process.cwd(), | ||
| 1879 | }); | ||
| 1880 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 1881 | const sessionScope = yield* Scope.make(); | ||
| 1882 | const runtime = yield* adapter | ||
| 1883 | .openSession({ | ||
| 1884 | threadId, | ||
| 1885 | providerSessionId: ProviderSessionId.make( | ||
| 1886 | "provider-session-acp-pending-response-timeout", | ||
| 1887 | ), | ||
| 1888 | modelSelection, | ||
| 1889 | runtimePolicy, | ||
| 1890 | }) | ||
| 1891 | .pipe(Effect.provideService(Scope.Scope, sessionScope)); | ||
| 1892 | yield* pollProtocolMethods(protocolEvents); | ||
| 1893 | const providerThread = yield* runtime.ensureThread({ | ||
| 1894 | threadId, | ||
| 1895 | modelSelection, | ||
| 1896 | runtimePolicy, | ||
| 1897 | }); | ||
| 1898 | yield* runtime.startTurn( | ||
| 1899 | makeTurnInput({ | ||
| 1900 | threadId, | ||
| 1901 | providerThread, | ||
| 1902 | instanceId, | ||
| 1903 | runtimePolicy, | ||
| 1904 | now: yield* DateTime.now, | ||
| 1905 | }), | ||
| 1906 | ); | ||
| 1907 | const pending = Option.getOrThrow( | ||
| 1908 | yield* runtime.events.pipe( | ||
| 1909 | Stream.filter( | ||
| 1910 | (event) => | ||
| 1911 | event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | ||
| 1912 | ), | ||
| 1913 | Stream.runHead, | ||
| 1914 | ), | ||
| 1915 | ); | ||
| 1916 | if ( | ||
| 1917 | pending.type !== "runtime_request.updated" || | ||
| 1918 | pending.runtimeRequest.providerTurnId === null | ||
| 1919 | ) { | ||
| 1920 | return yield* Effect.die("Expected a pending permission request"); | ||
| 1921 | } | ||
| 1922 | const responseExit = yield* runtime | ||
| 1923 | .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" }) | ||
| 1924 | .pipe(Effect.exit); | ||
| 1925 | if (Exit.isSuccess(responseExit)) { | ||
| 1926 | assert.fail("missing native response acknowledgement must fail the pending response"); | ||
| 1927 | } | ||
| 1928 | assert.include(Cause.pretty(responseExit.cause), "Native response acknowledgement timed out"); | ||
| 1929 | assert.isTrue(yield* Deferred.isDone(responseEnqueued)); | ||
| 1930 | assert.isFalse(yield* Deferred.isDone(releaseNativeHook)); | ||
| 1931 | assert.isBelow(responseLifecycle.indexOf("removed"), responseLifecycle.indexOf("failed")); | ||
| 1932 | assert.includeMembers(responseLifecycle, [ | ||
| 1933 | "registered", | ||
| 1934 | "removed", | ||
| 1935 | "failed", | ||
| 1936 | "timer_exited", | ||
| 1937 | "timer_started", | ||
| 1938 | "watcher_exited", | ||
| 1939 | "watcher_started", | ||
| 1940 | ]); | ||
| 1941 | |||
| 1942 | yield* runtime.interruptTurn({ | ||
| 1943 | providerThread, | ||
| 1944 | providerTurnId: pending.runtimeRequest.providerTurnId, | ||
| 1945 | requestRuntimeRestart: true, | ||
| 1946 | }); | ||
| 1947 | assert.isTrue(yield* Deferred.isDone(releaseNativeHook)); | ||
| 1948 | yield* Effect.sleep("50 millis"); | ||
| 1949 | assert.include(responseLifecycle, "late_noop"); | ||
| 1950 | yield* Scope.close(sessionScope, Exit.void); | ||
| 1951 | assert.notInclude(yield* pollProtocolMethods(protocolEvents), "session/close"); | ||
| 1952 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 1953 | ); | ||
| 1954 | |||
| 1955 | it.live("defers caller cancellation until a pending response acknowledgement is bounded", () => | ||
| 1956 | Effect.gen(function* () { | ||
| 1957 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 1958 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 1959 | const idAllocator = yield* IdAllocatorV2; | ||
| 1960 | const path = yield* Path.Path; | ||
| 1961 | const serverConfig = yield* ServerConfig; | ||
| 1962 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 1963 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 1964 | ); | ||
| 1965 | const responseEnqueued = yield* Deferred.make<void>(); | ||
| 1966 | const releaseNativeHook = yield* Deferred.make<void>(); | ||
| 1967 | const instanceId = ProviderInstanceId.make("acp-test-pending-response-cancel"); | ||
| 1968 | const adapter = makeAcpAdapterV2({ | ||
| 1969 | instanceId, | ||
| 1970 | flavor: { | ||
| 1971 | driver: ACP_TEST_DRIVER, | ||
| 1972 | capabilities: AcpProviderCapabilitiesV2, | ||
| 1973 | restartRuntimeAfterInterrupt: true, | ||
| 1974 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 1975 | makeRuntime: makeMockRuntime({ | ||
| 1976 | childProcessSpawner, | ||
| 1977 | mockAgentPath, | ||
| 1978 | environment: { | ||
| 1979 | T3_ACP_EMIT_TOOL_CALLS: "1", | ||
| 1980 | T3_ACP_HANG_AFTER_PERMISSION: "1", | ||
| 1981 | }, | ||
| 1982 | ownDetachedProcessGroup: true, | ||
| 1983 | processGroupPlatform: "win32", | ||
| 1984 | windowsProcessTreeTerminator: (pid) => | ||
| 1985 | Deferred.succeed(releaseNativeHook, undefined).pipe( | ||
| 1986 | Effect.andThen( | ||
| 1987 | Effect.sync(() => { | ||
| 1988 | process.kill(pid, "SIGTERM"); | ||
| 1989 | }), | ||
| 1990 | ), | ||
| 1991 | ), | ||
| 1992 | wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => | ||
| 1993 | Deferred.succeed(responseEnqueued, undefined).pipe( | ||
| 1994 | Effect.andThen(Deferred.await(releaseNativeHook)), | ||
| 1995 | Effect.andThen(onOutgoingResponse(requestId)), | ||
| 1996 | ), | ||
| 1997 | }), | ||
| 1998 | }, | ||
| 1999 | fileSystem, | ||
| 2000 | idAllocator, | ||
| 2001 | serverConfig, | ||
| 2002 | }); | ||
| 2003 | const threadId = ThreadId.make("thread-acp-pending-response-cancel"); | ||
| 2004 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 2005 | runtimeMode: "approval-required", | ||
| 2006 | interactionMode: "default", | ||
| 2007 | cwd: process.cwd(), | ||
| 2008 | }); | ||
| 2009 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 2010 | const runtime = yield* adapter.openSession({ | ||
| 2011 | threadId, | ||
| 2012 | providerSessionId: ProviderSessionId.make("provider-session-acp-pending-response-cancel"), | ||
| 2013 | modelSelection, | ||
| 2014 | runtimePolicy, | ||
| 2015 | }); | ||
| 2016 | const providerThread = yield* runtime.ensureThread({ | ||
| 2017 | threadId, | ||
| 2018 | modelSelection, | ||
| 2019 | runtimePolicy, | ||
| 2020 | }); | ||
| 2021 | yield* runtime.startTurn( | ||
| 2022 | makeTurnInput({ | ||
| 2023 | threadId, | ||
| 2024 | providerThread, | ||
| 2025 | instanceId, | ||
| 2026 | runtimePolicy, | ||
| 2027 | now: yield* DateTime.now, | ||
| 2028 | }), | ||
| 2029 | ); | ||
| 2030 | const pending = Option.getOrThrow( | ||
| 2031 | yield* runtime.events.pipe( | ||
| 2032 | Stream.filter( | ||
| 2033 | (event) => | ||
| 2034 | event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | ||
| 2035 | ), | ||
| 2036 | Stream.runHead, | ||
| 2037 | ), | ||
| 2038 | ); | ||
| 2039 | if ( | ||
| 2040 | pending.type !== "runtime_request.updated" || | ||
| 2041 | pending.runtimeRequest.providerTurnId === null | ||
| 2042 | ) { | ||
| 2043 | return yield* Effect.die("Expected a pending permission request"); | ||
| 2044 | } | ||
| 2045 | const responseFiber = yield* runtime | ||
| 2046 | .respondToRuntimeRequest({ requestId: pending.runtimeRequest.id, decision: "accept" }) | ||
| 2047 | .pipe(Effect.forkScoped); | ||
| 2048 | yield* Deferred.await(responseEnqueued); | ||
| 2049 | const cancellationFiber = yield* Fiber.interrupt(responseFiber).pipe(Effect.forkScoped); | ||
| 2050 | const interruptFiber = yield* runtime | ||
| 2051 | .interruptTurn({ | ||
| 2052 | providerThread, | ||
| 2053 | providerTurnId: pending.runtimeRequest.providerTurnId, | ||
| 2054 | requestRuntimeRestart: true, | ||
| 2055 | }) | ||
| 2056 | .pipe(Effect.forkScoped); | ||
| 2057 | yield* Effect.sleep("100 millis"); | ||
| 2058 | assert.isUndefined(cancellationFiber.pollUnsafe()); | ||
| 2059 | assert.isUndefined(interruptFiber.pollUnsafe()); | ||
| 2060 | assert.isFalse(yield* Deferred.isDone(releaseNativeHook)); | ||
| 2061 | |||
| 2062 | yield* Fiber.join(cancellationFiber); | ||
| 2063 | yield* Fiber.join(interruptFiber); | ||
| 2064 | assert.isTrue(yield* Deferred.isDone(releaseNativeHook)); | ||
| 2065 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 2066 | ); | ||
| 2067 | |||
| 2068 | it.live("waits for immediate allow and deny permission responses before hard teardown", () => | ||
| 2069 | Effect.gen(function* () { | ||
| 2070 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 2071 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 2072 | const idAllocator = yield* IdAllocatorV2; | ||
| 2073 | const path = yield* Path.Path; | ||
| 2074 | const serverConfig = yield* ServerConfig; | ||
| 2075 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 2076 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 2077 | ); | ||
| 2078 | |||
| 2079 | for (const [name, sandboxPolicy] of [ | ||
| 2080 | ["allow", undefined], | ||
| 2081 | ["deny", { type: "readOnly" } as const], | ||
| 2082 | ] as const) { | ||
| 2083 | yield* Effect.gen(function* () { | ||
| 2084 | const responseEnqueued = yield* Deferred.make<void>(); | ||
| 2085 | const releaseResponseAcknowledgement = yield* Deferred.make<void>(); | ||
| 2086 | const instanceId = ProviderInstanceId.make(`acp-test-${name}`); | ||
| 2087 | const adapter = makeAcpAdapterV2({ | ||
| 2088 | instanceId, | ||
| 2089 | flavor: { | ||
| 2090 | driver: ACP_TEST_DRIVER, | ||
| 2091 | capabilities: AcpProviderCapabilitiesV2, | ||
| 2092 | restartRuntimeAfterInterrupt: true, | ||
| 2093 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 2094 | makeRuntime: makeMockRuntime({ | ||
| 2095 | childProcessSpawner, | ||
| 2096 | mockAgentPath, | ||
| 2097 | environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, | ||
| 2098 | ownDetachedProcessGroup: true, | ||
| 2099 | processGroupPlatform: "win32", | ||
| 2100 | windowsProcessTreeTerminator: (pid) => | ||
| 2101 | Effect.sync(() => { | ||
| 2102 | process.kill(pid, "SIGTERM"); | ||
| 2103 | }), | ||
| 2104 | wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => | ||
| 2105 | Deferred.succeed(responseEnqueued, undefined).pipe( | ||
| 2106 | Effect.andThen(Deferred.await(releaseResponseAcknowledgement)), | ||
| 2107 | Effect.andThen(onOutgoingResponse(requestId)), | ||
| 2108 | ), | ||
| 2109 | }), | ||
| 2110 | }, | ||
| 2111 | fileSystem, | ||
| 2112 | idAllocator, | ||
| 2113 | serverConfig, | ||
| 2114 | }); | ||
| 2115 | const threadId = ThreadId.make(`thread-acp-immediate-permission-${name}`); | ||
| 2116 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 2117 | runtimeMode: "full-access", | ||
| 2118 | interactionMode: "default", | ||
| 2119 | approvalPolicy: "never", | ||
| 2120 | cwd: process.cwd(), | ||
| 2121 | ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }), | ||
| 2122 | }); | ||
| 2123 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 2124 | const runtime = yield* adapter.openSession({ | ||
| 2125 | threadId, | ||
| 2126 | providerSessionId: ProviderSessionId.make( | ||
| 2127 | `provider-session-acp-immediate-permission-${name}`, | ||
| 2128 | ), | ||
| 2129 | modelSelection, | ||
| 2130 | runtimePolicy, | ||
| 2131 | }); | ||
| 2132 | const providerThread = yield* runtime.ensureThread({ | ||
| 2133 | threadId, | ||
| 2134 | modelSelection, | ||
| 2135 | runtimePolicy, | ||
| 2136 | }); | ||
| 2137 | const turnFiber = yield* runtime | ||
| 2138 | .startTurn( | ||
| 2139 | makeTurnInput({ | ||
| 2140 | threadId, | ||
| 2141 | providerThread, | ||
| 2142 | instanceId, | ||
| 2143 | runtimePolicy, | ||
| 2144 | now: yield* DateTime.now, | ||
| 2145 | }), | ||
| 2146 | ) | ||
| 2147 | .pipe(Effect.forkDetach); | ||
| 2148 | yield* Deferred.await(responseEnqueued); | ||
| 2149 | const interruptFiber = yield* runtime | ||
| 2150 | .interruptTurn({ | ||
| 2151 | providerThread, | ||
| 2152 | providerTurnId: idAllocator.derive.providerTurn({ | ||
| 2153 | driver: ACP_TEST_DRIVER, | ||
| 2154 | nativeTurnId: "mock-session-1:turn:1", | ||
| 2155 | }), | ||
| 2156 | requestRuntimeRestart: true, | ||
| 2157 | }) | ||
| 2158 | .pipe(Effect.forkScoped); | ||
| 2159 | yield* Effect.yieldNow; | ||
| 2160 | assert.isUndefined(interruptFiber.pollUnsafe()); | ||
| 2161 | |||
| 2162 | yield* Deferred.succeed(releaseResponseAcknowledgement, undefined); | ||
| 2163 | yield* Fiber.join(interruptFiber); | ||
| 2164 | yield* Fiber.interrupt(turnFiber).pipe(Effect.forkDetach); | ||
| 2165 | }).pipe(Effect.scoped); | ||
| 2166 | } | ||
| 2167 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 2168 | ); | ||
| 2169 | |||
| 2170 | it.live("waits for immediate URL elicitation responses before hard teardown", () => | ||
| 2171 | Effect.gen(function* () { | ||
| 2172 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 2173 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 2174 | const idAllocator = yield* IdAllocatorV2; | ||
| 2175 | const path = yield* Path.Path; | ||
| 2176 | const serverConfig = yield* ServerConfig; | ||
| 2177 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 2178 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 2179 | ); | ||
| 2180 | const responseEnqueued = yield* Deferred.make<void>(); | ||
| 2181 | const releaseResponseAcknowledgement = yield* Deferred.make<void>(); | ||
| 2182 | const instanceId = ProviderInstanceId.make("acp-test-url-elicitation"); | ||
| 2183 | const adapter = makeAcpAdapterV2({ | ||
| 2184 | instanceId, | ||
| 2185 | flavor: { | ||
| 2186 | driver: ACP_TEST_DRIVER, | ||
| 2187 | capabilities: AcpProviderCapabilitiesV2, | ||
| 2188 | restartRuntimeAfterInterrupt: true, | ||
| 2189 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 2190 | makeRuntime: makeMockRuntime({ | ||
| 2191 | childProcessSpawner, | ||
| 2192 | mockAgentPath, | ||
| 2193 | environment: { T3_ACP_EMIT_URL_ELICITATION: "1" }, | ||
| 2194 | ownDetachedProcessGroup: true, | ||
| 2195 | processGroupPlatform: "win32", | ||
| 2196 | windowsProcessTreeTerminator: (pid) => | ||
| 2197 | Effect.sync(() => { | ||
| 2198 | process.kill(pid, "SIGTERM"); | ||
| 2199 | }), | ||
| 2200 | wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => | ||
| 2201 | Deferred.succeed(responseEnqueued, undefined).pipe( | ||
| 2202 | Effect.andThen(Deferred.await(releaseResponseAcknowledgement)), | ||
| 2203 | Effect.andThen(onOutgoingResponse(requestId)), | ||
| 2204 | ), | ||
| 2205 | }), | ||
| 2206 | }, | ||
| 2207 | fileSystem, | ||
| 2208 | idAllocator, | ||
| 2209 | serverConfig, | ||
| 2210 | }); | ||
| 2211 | const threadId = ThreadId.make("thread-acp-immediate-url-elicitation"); | ||
| 2212 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 2213 | runtimeMode: "full-access", | ||
| 2214 | interactionMode: "default", | ||
| 2215 | cwd: process.cwd(), | ||
| 2216 | }); | ||
| 2217 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 2218 | const runtime = yield* adapter.openSession({ | ||
| 2219 | threadId, | ||
| 2220 | providerSessionId: ProviderSessionId.make("provider-session-acp-immediate-url-elicitation"), | ||
| 2221 | modelSelection, | ||
| 2222 | runtimePolicy, | ||
| 2223 | }); | ||
| 2224 | const providerThread = yield* runtime.ensureThread({ | ||
| 2225 | threadId, | ||
| 2226 | modelSelection, | ||
| 2227 | runtimePolicy, | ||
| 2228 | }); | ||
| 2229 | const turnFiber = yield* runtime | ||
| 2230 | .startTurn( | ||
| 2231 | makeTurnInput({ | ||
| 2232 | threadId, | ||
| 2233 | providerThread, | ||
| 2234 | instanceId, | ||
| 2235 | runtimePolicy, | ||
| 2236 | now: yield* DateTime.now, | ||
| 2237 | }), | ||
| 2238 | ) | ||
| 2239 | .pipe(Effect.forkDetach); | ||
| 2240 | yield* Deferred.await(responseEnqueued); | ||
| 2241 | const interruptFiber = yield* runtime | ||
| 2242 | .interruptTurn({ | ||
| 2243 | providerThread, | ||
| 2244 | providerTurnId: idAllocator.derive.providerTurn({ | ||
| 2245 | driver: ACP_TEST_DRIVER, | ||
| 2246 | nativeTurnId: "mock-session-1:turn:1", | ||
| 2247 | }), | ||
| 2248 | requestRuntimeRestart: true, | ||
| 2249 | }) | ||
| 2250 | .pipe(Effect.forkScoped); | ||
| 2251 | yield* Effect.yieldNow; | ||
| 2252 | assert.isUndefined(interruptFiber.pollUnsafe()); | ||
| 2253 | |||
| 2254 | yield* Deferred.succeed(releaseResponseAcknowledgement, undefined); | ||
| 2255 | yield* Fiber.join(interruptFiber); | ||
| 2256 | yield* Fiber.interrupt(turnFiber).pipe(Effect.forkDetach); | ||
| 2257 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 2258 | ); | ||
| 2259 | |||
| 2260 | it.live("bounds a missing immediate response acknowledgement before hard teardown", () => | ||
| 2261 | Effect.gen(function* () { | ||
| 2262 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 2263 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 2264 | const idAllocator = yield* IdAllocatorV2; | ||
| 2265 | const path = yield* Path.Path; | ||
| 2266 | const serverConfig = yield* ServerConfig; | ||
| 2267 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 2268 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 2269 | ); | ||
| 2270 | const responseEnqueued = yield* Deferred.make<void>(); | ||
| 2271 | const releaseNativeHook = yield* Deferred.make<void>(); | ||
| 2272 | const instanceId = ProviderInstanceId.make("acp-test-missing-response-ack"); | ||
| 2273 | const adapter = makeAcpAdapterV2({ | ||
| 2274 | instanceId, | ||
| 2275 | flavor: { | ||
| 2276 | driver: ACP_TEST_DRIVER, | ||
| 2277 | capabilities: AcpProviderCapabilitiesV2, | ||
| 2278 | restartRuntimeAfterInterrupt: true, | ||
| 2279 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 2280 | makeRuntime: makeMockRuntime({ | ||
| 2281 | childProcessSpawner, | ||
| 2282 | mockAgentPath, | ||
| 2283 | environment: { T3_ACP_EMIT_TOOL_CALLS: "1" }, | ||
| 2284 | ownDetachedProcessGroup: true, | ||
| 2285 | processGroupPlatform: "win32", | ||
| 2286 | windowsProcessTreeTerminator: (pid) => | ||
| 2287 | Deferred.succeed(releaseNativeHook, undefined).pipe( | ||
| 2288 | Effect.andThen( | ||
| 2289 | Effect.sync(() => { | ||
| 2290 | process.kill(pid, "SIGTERM"); | ||
| 2291 | }), | ||
| 2292 | ), | ||
| 2293 | ), | ||
| 2294 | wrapOutgoingResponse: (onOutgoingResponse) => (requestId) => | ||
| 2295 | Deferred.succeed(responseEnqueued, undefined).pipe( | ||
| 2296 | Effect.andThen(Deferred.await(releaseNativeHook)), | ||
| 2297 | Effect.andThen(onOutgoingResponse(requestId)), | ||
| 2298 | ), | ||
| 2299 | }), | ||
| 2300 | }, | ||
| 2301 | fileSystem, | ||
| 2302 | idAllocator, | ||
| 2303 | serverConfig, | ||
| 2304 | }); | ||
| 2305 | const threadId = ThreadId.make("thread-acp-missing-response-ack"); | ||
| 2306 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 2307 | runtimeMode: "full-access", | ||
| 2308 | interactionMode: "default", | ||
| 2309 | approvalPolicy: "never", | ||
| 2310 | cwd: process.cwd(), | ||
| 2311 | }); | ||
| 2312 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 2313 | const runtime = yield* adapter.openSession({ | ||
| 2314 | threadId, | ||
| 2315 | providerSessionId: ProviderSessionId.make("provider-session-acp-missing-response-ack"), | ||
| 2316 | modelSelection, | ||
| 2317 | runtimePolicy, | ||
| 2318 | }); | ||
| 2319 | const providerThread = yield* runtime.ensureThread({ | ||
| 2320 | threadId, | ||
| 2321 | modelSelection, | ||
| 2322 | runtimePolicy, | ||
| 2323 | }); | ||
| 2324 | const turnFiber = yield* runtime | ||
| 2325 | .startTurn( | ||
| 2326 | makeTurnInput({ | ||
| 2327 | threadId, | ||
| 2328 | providerThread, | ||
| 2329 | instanceId, | ||
| 2330 | runtimePolicy, | ||
| 2331 | now: yield* DateTime.now, | ||
| 2332 | }), | ||
| 2333 | ) | ||
| 2334 | .pipe(Effect.forkDetach); | ||
| 2335 | yield* Deferred.await(responseEnqueued); | ||
| 2336 | const startedAt = yield* Clock.currentTimeMillis; | ||
| 2337 | yield* runtime.interruptTurn({ | ||
| 2338 | providerThread, | ||
| 2339 | providerTurnId: idAllocator.derive.providerTurn({ | ||
| 2340 | driver: ACP_TEST_DRIVER, | ||
| 2341 | nativeTurnId: "mock-session-1:turn:1", | ||
| 2342 | }), | ||
| 2343 | requestRuntimeRestart: true, | ||
| 2344 | }); | ||
| 2345 | assert.isAtLeast((yield* Clock.currentTimeMillis) - startedAt, 1_500); | ||
| 2346 | yield* Fiber.interrupt(turnFiber).pipe(Effect.forkDetach); | ||
| 2347 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 2348 | ); | ||
| 2349 | |||
| 2350 | it.live("rejects an elicitation response when hard teardown wins admission", () => | ||
| 2351 | Effect.gen(function* () { | ||
| 2352 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 2353 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 2354 | const idAllocator = yield* IdAllocatorV2; | ||
| 2355 | const path = yield* Path.Path; | ||
| 2356 | const serverConfig = yield* ServerConfig; | ||
| 2357 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 2358 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 2359 | ); | ||
| 2360 | const teardownStarted = yield* Deferred.make<void>(); | ||
| 2361 | const releaseTeardown = yield* Deferred.make<void>(); | ||
| 2362 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 2363 | const adapter = makeAcpAdapterV2({ | ||
| 2364 | instanceId, | ||
| 2365 | flavor: { | ||
| 2366 | driver: ACP_TEST_DRIVER, | ||
| 2367 | capabilities: AcpProviderCapabilitiesV2, | ||
| 2368 | restartRuntimeAfterInterrupt: true, | ||
| 2369 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 2370 | makeRuntime: makeMockRuntime({ | ||
| 2371 | childProcessSpawner, | ||
| 2372 | mockAgentPath, | ||
| 2373 | environment: { T3_ACP_EMIT_ELICITATION: "1" }, | ||
| 2374 | ownDetachedProcessGroup: true, | ||
| 2375 | processGroupPlatform: "win32", | ||
| 2376 | windowsProcessTreeTerminator: (pid) => | ||
| 2377 | Deferred.succeed(teardownStarted, undefined).pipe( | ||
| 2378 | Effect.andThen(Deferred.await(releaseTeardown)), | ||
| 2379 | Effect.andThen( | ||
| 2380 | Effect.sync(() => { | ||
| 2381 | process.kill(pid, "SIGTERM"); | ||
| 2382 | }), | ||
| 2383 | ), | ||
| 2384 | ), | ||
| 2385 | }), | ||
| 2386 | }, | ||
| 2387 | fileSystem, | ||
| 2388 | idAllocator, | ||
| 2389 | serverConfig, | ||
| 2390 | }); | ||
| 2391 | const threadId = ThreadId.make("thread-acp-teardown-wins-elicitation"); | ||
| 2392 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 2393 | runtimeMode: "approval-required", | ||
| 2394 | interactionMode: "default", | ||
| 2395 | cwd: process.cwd(), | ||
| 2396 | }); | ||
| 2397 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 2398 | const runtime = yield* adapter.openSession({ | ||
| 2399 | threadId, | ||
| 2400 | providerSessionId: ProviderSessionId.make("provider-session-acp-teardown-wins-elicitation"), | ||
| 2401 | modelSelection, | ||
| 2402 | runtimePolicy, | ||
| 2403 | }); | ||
| 2404 | const providerThread = yield* runtime.ensureThread({ | ||
| 2405 | threadId, | ||
| 2406 | modelSelection, | ||
| 2407 | runtimePolicy, | ||
| 2408 | }); | ||
| 2409 | yield* runtime.startTurn( | ||
| 2410 | makeTurnInput({ | ||
| 2411 | threadId, | ||
| 2412 | providerThread, | ||
| 2413 | instanceId, | ||
| 2414 | runtimePolicy, | ||
| 2415 | now: yield* DateTime.now, | ||
| 2416 | }), | ||
| 2417 | ); | ||
| 2418 | const pending = Option.getOrThrow( | ||
| 2419 | yield* runtime.events.pipe( | ||
| 2420 | Stream.filter( | ||
| 2421 | (event) => | ||
| 2422 | event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", | ||
| 2423 | ), | ||
| 2424 | Stream.runHead, | ||
| 2425 | ), | ||
| 2426 | ); | ||
| 2427 | if ( | ||
| 2428 | pending.type !== "runtime_request.updated" || | ||
| 2429 | pending.runtimeRequest.providerTurnId === null | ||
| 2430 | ) { | ||
| 2431 | return yield* Effect.die("Expected a pending elicitation request"); | ||
| 2432 | } | ||
| 2433 | const interruptFiber = yield* runtime | ||
| 2434 | .interruptTurn({ | ||
| 2435 | providerThread, | ||
| 2436 | providerTurnId: pending.runtimeRequest.providerTurnId, | ||
| 2437 | requestRuntimeRestart: true, | ||
| 2438 | }) | ||
| 2439 | .pipe(Effect.forkScoped); | ||
| 2440 | yield* Deferred.await(teardownStarted); | ||
| 2441 | const responseFiber = yield* runtime | ||
| 2442 | .respondToRuntimeRequest({ | ||
| 2443 | requestId: pending.runtimeRequest.id, | ||
| 2444 | answers: { approved: ["true"] }, | ||
| 2445 | }) | ||
| 2446 | .pipe(Effect.exit, Effect.forkScoped); | ||
| 2447 | yield* Effect.yieldNow; | ||
| 2448 | assert.isUndefined(responseFiber.pollUnsafe()); | ||
| 2449 | |||
| 2450 | yield* Deferred.succeed(releaseTeardown, undefined); | ||
| 2451 | yield* Fiber.join(interruptFiber); | ||
| 2452 | const responseExit = yield* Fiber.join(responseFiber); | ||
| 2453 | if (Exit.isSuccess(responseExit)) { | ||
| 2454 | assert.fail("teardown winning admission must reject the elicitation response"); | ||
| 2455 | } | ||
| 2456 | assert.include(Cause.pretty(responseExit.cause), "No pending ACP runtime request"); | ||
| 2457 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 2458 | ); | ||
| 2459 | |||
| 2460 | it.effect("releases an ACP turn when cancellation times out", () => | ||
| 2461 | Effect.gen(function* () { | ||
| 2462 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 2463 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 2464 | const idAllocator = yield* IdAllocatorV2; | ||
| 2465 | const path = yield* Path.Path; | ||
| 2466 | const serverConfig = yield* ServerConfig; | ||
| 2467 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 2468 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 2469 | ); | ||
| 2470 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 2471 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 2472 | const adapter = makeAcpAdapterV2({ | ||
| 2473 | instanceId, | ||
| 2474 | flavor: { | ||
| 2475 | driver: ACP_TEST_DRIVER, | ||
| 2476 | capabilities: AcpProviderCapabilitiesV2, | ||
| 2477 | makeRuntime: makeMockRuntime({ | ||
| 2478 | childProcessSpawner, | ||
| 2479 | mockAgentPath, | ||
| 2480 | environment: { T3_ACP_PROMPT_DELAY_MS: "5000" }, | ||
| 2481 | protocolEvents, | ||
| 2482 | }), | ||
| 2483 | }, | ||
| 2484 | fileSystem, | ||
| 2485 | idAllocator, | ||
| 2486 | serverConfig, | ||
| 2487 | }); | ||
| 2488 | const threadId = ThreadId.make("thread-acp-cancel-timeout"); | ||
| 2489 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 2490 | runtimeMode: "full-access", | ||
| 2491 | interactionMode: "default", | ||
| 2492 | cwd: process.cwd(), | ||
| 2493 | }); | ||
| 2494 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 2495 | const runtime = yield* adapter.openSession({ | ||
| 2496 | threadId, | ||
| 2497 | providerSessionId: ProviderSessionId.make("provider-session-acp-cancel-timeout"), | ||
| 2498 | modelSelection, | ||
| 2499 | runtimePolicy, | ||
| 2500 | }); | ||
| 2501 | const providerThread = yield* runtime.ensureThread({ | ||
| 2502 | threadId, | ||
| 2503 | modelSelection, | ||
| 2504 | runtimePolicy, | ||
| 2505 | }); | ||
| 2506 | const now = yield* DateTime.now; | ||
| 2507 | const firstTurn = makeTurnInput({ | ||
| 2508 | threadId, | ||
| 2509 | providerThread, | ||
| 2510 | instanceId, | ||
| 2511 | runtimePolicy, | ||
| 2512 | now, | ||
| 2513 | }); | ||
| 2514 | yield* runtime.startTurn(firstTurn); | ||
| 2515 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 2516 | Stream.filter( | ||
| 2517 | (event) => | ||
| 2518 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | ||
| 2519 | ), | ||
| 2520 | Stream.runHead, | ||
| 2521 | ); | ||
| 2522 | const providerTurnId = idAllocator.derive.providerTurn({ | ||
| 2523 | driver: ACP_TEST_DRIVER, | ||
| 2524 | nativeTurnId: "mock-session-1:turn:1", | ||
| 2525 | }); | ||
| 2526 | const interruptFiber = yield* runtime | ||
| 2527 | .interruptTurn({ providerThread, providerTurnId }) | ||
| 2528 | .pipe(Effect.flip, Effect.forkScoped); | ||
| 2529 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 2530 | Stream.filter( | ||
| 2531 | (event) => | ||
| 2532 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/cancel", | ||
| 2533 | ), | ||
| 2534 | Stream.runHead, | ||
| 2535 | ); | ||
| 2536 | yield* TestClock.adjust("10 seconds"); | ||
| 2537 | const interruptError = yield* Fiber.join(interruptFiber); | ||
| 2538 | assert.equal(interruptError._tag, "ProviderAdapterInterruptError"); | ||
| 2539 | |||
| 2540 | yield* runtime.startTurn( | ||
| 2541 | makeTurnInput({ | ||
| 2542 | threadId, | ||
| 2543 | providerThread, | ||
| 2544 | instanceId, | ||
| 2545 | runtimePolicy, | ||
| 2546 | now, | ||
| 2547 | ordinal: 2, | ||
| 2548 | }), | ||
| 2549 | ); | ||
| 2550 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 2551 | Stream.filter( | ||
| 2552 | (event) => | ||
| 2553 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | ||
| 2554 | ), | ||
| 2555 | Stream.runHead, | ||
| 2556 | ); | ||
| 2557 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 2558 | ); | ||
| 2559 | |||
| 2560 | it.live("treats a second hard Stop as success when the turn is already gone", () => | ||
| 2561 | Effect.gen(function* () { | ||
| 2562 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 2563 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 2564 | const idAllocator = yield* IdAllocatorV2; | ||
| 2565 | const path = yield* Path.Path; | ||
| 2566 | const serverConfig = yield* ServerConfig; | ||
| 2567 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 2568 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 2569 | ); | ||
| 2570 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 2571 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 2572 | const adapter = makeAcpAdapterV2({ | ||
| 2573 | instanceId, | ||
| 2574 | flavor: { | ||
| 2575 | driver: ACP_TEST_DRIVER, | ||
| 2576 | capabilities: AcpProviderCapabilitiesV2, | ||
| 2577 | interruptPromptOnCancel: false, | ||
| 2578 | restartRuntimeAfterInterrupt: true, | ||
| 2579 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 2580 | makeRuntime: makeMockRuntime({ | ||
| 2581 | childProcessSpawner, | ||
| 2582 | mockAgentPath, | ||
| 2583 | ownDetachedProcessGroup: true, | ||
| 2584 | environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, | ||
| 2585 | protocolEvents, | ||
| 2586 | }), | ||
| 2587 | }, | ||
| 2588 | fileSystem, | ||
| 2589 | idAllocator, | ||
| 2590 | serverConfig, | ||
| 2591 | }); | ||
| 2592 | const threadId = ThreadId.make("thread-acp-double-stop"); | ||
| 2593 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 2594 | runtimeMode: "full-access", | ||
| 2595 | interactionMode: "default", | ||
| 2596 | cwd: process.cwd(), | ||
| 2597 | }); | ||
| 2598 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 2599 | const runtime = yield* adapter.openSession({ | ||
| 2600 | threadId, | ||
| 2601 | providerSessionId: ProviderSessionId.make("provider-session-acp-double-stop"), | ||
| 2602 | modelSelection, | ||
| 2603 | runtimePolicy, | ||
| 2604 | }); | ||
| 2605 | const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | ||
| 2606 | yield* runtime.events.pipe( | ||
| 2607 | Stream.runForEach((event) => Queue.offer(events, event)), | ||
| 2608 | Effect.forkScoped, | ||
| 2609 | ); | ||
| 2610 | const providerThread = yield* runtime.ensureThread({ | ||
| 2611 | threadId, | ||
| 2612 | modelSelection, | ||
| 2613 | runtimePolicy, | ||
| 2614 | }); | ||
| 2615 | const now = yield* DateTime.now; | ||
| 2616 | yield* runtime | ||
| 2617 | .startTurn(makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now })) | ||
| 2618 | .pipe(Effect.forkScoped); | ||
| 2619 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 2620 | Stream.filter( | ||
| 2621 | (event) => | ||
| 2622 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | ||
| 2623 | ), | ||
| 2624 | Stream.runHead, | ||
| 2625 | ); | ||
| 2626 | const providerTurnId = idAllocator.derive.providerTurn({ | ||
| 2627 | driver: ACP_TEST_DRIVER, | ||
| 2628 | nativeTurnId: "mock-session-1:turn:1", | ||
| 2629 | }); | ||
| 2630 | yield* runtime.interruptTurn({ | ||
| 2631 | providerThread, | ||
| 2632 | providerTurnId, | ||
| 2633 | requestRuntimeRestart: true, | ||
| 2634 | }); | ||
| 2635 | // Second durable interrupt after activeTurn is cleared must not fail. | ||
| 2636 | const second = yield* Effect.exit( | ||
| 2637 | runtime.interruptTurn({ | ||
| 2638 | providerThread, | ||
| 2639 | providerTurnId, | ||
| 2640 | requestRuntimeRestart: true, | ||
| 2641 | }), | ||
| 2642 | ); | ||
| 2643 | assert.isTrue(Exit.isSuccess(second), "duplicate hard Stop must be idempotent"); | ||
| 2644 | let terminal: string | null = null; | ||
| 2645 | while (terminal === null) { | ||
| 2646 | const event = yield* Queue.take(events); | ||
| 2647 | if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { | ||
| 2648 | terminal = event.status; | ||
| 2649 | } | ||
| 2650 | } | ||
| 2651 | assert.equal(terminal, "interrupted"); | ||
| 2652 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 2653 | ); | ||
| 2654 | |||
| 2655 | it.effect("finalizes a settled turn held open for background work when interrupted", () => | ||
| 2656 | Effect.gen(function* () { | ||
| 2657 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 2658 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 2659 | const idAllocator = yield* IdAllocatorV2; | ||
| 2660 | const path = yield* Path.Path; | ||
| 2661 | const serverConfig = yield* ServerConfig; | ||
| 2662 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 2663 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 2664 | ); | ||
| 2665 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 2666 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 2667 | const adapter = makeAcpAdapterV2({ | ||
| 2668 | instanceId, | ||
| 2669 | flavor: { | ||
| 2670 | driver: ACP_TEST_DRIVER, | ||
| 2671 | capabilities: AcpProviderCapabilitiesV2, | ||
| 2672 | deferFinalizeForBackgroundWork: true, | ||
| 2673 | extractSubagentUpdate: (toolCall) => | ||
| 2674 | toolCall.toolCallId === "tool-call-generic-1" | ||
| 2675 | ? { | ||
| 2676 | nativeTaskId: "task-generic-1", | ||
| 2677 | prompt: "background subagent", | ||
| 2678 | title: "background subagent", | ||
| 2679 | model: null, | ||
| 2680 | status: "running", | ||
| 2681 | childSessionId: null, | ||
| 2682 | result: null, | ||
| 2683 | } | ||
| 2684 | : undefined, | ||
| 2685 | makeRuntime: makeMockRuntime({ | ||
| 2686 | childProcessSpawner, | ||
| 2687 | mockAgentPath, | ||
| 2688 | environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, | ||
| 2689 | protocolEvents, | ||
| 2690 | }), | ||
| 2691 | }, | ||
| 2692 | fileSystem, | ||
| 2693 | idAllocator, | ||
| 2694 | serverConfig, | ||
| 2695 | }); | ||
| 2696 | const threadId = ThreadId.make("thread-acp-interrupt-background-hold"); | ||
| 2697 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 2698 | runtimeMode: "full-access", | ||
| 2699 | interactionMode: "default", | ||
| 2700 | cwd: process.cwd(), | ||
| 2701 | }); | ||
| 2702 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 2703 | const runtime = yield* adapter.openSession({ | ||
| 2704 | threadId, | ||
| 2705 | providerSessionId: ProviderSessionId.make("provider-session-acp-interrupt-background"), | ||
| 2706 | modelSelection, | ||
| 2707 | runtimePolicy, | ||
| 2708 | }); | ||
| 2709 | const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | ||
| 2710 | yield* runtime.events.pipe( | ||
| 2711 | Stream.runForEach((event) => Queue.offer(events, event)), | ||
| 2712 | Effect.forkScoped, | ||
| 2713 | ); | ||
| 2714 | const providerThread = yield* runtime.ensureThread({ | ||
| 2715 | threadId, | ||
| 2716 | modelSelection, | ||
| 2717 | runtimePolicy, | ||
| 2718 | }); | ||
| 2719 | const now = yield* DateTime.now; | ||
| 2720 | yield* runtime.startTurn( | ||
| 2721 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | ||
| 2722 | ); | ||
| 2723 | // The still-running subagent defers finalize after session/prompt returns. | ||
| 2724 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 2725 | Stream.filter( | ||
| 2726 | (event) => | ||
| 2727 | event.direction === "incoming" && | ||
| 2728 | event.stage === "raw" && | ||
| 2729 | typeof event.payload === "string" && | ||
| 2730 | event.payload.includes('"stopReason"'), | ||
| 2731 | ), | ||
| 2732 | Stream.runHead, | ||
| 2733 | ); | ||
| 2734 | yield* Effect.yieldNow; | ||
| 2735 | yield* Effect.yieldNow; | ||
| 2736 | |||
| 2737 | const providerTurnId = idAllocator.derive.providerTurn({ | ||
| 2738 | driver: ACP_TEST_DRIVER, | ||
| 2739 | nativeTurnId: "mock-session-1:turn:1", | ||
| 2740 | }); | ||
| 2741 | const interruptFiber = yield* runtime | ||
| 2742 | .interruptTurn({ providerThread, providerTurnId }) | ||
| 2743 | .pipe(Effect.forkScoped); | ||
| 2744 | yield* TestClock.adjust("10 seconds"); | ||
| 2745 | yield* Fiber.join(interruptFiber); | ||
| 2746 | |||
| 2747 | let terminalStatus: string | null = null; | ||
| 2748 | while (terminalStatus === null) { | ||
| 2749 | const event = yield* Queue.take(events); | ||
| 2750 | if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { | ||
| 2751 | terminalStatus = event.status; | ||
| 2752 | } | ||
| 2753 | } | ||
| 2754 | assert.equal(terminalStatus, "interrupted"); | ||
| 2755 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 2756 | ); | ||
| 2757 | |||
| 2758 | it.effect( | ||
| 2759 | "carries a live subagent lineage across an interrupt so the next turn can complete it", | ||
| 2760 | () => | ||
| 2761 | Effect.gen(function* () { | ||
| 2762 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 2763 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 2764 | const idAllocator = yield* IdAllocatorV2; | ||
| 2765 | const path = yield* Path.Path; | ||
| 2766 | const serverConfig = yield* ServerConfig; | ||
| 2767 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 2768 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 2769 | ); | ||
| 2770 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 2771 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 2772 | let subagentPhase: "spawn" | "complete" = "spawn"; | ||
| 2773 | const adapter = makeAcpAdapterV2({ | ||
| 2774 | instanceId, | ||
| 2775 | flavor: { | ||
| 2776 | driver: ACP_TEST_DRIVER, | ||
| 2777 | capabilities: AcpProviderCapabilitiesV2, | ||
| 2778 | deferFinalizeForBackgroundWork: true, | ||
| 2779 | extractSubagentUpdate: (toolCall) => | ||
| 2780 | toolCall.toolCallId !== "tool-call-generic-1" | ||
| 2781 | ? undefined | ||
| 2782 | : subagentPhase === "spawn" | ||
| 2783 | ? { | ||
| 2784 | nativeTaskId: "task-generic-1", | ||
| 2785 | prompt: "background subagent", | ||
| 2786 | title: "background subagent", | ||
| 2787 | model: null, | ||
| 2788 | status: "running", | ||
| 2789 | childSessionId: null, | ||
| 2790 | result: null, | ||
| 2791 | } | ||
| 2792 | : // Hydration-only shape (empty prompt, null title): without a | ||
| 2793 | // carried-over lineage this update is dropped and the item | ||
| 2794 | // stays running forever. | ||
| 2795 | { | ||
| 2796 | nativeTaskId: "task-generic-1", | ||
| 2797 | prompt: "", | ||
| 2798 | title: null, | ||
| 2799 | model: null, | ||
| 2800 | status: "completed", | ||
| 2801 | childSessionId: null, | ||
| 2802 | result: "SUB_DONE", | ||
| 2803 | }, | ||
| 2804 | makeRuntime: makeMockRuntime({ | ||
| 2805 | childProcessSpawner, | ||
| 2806 | mockAgentPath, | ||
| 2807 | environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, | ||
| 2808 | protocolEvents, | ||
| 2809 | }), | ||
| 2810 | }, | ||
| 2811 | fileSystem, | ||
| 2812 | idAllocator, | ||
| 2813 | serverConfig, | ||
| 2814 | }); | ||
| 2815 | const threadId = ThreadId.make("thread-acp-subagent-carryover"); | ||
| 2816 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 2817 | runtimeMode: "full-access", | ||
| 2818 | interactionMode: "default", | ||
| 2819 | cwd: process.cwd(), | ||
| 2820 | }); | ||
| 2821 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 2822 | const runtime = yield* adapter.openSession({ | ||
| 2823 | threadId, | ||
| 2824 | providerSessionId: ProviderSessionId.make("provider-session-acp-subagent-carryover"), | ||
| 2825 | modelSelection, | ||
| 2826 | runtimePolicy, | ||
| 2827 | }); | ||
| 2828 | const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | ||
| 2829 | yield* runtime.events.pipe( | ||
| 2830 | Stream.runForEach((event) => Queue.offer(events, event)), | ||
| 2831 | Effect.forkScoped, | ||
| 2832 | ); | ||
| 2833 | const providerThread = yield* runtime.ensureThread({ | ||
| 2834 | threadId, | ||
| 2835 | modelSelection, | ||
| 2836 | runtimePolicy, | ||
| 2837 | }); | ||
| 2838 | const now = yield* DateTime.now; | ||
| 2839 | yield* runtime.startTurn( | ||
| 2840 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | ||
| 2841 | ); | ||
| 2842 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 2843 | Stream.filter( | ||
| 2844 | (event) => | ||
| 2845 | event.direction === "incoming" && | ||
| 2846 | event.stage === "raw" && | ||
| 2847 | typeof event.payload === "string" && | ||
| 2848 | event.payload.includes('"stopReason"'), | ||
| 2849 | ), | ||
| 2850 | Stream.runHead, | ||
| 2851 | ); | ||
| 2852 | yield* Effect.yieldNow; | ||
| 2853 | yield* Effect.yieldNow; | ||
| 2854 | |||
| 2855 | const firstProviderTurnId = idAllocator.derive.providerTurn({ | ||
| 2856 | driver: ACP_TEST_DRIVER, | ||
| 2857 | nativeTurnId: "mock-session-1:turn:1", | ||
| 2858 | }); | ||
| 2859 | const interruptFiber = yield* runtime | ||
| 2860 | .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) | ||
| 2861 | .pipe(Effect.forkScoped); | ||
| 2862 | yield* TestClock.adjust("10 seconds"); | ||
| 2863 | yield* Fiber.join(interruptFiber); | ||
| 2864 | |||
| 2865 | let subagentTurnItemId: string | null = null; | ||
| 2866 | let firstTerminalStatus: string | null = null; | ||
| 2867 | while (firstTerminalStatus === null) { | ||
| 2868 | const event = yield* Queue.take(events); | ||
| 2869 | if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { | ||
| 2870 | subagentTurnItemId = event.turnItem.id; | ||
| 2871 | } | ||
| 2872 | if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { | ||
| 2873 | firstTerminalStatus = event.status; | ||
| 2874 | } | ||
| 2875 | } | ||
| 2876 | assert.equal(firstTerminalStatus, "interrupted"); | ||
| 2877 | assert.notEqual(subagentTurnItemId, null); | ||
| 2878 | |||
| 2879 | subagentPhase = "complete"; | ||
| 2880 | const secondNow = yield* DateTime.now; | ||
| 2881 | yield* runtime.startTurn( | ||
| 2882 | makeTurnInput({ | ||
| 2883 | threadId, | ||
| 2884 | providerThread, | ||
| 2885 | instanceId, | ||
| 2886 | runtimePolicy, | ||
| 2887 | now: secondNow, | ||
| 2888 | ordinal: 2, | ||
| 2889 | }), | ||
| 2890 | ); | ||
| 2891 | const secondProviderTurnId = idAllocator.derive.providerTurn({ | ||
| 2892 | driver: ACP_TEST_DRIVER, | ||
| 2893 | nativeTurnId: "mock-session-1:turn:2", | ||
| 2894 | }); | ||
| 2895 | let carriedItemStatus: string | null = null; | ||
| 2896 | let secondTerminalStatus: string | null = null; | ||
| 2897 | while (secondTerminalStatus === null) { | ||
| 2898 | const event = yield* Queue.take(events); | ||
| 2899 | if ( | ||
| 2900 | event.type === "turn_item.updated" && | ||
| 2901 | event.turnItem.type === "subagent" && | ||
| 2902 | event.turnItem.id === subagentTurnItemId | ||
| 2903 | ) { | ||
| 2904 | carriedItemStatus = event.turnItem.status; | ||
| 2905 | } | ||
| 2906 | if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { | ||
| 2907 | secondTerminalStatus = event.status; | ||
| 2908 | } | ||
| 2909 | } | ||
| 2910 | assert.equal(carriedItemStatus, "completed"); | ||
| 2911 | assert.equal(secondTerminalStatus, "completed"); | ||
| 2912 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 2913 | ); | ||
| 2914 | |||
| 2915 | it.effect( | ||
| 2916 | "preserveRuntimeOnSettledInterrupt keeps the process alive and carries subagents through a settled steering interrupt", | ||
| 2917 | () => | ||
| 2918 | Effect.gen(function* () { | ||
| 2919 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 2920 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 2921 | const idAllocator = yield* IdAllocatorV2; | ||
| 2922 | const path = yield* Path.Path; | ||
| 2923 | const serverConfig = yield* ServerConfig; | ||
| 2924 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 2925 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 2926 | ); | ||
| 2927 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 2928 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 2929 | let subagentPhase: "spawn" | "complete" = "spawn"; | ||
| 2930 | let cancelCalled = false; | ||
| 2931 | let runtimeOrdinalSeen = 0; | ||
| 2932 | const adapter = makeAcpAdapterV2({ | ||
| 2933 | instanceId, | ||
| 2934 | flavor: { | ||
| 2935 | driver: ACP_TEST_DRIVER, | ||
| 2936 | capabilities: AcpProviderCapabilitiesV2, | ||
| 2937 | deferFinalizeForBackgroundWork: true, | ||
| 2938 | // Hard interrupt flags (stricter than production Grok, which no | ||
| 2939 | // longer sets restartRuntimeOnEveryInterrupt): every interrupt | ||
| 2940 | // would hard-kill the process group without the settled-soft gate | ||
| 2941 | // under test. | ||
| 2942 | restartRuntimeAfterInterrupt: true, | ||
| 2943 | restartRuntimeOnEveryInterrupt: true, | ||
| 2944 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 2945 | preserveRuntimeOnSettledInterrupt: true, | ||
| 2946 | extractSubagentUpdate: (toolCall) => | ||
| 2947 | toolCall.toolCallId !== "tool-call-generic-1" | ||
| 2948 | ? undefined | ||
| 2949 | : subagentPhase === "spawn" | ||
| 2950 | ? { | ||
| 2951 | nativeTaskId: "task-generic-1", | ||
| 2952 | prompt: "background subagent", | ||
| 2953 | title: "background subagent", | ||
| 2954 | model: null, | ||
| 2955 | status: "running", | ||
| 2956 | childSessionId: null, | ||
| 2957 | result: null, | ||
| 2958 | } | ||
| 2959 | : { | ||
| 2960 | nativeTaskId: "task-generic-1", | ||
| 2961 | prompt: "", | ||
| 2962 | title: null, | ||
| 2963 | model: null, | ||
| 2964 | status: "completed", | ||
| 2965 | childSessionId: null, | ||
| 2966 | result: "SUB_DONE", | ||
| 2967 | }, | ||
| 2968 | // No ownDetachedProcessGroup: if the interrupt wrongly takes the | ||
| 2969 | // hard path, terminateProcessGroup is missing and the interrupt | ||
| 2970 | // fails loudly with a poisoned session. | ||
| 2971 | makeRuntime: makeMockRuntime({ | ||
| 2972 | childProcessSpawner, | ||
| 2973 | mockAgentPath, | ||
| 2974 | environment: (runtimeOrdinal) => { | ||
| 2975 | runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); | ||
| 2976 | return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }; | ||
| 2977 | }, | ||
| 2978 | protocolEvents, | ||
| 2979 | wrapCancel: (cancel) => | ||
| 2980 | Effect.sync(() => { | ||
| 2981 | cancelCalled = true; | ||
| 2982 | }).pipe(Effect.andThen(cancel)), | ||
| 2983 | }), | ||
| 2984 | }, | ||
| 2985 | fileSystem, | ||
| 2986 | idAllocator, | ||
| 2987 | serverConfig, | ||
| 2988 | }); | ||
| 2989 | const threadId = ThreadId.make("thread-acp-settled-soft-steer"); | ||
| 2990 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 2991 | runtimeMode: "full-access", | ||
| 2992 | interactionMode: "default", | ||
| 2993 | cwd: process.cwd(), | ||
| 2994 | }); | ||
| 2995 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 2996 | const runtime = yield* adapter.openSession({ | ||
| 2997 | threadId, | ||
| 2998 | providerSessionId: ProviderSessionId.make("provider-session-acp-settled-soft-steer"), | ||
| 2999 | modelSelection, | ||
| 3000 | runtimePolicy, | ||
| 3001 | }); | ||
| 3002 | const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | ||
| 3003 | yield* runtime.events.pipe( | ||
| 3004 | Stream.runForEach((event) => Queue.offer(events, event)), | ||
| 3005 | Effect.forkScoped, | ||
| 3006 | ); | ||
| 3007 | const providerThread = yield* runtime.ensureThread({ | ||
| 3008 | threadId, | ||
| 3009 | modelSelection, | ||
| 3010 | runtimePolicy, | ||
| 3011 | }); | ||
| 3012 | const now = yield* DateTime.now; | ||
| 3013 | yield* runtime.startTurn( | ||
| 3014 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | ||
| 3015 | ); | ||
| 3016 | // The still-running subagent defers finalize after session/prompt returns, | ||
| 3017 | // so the interrupt below hits a settled turn held open for background work. | ||
| 3018 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 3019 | Stream.filter( | ||
| 3020 | (event) => | ||
| 3021 | event.direction === "incoming" && | ||
| 3022 | event.stage === "raw" && | ||
| 3023 | typeof event.payload === "string" && | ||
| 3024 | event.payload.includes('"stopReason"'), | ||
| 3025 | ), | ||
| 3026 | Stream.runHead, | ||
| 3027 | ); | ||
| 3028 | yield* Effect.yieldNow; | ||
| 3029 | yield* Effect.yieldNow; | ||
| 3030 | |||
| 3031 | const firstProviderTurnId = idAllocator.derive.providerTurn({ | ||
| 3032 | driver: ACP_TEST_DRIVER, | ||
| 3033 | nativeTurnId: "mock-session-1:turn:1", | ||
| 3034 | }); | ||
| 3035 | const interruptFiber = yield* runtime | ||
| 3036 | .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) | ||
| 3037 | .pipe(Effect.forkScoped); | ||
| 3038 | yield* TestClock.adjust("10 seconds"); | ||
| 3039 | yield* Fiber.join(interruptFiber); | ||
| 3040 | assert.isFalse( | ||
| 3041 | cancelCalled, | ||
| 3042 | "settled soft steer must not send session/cancel (the real Grok CLI kills background subagents on cancel)", | ||
| 3043 | ); | ||
| 3044 | |||
| 3045 | let subagentTurnItemId: string | null = null; | ||
| 3046 | let firstTerminalStatus: string | null = null; | ||
| 3047 | while (firstTerminalStatus === null) { | ||
| 3048 | const event = yield* Queue.take(events); | ||
| 3049 | if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { | ||
| 3050 | subagentTurnItemId = event.turnItem.id; | ||
| 3051 | } | ||
| 3052 | if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { | ||
| 3053 | firstTerminalStatus = event.status; | ||
| 3054 | } | ||
| 3055 | } | ||
| 3056 | assert.equal(firstTerminalStatus, "interrupted"); | ||
| 3057 | assert.notEqual(subagentTurnItemId, null); | ||
| 3058 | |||
| 3059 | subagentPhase = "complete"; | ||
| 3060 | const secondNow = yield* DateTime.now; | ||
| 3061 | yield* runtime.startTurn( | ||
| 3062 | makeTurnInput({ | ||
| 3063 | threadId, | ||
| 3064 | providerThread, | ||
| 3065 | instanceId, | ||
| 3066 | runtimePolicy, | ||
| 3067 | now: secondNow, | ||
| 3068 | ordinal: 2, | ||
| 3069 | }), | ||
| 3070 | ); | ||
| 3071 | // Same runtime process (mock-session-1): a respawn would start | ||
| 3072 | // mock-session-2 and drop the carryover on the session mismatch. | ||
| 3073 | const secondProviderTurnId = idAllocator.derive.providerTurn({ | ||
| 3074 | driver: ACP_TEST_DRIVER, | ||
| 3075 | nativeTurnId: "mock-session-1:turn:2", | ||
| 3076 | }); | ||
| 3077 | let carriedItemStatus: string | null = null; | ||
| 3078 | let secondTerminalStatus: string | null = null; | ||
| 3079 | while (secondTerminalStatus === null) { | ||
| 3080 | const event = yield* Queue.take(events); | ||
| 3081 | if ( | ||
| 3082 | event.type === "turn_item.updated" && | ||
| 3083 | event.turnItem.type === "subagent" && | ||
| 3084 | event.turnItem.id === subagentTurnItemId | ||
| 3085 | ) { | ||
| 3086 | carriedItemStatus = event.turnItem.status; | ||
| 3087 | } | ||
| 3088 | if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { | ||
| 3089 | secondTerminalStatus = event.status; | ||
| 3090 | } | ||
| 3091 | } | ||
| 3092 | assert.equal(carriedItemStatus, "completed"); | ||
| 3093 | assert.equal(secondTerminalStatus, "completed"); | ||
| 3094 | assert.equal( | ||
| 3095 | runtimeOrdinalSeen, | ||
| 3096 | 1, | ||
| 3097 | "settled soft steer must not respawn the ACP runtime process", | ||
| 3098 | ); | ||
| 3099 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 3100 | ); | ||
| 3101 | |||
| 3102 | it.live("preserveRuntimeOnSettledInterrupt does not soften a mid-prompt steering interrupt", () => | ||
| 3103 | Effect.gen(function* () { | ||
| 3104 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 3105 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 3106 | const idAllocator = yield* IdAllocatorV2; | ||
| 3107 | const path = yield* Path.Path; | ||
| 3108 | const serverConfig = yield* ServerConfig; | ||
| 3109 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 3110 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 3111 | ); | ||
| 3112 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 3113 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 3114 | const adapter = makeAcpAdapterV2({ | ||
| 3115 | instanceId, | ||
| 3116 | flavor: { | ||
| 3117 | driver: ACP_TEST_DRIVER, | ||
| 3118 | capabilities: AcpProviderCapabilitiesV2, | ||
| 3119 | restartRuntimeAfterInterrupt: true, | ||
| 3120 | // Local hard-flavor gate: production Grok no longer sets | ||
| 3121 | // restartRuntimeOnEveryInterrupt, but when a flavor does, the | ||
| 3122 | // settled-soft gate must not leak onto an unsettled prompt. | ||
| 3123 | restartRuntimeOnEveryInterrupt: true, | ||
| 3124 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 3125 | preserveRuntimeOnSettledInterrupt: true, | ||
| 3126 | // No ownDetachedProcessGroup: the expected hard path fails loudly | ||
| 3127 | // on the missing terminateProcessGroup, proving the settled-soft | ||
| 3128 | // gate did not apply to an unsettled prompt. | ||
| 3129 | makeRuntime: makeMockRuntime({ | ||
| 3130 | childProcessSpawner, | ||
| 3131 | mockAgentPath, | ||
| 3132 | environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, | ||
| 3133 | protocolEvents, | ||
| 3134 | }), | ||
| 3135 | }, | ||
| 3136 | fileSystem, | ||
| 3137 | idAllocator, | ||
| 3138 | serverConfig, | ||
| 3139 | }); | ||
| 3140 | const threadId = ThreadId.make("thread-acp-unsettled-steer-stays-hard"); | ||
| 3141 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 3142 | runtimeMode: "full-access", | ||
| 3143 | interactionMode: "default", | ||
| 3144 | cwd: process.cwd(), | ||
| 3145 | }); | ||
| 3146 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 3147 | const runtime = yield* adapter.openSession({ | ||
| 3148 | threadId, | ||
| 3149 | providerSessionId: ProviderSessionId.make( | ||
| 3150 | "provider-session-acp-unsettled-steer-stays-hard", | ||
| 3151 | ), | ||
| 3152 | modelSelection, | ||
| 3153 | runtimePolicy, | ||
| 3154 | }); | ||
| 3155 | const providerThread = yield* runtime.ensureThread({ | ||
| 3156 | threadId, | ||
| 3157 | modelSelection, | ||
| 3158 | runtimePolicy, | ||
| 3159 | }); | ||
| 3160 | const now = yield* DateTime.now; | ||
| 3161 | yield* runtime | ||
| 3162 | .startTurn( | ||
| 3163 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | ||
| 3164 | ) | ||
| 3165 | .pipe(Effect.forkScoped); | ||
| 3166 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 3167 | Stream.filter( | ||
| 3168 | (event) => | ||
| 3169 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | ||
| 3170 | ), | ||
| 3171 | Stream.runHead, | ||
| 3172 | ); | ||
| 3173 | const providerTurnId = idAllocator.derive.providerTurn({ | ||
| 3174 | driver: ACP_TEST_DRIVER, | ||
| 3175 | nativeTurnId: "mock-session-1:turn:1", | ||
| 3176 | }); | ||
| 3177 | const interruptExit = yield* runtime | ||
| 3178 | .interruptTurn({ providerThread, providerTurnId }) | ||
| 3179 | .pipe(Effect.exit); | ||
| 3180 | if (Exit.isSuccess(interruptExit)) { | ||
| 3181 | assert.fail("mid-prompt steering interrupt must still take the hard teardown path"); | ||
| 3182 | } | ||
| 3183 | assert.include(Cause.pretty(interruptExit.cause), "session is poisoned"); | ||
| 3184 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 3185 | ); | ||
| 3186 | |||
| 3187 | it.live( | ||
| 3188 | "soft mid-prompt interrupt cancels in place, reuses the runtime, and tracks cancel-backgrounded work", | ||
| 3189 | () => | ||
| 3190 | Effect.gen(function* () { | ||
| 3191 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 3192 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 3193 | const idAllocator = yield* IdAllocatorV2; | ||
| 3194 | const path = yield* Path.Path; | ||
| 3195 | const serverConfig = yield* ServerConfig; | ||
| 3196 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 3197 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 3198 | ); | ||
| 3199 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 3200 | const continuationRequests: Array<ProviderContinuationRequest> = []; | ||
| 3201 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 3202 | let cancelCalled = false; | ||
| 3203 | let runtimeOrdinalSeen = 0; | ||
| 3204 | const adapter = makeAcpAdapterV2({ | ||
| 3205 | instanceId, | ||
| 3206 | flavor: { | ||
| 3207 | driver: ACP_TEST_DRIVER, | ||
| 3208 | capabilities: AcpProviderCapabilitiesV2, | ||
| 3209 | enablePostSettleContinuation: true, | ||
| 3210 | // Production Grok interrupt flags: hard teardown only with | ||
| 3211 | // requestRuntimeRestart (user Stop). Without | ||
| 3212 | // restartRuntimeOnEveryInterrupt a mid-prompt steering interrupt | ||
| 3213 | // stays soft: session/cancel, same process, session reuse. | ||
| 3214 | restartRuntimeAfterInterrupt: true, | ||
| 3215 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 3216 | preserveRuntimeOnSettledInterrupt: true, | ||
| 3217 | registerExtensions: ({ runtime: extensionRuntime, applyBackgroundTaskMutation }) => | ||
| 3218 | registerXAiBackgroundTaskTracking(extensionRuntime, applyBackgroundTaskMutation), | ||
| 3219 | // No ownDetachedProcessGroup: if the interrupt wrongly takes the | ||
| 3220 | // hard path, terminateProcessGroup is missing and the interrupt | ||
| 3221 | // fails loudly with a poisoned session. | ||
| 3222 | makeRuntime: makeMockRuntime({ | ||
| 3223 | childProcessSpawner, | ||
| 3224 | mockAgentPath, | ||
| 3225 | environment: (runtimeOrdinal) => { | ||
| 3226 | runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); | ||
| 3227 | return { | ||
| 3228 | T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG_FIRST_PROMPT: "1", | ||
| 3229 | T3_ACP_EMIT_TASK_BACKGROUNDED_AFTER_CANCEL: "1", | ||
| 3230 | }; | ||
| 3231 | }, | ||
| 3232 | protocolEvents, | ||
| 3233 | wrapCancel: (cancel) => | ||
| 3234 | Effect.sync(() => { | ||
| 3235 | cancelCalled = true; | ||
| 3236 | }).pipe(Effect.andThen(cancel)), | ||
| 3237 | }), | ||
| 3238 | }, | ||
| 3239 | fileSystem, | ||
| 3240 | idAllocator, | ||
| 3241 | serverConfig, | ||
| 3242 | continuationRequests: { | ||
| 3243 | offer: (request) => | ||
| 3244 | Effect.sync(() => { | ||
| 3245 | continuationRequests.push(request); | ||
| 3246 | }), | ||
| 3247 | }, | ||
| 3248 | }); | ||
| 3249 | const threadId = ThreadId.make("thread-acp-soft-mid-prompt-steer"); | ||
| 3250 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 3251 | runtimeMode: "full-access", | ||
| 3252 | interactionMode: "default", | ||
| 3253 | cwd: process.cwd(), | ||
| 3254 | }); | ||
| 3255 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 3256 | const runtime = yield* adapter.openSession({ | ||
| 3257 | threadId, | ||
| 3258 | providerSessionId: ProviderSessionId.make("provider-session-acp-soft-mid-prompt-steer"), | ||
| 3259 | modelSelection, | ||
| 3260 | runtimePolicy, | ||
| 3261 | }); | ||
| 3262 | if (runtime.hasPendingBackgroundWork === undefined) { | ||
| 3263 | return yield* Effect.die( | ||
| 3264 | "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", | ||
| 3265 | ); | ||
| 3266 | } | ||
| 3267 | const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; | ||
| 3268 | const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | ||
| 3269 | yield* runtime.events.pipe( | ||
| 3270 | Stream.runForEach((event) => Queue.offer(events, event)), | ||
| 3271 | Effect.forkScoped, | ||
| 3272 | ); | ||
| 3273 | const providerThread = yield* runtime.ensureThread({ | ||
| 3274 | threadId, | ||
| 3275 | modelSelection, | ||
| 3276 | runtimePolicy, | ||
| 3277 | }); | ||
| 3278 | const now = yield* DateTime.now; | ||
| 3279 | yield* runtime | ||
| 3280 | .startTurn( | ||
| 3281 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | ||
| 3282 | ) | ||
| 3283 | .pipe(Effect.forkScoped); | ||
| 3284 | // Wait for the running command tool so the interrupt lands mid-prompt. | ||
| 3285 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 3286 | Stream.filter( | ||
| 3287 | (event) => | ||
| 3288 | event.direction === "incoming" && | ||
| 3289 | event.stage === "raw" && | ||
| 3290 | typeof event.payload === "string" && | ||
| 3291 | event.payload.includes("tool-call-running-1"), | ||
| 3292 | ), | ||
| 3293 | Stream.runHead, | ||
| 3294 | ); | ||
| 3295 | |||
| 3296 | const firstProviderTurnId = idAllocator.derive.providerTurn({ | ||
| 3297 | driver: ACP_TEST_DRIVER, | ||
| 3298 | nativeTurnId: "mock-session-1:turn:1", | ||
| 3299 | }); | ||
| 3300 | // No requestRuntimeRestart: a steering interrupt, not a user Stop. | ||
| 3301 | yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); | ||
| 3302 | assert.isTrue( | ||
| 3303 | cancelCalled, | ||
| 3304 | "soft mid-prompt interrupt must send session/cancel to detach the running work", | ||
| 3305 | ); | ||
| 3306 | |||
| 3307 | let firstTerminalStatus: string | null = null; | ||
| 3308 | while (firstTerminalStatus === null) { | ||
| 3309 | const event = yield* Queue.take(events); | ||
| 3310 | if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { | ||
| 3311 | firstTerminalStatus = event.status; | ||
| 3312 | } | ||
| 3313 | } | ||
| 3314 | assert.equal(firstTerminalStatus, "interrupted"); | ||
| 3315 | // The cancel handler emitted _x.ai/task_backgrounded for the detached | ||
| 3316 | // command; the tracked task must report as pending background work. | ||
| 3317 | let backgroundTracked = false; | ||
| 3318 | for (let attempt = 0; attempt < 80 && !backgroundTracked; attempt += 1) { | ||
| 3319 | backgroundTracked = yield* hasPendingBackgroundWork; | ||
| 3320 | if (!backgroundTracked) { | ||
| 3321 | yield* Effect.sleep("25 millis"); | ||
| 3322 | } | ||
| 3323 | } | ||
| 3324 | assert.isTrue( | ||
| 3325 | backgroundTracked, | ||
| 3326 | "cancel-backgrounded task must be tracked as running background work", | ||
| 3327 | ); | ||
| 3328 | |||
| 3329 | // The second prompt reuses the same process and session. | ||
| 3330 | const secondNow = yield* DateTime.now; | ||
| 3331 | yield* runtime.startTurn( | ||
| 3332 | makeTurnInput({ | ||
| 3333 | threadId, | ||
| 3334 | providerThread, | ||
| 3335 | instanceId, | ||
| 3336 | runtimePolicy, | ||
| 3337 | now: secondNow, | ||
| 3338 | ordinal: 2, | ||
| 3339 | }), | ||
| 3340 | ); | ||
| 3341 | const secondProviderTurnId = idAllocator.derive.providerTurn({ | ||
| 3342 | driver: ACP_TEST_DRIVER, | ||
| 3343 | nativeTurnId: "mock-session-1:turn:2", | ||
| 3344 | }); | ||
| 3345 | let secondTerminalStatus: string | null = null; | ||
| 3346 | while (secondTerminalStatus === null) { | ||
| 3347 | const event = yield* Queue.take(events); | ||
| 3348 | if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { | ||
| 3349 | secondTerminalStatus = event.status; | ||
| 3350 | } | ||
| 3351 | } | ||
| 3352 | assert.equal(secondTerminalStatus, "completed"); | ||
| 3353 | assert.equal( | ||
| 3354 | runtimeOrdinalSeen, | ||
| 3355 | 1, | ||
| 3356 | "soft mid-prompt interrupt must not respawn the ACP runtime process", | ||
| 3357 | ); | ||
| 3358 | |||
| 3359 | // _x.ai/task_completed lands ~1.2s after the cancel and clears the | ||
| 3360 | // tracked task without opening a synthetic continuation run. | ||
| 3361 | let backgroundPending = true; | ||
| 3362 | for (let attempt = 0; attempt < 50 && backgroundPending; attempt += 1) { | ||
| 3363 | backgroundPending = yield* hasPendingBackgroundWork; | ||
| 3364 | if (backgroundPending) { | ||
| 3365 | yield* Effect.sleep("100 millis"); | ||
| 3366 | } | ||
| 3367 | } | ||
| 3368 | assert.isFalse( | ||
| 3369 | backgroundPending, | ||
| 3370 | "tracked background task must clear after _x.ai/task_completed", | ||
| 3371 | ); | ||
| 3372 | assert.lengthOf( | ||
| 3373 | continuationRequests, | ||
| 3374 | 0, | ||
| 3375 | "a cancel-backgrounded task completion must not wake a synthetic continuation run", | ||
| 3376 | ); | ||
| 3377 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 3378 | ); | ||
| 3379 | |||
| 3380 | it.live("direct Stop quarantine drops late background task mutations from the stopped run", () => | ||
| 3381 | Effect.gen(function* () { | ||
| 3382 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 3383 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 3384 | const idAllocator = yield* IdAllocatorV2; | ||
| 3385 | const path = yield* Path.Path; | ||
| 3386 | const serverConfig = yield* ServerConfig; | ||
| 3387 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 3388 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 3389 | ); | ||
| 3390 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 3391 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 3392 | const capturedMutation: { | ||
| 3393 | current: | ||
| 3394 | | ((mutation: { | ||
| 3395 | readonly sessionId: string; | ||
| 3396 | readonly taskId: string; | ||
| 3397 | readonly status: "running" | "completed" | "failed"; | ||
| 3398 | }) => Effect.Effect<void>) | ||
| 3399 | | null; | ||
| 3400 | } = { current: null }; | ||
| 3401 | const adapter = makeAcpAdapterV2({ | ||
| 3402 | instanceId, | ||
| 3403 | flavor: { | ||
| 3404 | driver: ACP_TEST_DRIVER, | ||
| 3405 | capabilities: AcpProviderCapabilitiesV2, | ||
| 3406 | enablePostSettleContinuation: true, | ||
| 3407 | restartRuntimeAfterInterrupt: true, | ||
| 3408 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 3409 | preserveRuntimeOnSettledInterrupt: true, | ||
| 3410 | registerExtensions: (context) => | ||
| 3411 | Effect.sync(() => { | ||
| 3412 | capturedMutation.current = context.applyBackgroundTaskMutation; | ||
| 3413 | }), | ||
| 3414 | makeRuntime: makeMockRuntime({ | ||
| 3415 | childProcessSpawner, | ||
| 3416 | mockAgentPath, | ||
| 3417 | environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, | ||
| 3418 | ownDetachedProcessGroup: true, | ||
| 3419 | protocolEvents, | ||
| 3420 | }), | ||
| 3421 | }, | ||
| 3422 | fileSystem, | ||
| 3423 | idAllocator, | ||
| 3424 | serverConfig, | ||
| 3425 | continuationRequests: { offer: () => Effect.void }, | ||
| 3426 | }); | ||
| 3427 | const threadId = ThreadId.make("thread-acp-stop-quarantine-late-task"); | ||
| 3428 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 3429 | runtimeMode: "full-access", | ||
| 3430 | interactionMode: "default", | ||
| 3431 | cwd: process.cwd(), | ||
| 3432 | }); | ||
| 3433 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 3434 | const runtime = yield* adapter.openSession({ | ||
| 3435 | threadId, | ||
| 3436 | providerSessionId: ProviderSessionId.make("provider-session-acp-stop-quarantine-late-task"), | ||
| 3437 | modelSelection, | ||
| 3438 | runtimePolicy, | ||
| 3439 | }); | ||
| 3440 | if (runtime.hasPendingBackgroundWork === undefined) { | ||
| 3441 | return yield* Effect.die( | ||
| 3442 | "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", | ||
| 3443 | ); | ||
| 3444 | } | ||
| 3445 | const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; | ||
| 3446 | const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | ||
| 3447 | yield* runtime.events.pipe( | ||
| 3448 | Stream.runForEach((event) => Queue.offer(events, event)), | ||
| 3449 | Effect.forkScoped, | ||
| 3450 | ); | ||
| 3451 | const providerThread = yield* runtime.ensureThread({ | ||
| 3452 | threadId, | ||
| 3453 | modelSelection, | ||
| 3454 | runtimePolicy, | ||
| 3455 | }); | ||
| 3456 | const now = yield* DateTime.now; | ||
| 3457 | yield* runtime | ||
| 3458 | .startTurn( | ||
| 3459 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | ||
| 3460 | ) | ||
| 3461 | .pipe(Effect.forkScoped); | ||
| 3462 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 3463 | Stream.filter( | ||
| 3464 | (event) => | ||
| 3465 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | ||
| 3466 | ), | ||
| 3467 | Stream.runHead, | ||
| 3468 | ); | ||
| 3469 | const applyMutation = capturedMutation.current; | ||
| 3470 | if (applyMutation === null) { | ||
| 3471 | return yield* Effect.die("registerExtensions must capture applyBackgroundTaskMutation"); | ||
| 3472 | } | ||
| 3473 | // Plumbing sanity: pre-Stop mutations on the root session track and | ||
| 3474 | // clear pending background work through the extension callback. | ||
| 3475 | yield* applyMutation({ | ||
| 3476 | sessionId: "mock-session-1", | ||
| 3477 | taskId: "task-pre-stop", | ||
| 3478 | status: "running", | ||
| 3479 | }); | ||
| 3480 | assert.isTrue( | ||
| 3481 | yield* hasPendingBackgroundWork, | ||
| 3482 | "a running background task mutation on the root session must track before Stop", | ||
| 3483 | ); | ||
| 3484 | yield* applyMutation({ | ||
| 3485 | sessionId: "mock-session-1", | ||
| 3486 | taskId: "task-pre-stop", | ||
| 3487 | status: "completed", | ||
| 3488 | }); | ||
| 3489 | assert.isFalse( | ||
| 3490 | yield* hasPendingBackgroundWork, | ||
| 3491 | "a completed background task mutation must clear pending background work", | ||
| 3492 | ); | ||
| 3493 | |||
| 3494 | const providerTurnId = idAllocator.derive.providerTurn({ | ||
| 3495 | driver: ACP_TEST_DRIVER, | ||
| 3496 | nativeTurnId: "mock-session-1:turn:1", | ||
| 3497 | }); | ||
| 3498 | yield* runtime.interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }); | ||
| 3499 | let terminalStatus: string | null = null; | ||
| 3500 | while (terminalStatus === null) { | ||
| 3501 | const event = yield* Queue.take(events); | ||
| 3502 | if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { | ||
| 3503 | terminalStatus = event.status; | ||
| 3504 | } | ||
| 3505 | } | ||
| 3506 | assert.equal(terminalStatus, "interrupted"); | ||
| 3507 | |||
| 3508 | // Residual lifecycle from the stopped run: activeSessionId still points | ||
| 3509 | // at the stopped session until the next turn respawns the runtime, so | ||
| 3510 | // only the direct Stop quarantine stands between this mutation and the | ||
| 3511 | // wake machinery. | ||
| 3512 | yield* applyMutation({ | ||
| 3513 | sessionId: "mock-session-1", | ||
| 3514 | taskId: "task-late-after-stop", | ||
| 3515 | status: "running", | ||
| 3516 | }); | ||
| 3517 | assert.isFalse( | ||
| 3518 | yield* hasPendingBackgroundWork, | ||
| 3519 | "direct Stop quarantine must drop residual background task mutations from the stopped run", | ||
| 3520 | ); | ||
| 3521 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 3522 | ); | ||
| 3523 | |||
| 3524 | it.live("production Grok interrupt flags still hard-kill and respawn on user Stop", () => | ||
| 3525 | Effect.gen(function* () { | ||
| 3526 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 3527 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 3528 | const idAllocator = yield* IdAllocatorV2; | ||
| 3529 | const path = yield* Path.Path; | ||
| 3530 | const serverConfig = yield* ServerConfig; | ||
| 3531 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 3532 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 3533 | ); | ||
| 3534 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 3535 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 3536 | let runtimeOrdinalSeen = 0; | ||
| 3537 | const adapter = makeAcpAdapterV2({ | ||
| 3538 | instanceId, | ||
| 3539 | flavor: { | ||
| 3540 | driver: ACP_TEST_DRIVER, | ||
| 3541 | capabilities: AcpProviderCapabilitiesV2, | ||
| 3542 | enablePostSettleContinuation: true, | ||
| 3543 | // The full production Grok interrupt flag set after the non-Stop | ||
| 3544 | // softening: no restartRuntimeOnEveryInterrupt. User Stop | ||
| 3545 | // (requestRuntimeRestart) must still take the hard teardown and | ||
| 3546 | // respawn path, not the soft cancel path. | ||
| 3547 | restartRuntimeAfterInterrupt: true, | ||
| 3548 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 3549 | preserveRuntimeOnSettledInterrupt: true, | ||
| 3550 | registerExtensions: ({ runtime: extensionRuntime, applyBackgroundTaskMutation }) => | ||
| 3551 | registerXAiBackgroundTaskTracking(extensionRuntime, applyBackgroundTaskMutation), | ||
| 3552 | makeRuntime: makeMockRuntime({ | ||
| 3553 | childProcessSpawner, | ||
| 3554 | mockAgentPath, | ||
| 3555 | // Only hang the interrupted first turn. The replacement process must | ||
| 3556 | // complete normally so startTurn / session-load assertions can finish. | ||
| 3557 | environment: (runtimeOrdinal) => { | ||
| 3558 | runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); | ||
| 3559 | return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {}; | ||
| 3560 | }, | ||
| 3561 | ownDetachedProcessGroup: true, | ||
| 3562 | protocolEvents, | ||
| 3563 | }), | ||
| 3564 | }, | ||
| 3565 | fileSystem, | ||
| 3566 | idAllocator, | ||
| 3567 | serverConfig, | ||
| 3568 | continuationRequests: { offer: () => Effect.void }, | ||
| 3569 | }); | ||
| 3570 | const threadId = ThreadId.make("thread-acp-production-stop-hard-kill"); | ||
| 3571 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 3572 | runtimeMode: "full-access", | ||
| 3573 | interactionMode: "default", | ||
| 3574 | cwd: process.cwd(), | ||
| 3575 | }); | ||
| 3576 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 3577 | const runtime = yield* adapter.openSession({ | ||
| 3578 | threadId, | ||
| 3579 | providerSessionId: ProviderSessionId.make("provider-session-acp-production-stop-hard-kill"), | ||
| 3580 | modelSelection, | ||
| 3581 | runtimePolicy, | ||
| 3582 | }); | ||
| 3583 | const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | ||
| 3584 | yield* runtime.events.pipe( | ||
| 3585 | Stream.runForEach((event) => Queue.offer(events, event)), | ||
| 3586 | Effect.forkScoped, | ||
| 3587 | ); | ||
| 3588 | const providerThread = yield* runtime.ensureThread({ | ||
| 3589 | threadId, | ||
| 3590 | modelSelection, | ||
| 3591 | runtimePolicy, | ||
| 3592 | }); | ||
| 3593 | const now = yield* DateTime.now; | ||
| 3594 | yield* runtime | ||
| 3595 | .startTurn( | ||
| 3596 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | ||
| 3597 | ) | ||
| 3598 | .pipe(Effect.forkScoped); | ||
| 3599 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 3600 | Stream.filter( | ||
| 3601 | (event) => | ||
| 3602 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | ||
| 3603 | ), | ||
| 3604 | Stream.runHead, | ||
| 3605 | ); | ||
| 3606 | const providerTurnId = idAllocator.derive.providerTurn({ | ||
| 3607 | driver: ACP_TEST_DRIVER, | ||
| 3608 | nativeTurnId: "mock-session-1:turn:1", | ||
| 3609 | }); | ||
| 3610 | yield* runtime.interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }); | ||
| 3611 | let terminalStatus: string | null = null; | ||
| 3612 | while (terminalStatus === null) { | ||
| 3613 | const event = yield* Queue.take(events); | ||
| 3614 | if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { | ||
| 3615 | terminalStatus = event.status; | ||
| 3616 | } | ||
| 3617 | } | ||
| 3618 | assert.equal(terminalStatus, "interrupted"); | ||
| 3619 | |||
| 3620 | // Effect 4 Queue.takeAll waits for at least one element when empty. After | ||
| 3621 | // the session/prompt stream drain the protocol queue is often empty, so | ||
| 3622 | // takeAll would hang forever. Use clear (non-blocking drain) instead so | ||
| 3623 | // the session/load wait cannot match residual pre-restart traffic. | ||
| 3624 | yield* Queue.clear(protocolEvents); | ||
| 3625 | yield* runtime.startTurn( | ||
| 3626 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), | ||
| 3627 | ); | ||
| 3628 | const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 3629 | Stream.filter( | ||
| 3630 | (event) => event.direction === "outgoing" && rawProtocolMethod(event) === "session/load", | ||
| 3631 | ), | ||
| 3632 | Stream.runHead, | ||
| 3633 | ); | ||
| 3634 | assert.isTrue( | ||
| 3635 | Option.isSome(loadAfterRestart), | ||
| 3636 | "user Stop must respawn the runtime and reload the session on the next turn", | ||
| 3637 | ); | ||
| 3638 | assert.equal( | ||
| 3639 | runtimeOrdinalSeen, | ||
| 3640 | 2, | ||
| 3641 | "user Stop with production Grok flags must replace the ACP runtime process", | ||
| 3642 | ); | ||
| 3643 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 3644 | ); | ||
| 3645 | |||
| 3646 | // it.live: ownDetachedProcessGroup teardown uses wall-clock sleeps; under | ||
| 3647 | // it.effect the interrupt timeout on context.completed still needs real time | ||
| 3648 | // after the soft steer clears the turn. | ||
| 3649 | it.live( | ||
| 3650 | "Stop after settled soft steer contains the orphan runtime and respawns without subagent carryover", | ||
| 3651 | () => | ||
| 3652 | Effect.gen(function* () { | ||
| 3653 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 3654 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 3655 | const idAllocator = yield* IdAllocatorV2; | ||
| 3656 | const path = yield* Path.Path; | ||
| 3657 | const serverConfig = yield* ServerConfig; | ||
| 3658 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 3659 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 3660 | ); | ||
| 3661 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 3662 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 3663 | let subagentPhase: "spawn" | "complete" = "spawn"; | ||
| 3664 | let cancelCalled = false; | ||
| 3665 | let runtimeOrdinalSeen = 0; | ||
| 3666 | const childSessionId = "mock-child-session-1"; | ||
| 3667 | const streamedSubagentText = "streamed subagent carryover text"; | ||
| 3668 | type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 3669 | let sessionUpdateHandler: Parameters<RuntimeService["handleSessionUpdate"]>[0] | undefined; | ||
| 3670 | const adapter = makeAcpAdapterV2({ | ||
| 3671 | instanceId, | ||
| 3672 | flavor: { | ||
| 3673 | driver: ACP_TEST_DRIVER, | ||
| 3674 | capabilities: AcpProviderCapabilitiesV2, | ||
| 3675 | deferFinalizeForBackgroundWork: true, | ||
| 3676 | // Production Grok interrupt flags: soft settle keeps the process; | ||
| 3677 | // only requestRuntimeRestart (user Stop) hard-kills. | ||
| 3678 | restartRuntimeAfterInterrupt: true, | ||
| 3679 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 3680 | preserveRuntimeOnSettledInterrupt: true, | ||
| 3681 | extractSubagentUpdate: (toolCall) => | ||
| 3682 | toolCall.toolCallId !== "tool-call-generic-1" | ||
| 3683 | ? undefined | ||
| 3684 | : subagentPhase === "spawn" | ||
| 3685 | ? { | ||
| 3686 | nativeTaskId: "task-generic-1", | ||
| 3687 | prompt: "background subagent", | ||
| 3688 | title: "background subagent", | ||
| 3689 | model: null, | ||
| 3690 | status: "running", | ||
| 3691 | childSessionId, | ||
| 3692 | result: null, | ||
| 3693 | } | ||
| 3694 | : { | ||
| 3695 | nativeTaskId: "task-generic-1", | ||
| 3696 | prompt: "", | ||
| 3697 | title: null, | ||
| 3698 | model: null, | ||
| 3699 | status: "completed", | ||
| 3700 | childSessionId: null, | ||
| 3701 | result: "SUB_DONE", | ||
| 3702 | }, | ||
| 3703 | makeRuntime: makeMockRuntime({ | ||
| 3704 | childProcessSpawner, | ||
| 3705 | mockAgentPath, | ||
| 3706 | environment: (runtimeOrdinal) => { | ||
| 3707 | runtimeOrdinalSeen = Math.max(runtimeOrdinalSeen, runtimeOrdinal); | ||
| 3708 | return { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }; | ||
| 3709 | }, | ||
| 3710 | ownDetachedProcessGroup: true, | ||
| 3711 | protocolEvents, | ||
| 3712 | wrapCancel: (cancel) => | ||
| 3713 | Effect.sync(() => { | ||
| 3714 | cancelCalled = true; | ||
| 3715 | }).pipe(Effect.andThen(cancel)), | ||
| 3716 | wrapRuntime: (runtime) => ({ | ||
| 3717 | ...runtime, | ||
| 3718 | handleSessionUpdate: (handler) => | ||
| 3719 | Effect.sync(() => { | ||
| 3720 | sessionUpdateHandler = handler; | ||
| 3721 | }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), | ||
| 3722 | }), | ||
| 3723 | }), | ||
| 3724 | }, | ||
| 3725 | fileSystem, | ||
| 3726 | idAllocator, | ||
| 3727 | serverConfig, | ||
| 3728 | }); | ||
| 3729 | const threadId = ThreadId.make("thread-acp-stop-after-soft-steer-orphan"); | ||
| 3730 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 3731 | runtimeMode: "full-access", | ||
| 3732 | interactionMode: "default", | ||
| 3733 | cwd: process.cwd(), | ||
| 3734 | }); | ||
| 3735 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 3736 | const runtime = yield* adapter.openSession({ | ||
| 3737 | threadId, | ||
| 3738 | providerSessionId: ProviderSessionId.make( | ||
| 3739 | "provider-session-acp-stop-after-soft-steer-orphan", | ||
| 3740 | ), | ||
| 3741 | modelSelection, | ||
| 3742 | runtimePolicy, | ||
| 3743 | }); | ||
| 3744 | const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | ||
| 3745 | yield* runtime.events.pipe( | ||
| 3746 | Stream.runForEach((event) => Queue.offer(events, event)), | ||
| 3747 | Effect.forkScoped, | ||
| 3748 | ); | ||
| 3749 | const providerThread = yield* runtime.ensureThread({ | ||
| 3750 | threadId, | ||
| 3751 | modelSelection, | ||
| 3752 | runtimePolicy, | ||
| 3753 | }); | ||
| 3754 | const now = yield* DateTime.now; | ||
| 3755 | yield* runtime.startTurn( | ||
| 3756 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | ||
| 3757 | ); | ||
| 3758 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 3759 | Stream.filter( | ||
| 3760 | (event) => | ||
| 3761 | event.direction === "incoming" && | ||
| 3762 | event.stage === "raw" && | ||
| 3763 | typeof event.payload === "string" && | ||
| 3764 | event.payload.includes('"stopReason"'), | ||
| 3765 | ), | ||
| 3766 | Stream.runHead, | ||
| 3767 | ); | ||
| 3768 | yield* Effect.yieldNow; | ||
| 3769 | yield* Effect.yieldNow; | ||
| 3770 | |||
| 3771 | // Stream assistant text onto the carryover subagent while the deferred | ||
| 3772 | // turn is still active so assistantText races ahead of task.result. | ||
| 3773 | assert.isDefined(sessionUpdateHandler, "session update handler must be wired"); | ||
| 3774 | yield* sessionUpdateHandler!({ | ||
| 3775 | sessionId: childSessionId, | ||
| 3776 | update: { | ||
| 3777 | sessionUpdate: "agent_message_chunk", | ||
| 3778 | content: { type: "text", text: streamedSubagentText }, | ||
| 3779 | }, | ||
| 3780 | }); | ||
| 3781 | let subagentTurnItemId: string | null = null; | ||
| 3782 | let streamedTextSeen = false; | ||
| 3783 | for (let attempt = 0; attempt < 64; attempt += 1) { | ||
| 3784 | const maybeEvent = yield* Queue.take(events).pipe(Effect.timeoutOption("50 millis")); | ||
| 3785 | if (Option.isNone(maybeEvent)) break; | ||
| 3786 | const event = maybeEvent.value; | ||
| 3787 | if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { | ||
| 3788 | subagentTurnItemId = event.turnItem.id; | ||
| 3789 | } | ||
| 3790 | if ( | ||
| 3791 | event.type === "message.updated" && | ||
| 3792 | event.message.text.includes(streamedSubagentText) | ||
| 3793 | ) { | ||
| 3794 | streamedTextSeen = true; | ||
| 3795 | break; | ||
| 3796 | } | ||
| 3797 | } | ||
| 3798 | assert.isTrue( | ||
| 3799 | streamedTextSeen, | ||
| 3800 | "pre-steer child session chunk must project as subagent assistant text", | ||
| 3801 | ); | ||
| 3802 | |||
| 3803 | const firstProviderTurnId = idAllocator.derive.providerTurn({ | ||
| 3804 | driver: ACP_TEST_DRIVER, | ||
| 3805 | nativeTurnId: "mock-session-1:turn:1", | ||
| 3806 | }); | ||
| 3807 | // Soft steer first: clears the turn, leaves the process alive. | ||
| 3808 | yield* runtime.interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }); | ||
| 3809 | assert.isFalse( | ||
| 3810 | cancelCalled, | ||
| 3811 | "settled soft steer must not send session/cancel before the later Stop", | ||
| 3812 | ); | ||
| 3813 | |||
| 3814 | let firstTerminalStatus: string | null = null; | ||
| 3815 | while (firstTerminalStatus === null) { | ||
| 3816 | const event = yield* Queue.take(events); | ||
| 3817 | if (event.type === "turn_item.updated" && event.turnItem.type === "subagent") { | ||
| 3818 | subagentTurnItemId = event.turnItem.id; | ||
| 3819 | } | ||
| 3820 | if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { | ||
| 3821 | firstTerminalStatus = event.status; | ||
| 3822 | } | ||
| 3823 | } | ||
| 3824 | assert.equal(firstTerminalStatus, "interrupted"); | ||
| 3825 | assert.notEqual(subagentTurnItemId, null); | ||
| 3826 | assert.equal( | ||
| 3827 | runtimeOrdinalSeen, | ||
| 3828 | 1, | ||
| 3829 | "soft steer must leave the original ACP runtime process alive", | ||
| 3830 | ); | ||
| 3831 | |||
| 3832 | // User Stop against the already-cleared turn: contain the orphan runtime. | ||
| 3833 | const stopExit = yield* runtime | ||
| 3834 | .interruptTurn({ | ||
| 3835 | providerThread, | ||
| 3836 | providerTurnId: firstProviderTurnId, | ||
| 3837 | requestRuntimeRestart: true, | ||
| 3838 | }) | ||
| 3839 | .pipe(Effect.exit); | ||
| 3840 | if (Exit.isFailure(stopExit)) { | ||
| 3841 | assert.fail( | ||
| 3842 | `cleared-turn Stop must contain the orphan runtime, not fail: ${Cause.pretty(stopExit.cause)}`, | ||
| 3843 | ); | ||
| 3844 | } | ||
| 3845 | |||
| 3846 | // Orphan Stop must terminalize carried-over subagents (soft steer left them | ||
| 3847 | // running on purpose; quarantine alone would leave them stuck "running"). | ||
| 3848 | let subagentStopStatus: string | null = null; | ||
| 3849 | let subagentStopResult: string | null | undefined; | ||
| 3850 | let subagentStopProviderThreadId: string | null | undefined; | ||
| 3851 | let subagentUpdatedResult: string | null | undefined; | ||
| 3852 | for (let attempt = 0; attempt < 64; attempt += 1) { | ||
| 3853 | const maybeEvent = yield* Queue.take(events).pipe(Effect.timeoutOption("50 millis")); | ||
| 3854 | if (Option.isNone(maybeEvent)) break; | ||
| 3855 | const event = maybeEvent.value; | ||
| 3856 | if ( | ||
| 3857 | event.type === "turn_item.updated" && | ||
| 3858 | event.turnItem.type === "subagent" && | ||
| 3859 | event.turnItem.id === subagentTurnItemId | ||
| 3860 | ) { | ||
| 3861 | subagentStopStatus = event.turnItem.status; | ||
| 3862 | subagentStopResult = event.turnItem.result; | ||
| 3863 | subagentStopProviderThreadId = event.turnItem.providerThreadId; | ||
| 3864 | if (subagentStopStatus === "interrupted") break; | ||
| 3865 | } | ||
| 3866 | if ( | ||
| 3867 | event.type === "subagent.updated" && | ||
| 3868 | event.subagent.status === "interrupted" && | ||
| 3869 | event.subagent.result === streamedSubagentText | ||
| 3870 | ) { | ||
| 3871 | subagentUpdatedResult = event.subagent.result; | ||
| 3872 | } | ||
| 3873 | } | ||
| 3874 | assert.equal( | ||
| 3875 | subagentStopStatus, | ||
| 3876 | "interrupted", | ||
| 3877 | "orphan Stop must emit interrupted terminal for turn-1 carryover subagent", | ||
| 3878 | ); | ||
| 3879 | assert.equal( | ||
| 3880 | subagentStopResult, | ||
| 3881 | streamedSubagentText, | ||
| 3882 | "orphan Stop must merge streamed assistantText into the interrupted result", | ||
| 3883 | ); | ||
| 3884 | assert.equal( | ||
| 3885 | subagentStopProviderThreadId, | ||
| 3886 | providerThread.id, | ||
| 3887 | "orphan Stop parent-level events must use the spawn-time parent provider thread id", | ||
| 3888 | ); | ||
| 3889 | assert.equal( | ||
| 3890 | subagentUpdatedResult, | ||
| 3891 | streamedSubagentText, | ||
| 3892 | "orphan Stop subagent.updated must also carry the streamed result", | ||
| 3893 | ); | ||
| 3894 | |||
| 3895 | yield* Queue.clear(protocolEvents); | ||
| 3896 | const secondNow = yield* DateTime.now; | ||
| 3897 | yield* runtime.startTurn( | ||
| 3898 | makeTurnInput({ | ||
| 3899 | threadId, | ||
| 3900 | providerThread, | ||
| 3901 | instanceId, | ||
| 3902 | runtimePolicy, | ||
| 3903 | now: secondNow, | ||
| 3904 | ordinal: 2, | ||
| 3905 | }), | ||
| 3906 | ); | ||
| 3907 | const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 3908 | Stream.filter( | ||
| 3909 | (event) => | ||
| 3910 | event.direction === "outgoing" && | ||
| 3911 | (rawProtocolMethod(event) === "session/load" || | ||
| 3912 | rawProtocolMethod(event) === "session/new"), | ||
| 3913 | ), | ||
| 3914 | Stream.runHead, | ||
| 3915 | ); | ||
| 3916 | assert.isTrue( | ||
| 3917 | Option.isSome(loadAfterRestart), | ||
| 3918 | "orphan containment must force a runtime respawn before the next turn", | ||
| 3919 | ); | ||
| 3920 | assert.equal( | ||
| 3921 | runtimeOrdinalSeen, | ||
| 3922 | 2, | ||
| 3923 | "Stop after soft steer must replace the orphan ACP runtime process", | ||
| 3924 | ); | ||
| 3925 | |||
| 3926 | // nativeTurnId is `${sessionId}:turn:${ordinal}`. The mock always uses | ||
| 3927 | // mock-session-1; ordinal 2 yields turn:2 on the replacement process. | ||
| 3928 | // Carryover was quarantined by Stop, so turn-1's subagent must not re-attach. | ||
| 3929 | subagentPhase = "complete"; | ||
| 3930 | const secondProviderTurnId = idAllocator.derive.providerTurn({ | ||
| 3931 | driver: ACP_TEST_DRIVER, | ||
| 3932 | nativeTurnId: "mock-session-1:turn:2", | ||
| 3933 | }); | ||
| 3934 | let carriedItemStatus: string | null = null; | ||
| 3935 | let secondTerminalStatus: string | null = null; | ||
| 3936 | while (secondTerminalStatus === null) { | ||
| 3937 | const event = yield* Queue.take(events); | ||
| 3938 | if ( | ||
| 3939 | event.type === "turn_item.updated" && | ||
| 3940 | event.turnItem.type === "subagent" && | ||
| 3941 | event.turnItem.id === subagentTurnItemId | ||
| 3942 | ) { | ||
| 3943 | carriedItemStatus = event.turnItem.status; | ||
| 3944 | } | ||
| 3945 | if (event.type === "turn.terminal" && event.providerTurnId === secondProviderTurnId) { | ||
| 3946 | secondTerminalStatus = event.status; | ||
| 3947 | } | ||
| 3948 | } | ||
| 3949 | assert.isNull( | ||
| 3950 | carriedItemStatus, | ||
| 3951 | "Stop quarantine must drop turn-1 subagent carryover on the respawned runtime", | ||
| 3952 | ); | ||
| 3953 | assert.equal(secondTerminalStatus, "completed"); | ||
| 3954 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 3955 | ); | ||
| 3956 | |||
| 3957 | it.effect( | ||
| 3958 | "settled soft interrupt skips cancel when prompt wire is settled before completion callback", | ||
| 3959 | () => | ||
| 3960 | Effect.gen(function* () { | ||
| 3961 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 3962 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 3963 | const idAllocator = yield* IdAllocatorV2; | ||
| 3964 | const path = yield* Path.Path; | ||
| 3965 | const serverConfig = yield* ServerConfig; | ||
| 3966 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 3967 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 3968 | ); | ||
| 3969 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 3970 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 3971 | let subagentPhase: "spawn" | "complete" = "spawn"; | ||
| 3972 | let cancelCalled = false; | ||
| 3973 | const promptPhases: Array<string> = []; | ||
| 3974 | // Gate adapter-visible prompt return so we can open the race window after | ||
| 3975 | // the native stopReason is on the wire. Release lets Effect.tap mark | ||
| 3976 | // promptWireSettled before the completion callback requests the permit; | ||
| 3977 | // interrupt then ORs wire-done with promptSettled under the permit. | ||
| 3978 | const promptWireReturned = yield* Deferred.make<void>(); | ||
| 3979 | const releasePromptCompletion = yield* Deferred.make<void>(); | ||
| 3980 | const adapter = makeAcpAdapterV2({ | ||
| 3981 | instanceId, | ||
| 3982 | flavor: { | ||
| 3983 | driver: ACP_TEST_DRIVER, | ||
| 3984 | capabilities: AcpProviderCapabilitiesV2, | ||
| 3985 | deferFinalizeForBackgroundWork: true, | ||
| 3986 | // Production Grok flags: without settled-soft, a steer soft-cancels. | ||
| 3987 | restartRuntimeAfterInterrupt: true, | ||
| 3988 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 3989 | preserveRuntimeOnSettledInterrupt: true, | ||
| 3990 | extractSubagentUpdate: (toolCall) => | ||
| 3991 | toolCall.toolCallId !== "tool-call-generic-1" | ||
| 3992 | ? undefined | ||
| 3993 | : subagentPhase === "spawn" | ||
| 3994 | ? { | ||
| 3995 | nativeTaskId: "task-generic-1", | ||
| 3996 | prompt: "background subagent", | ||
| 3997 | title: "background subagent", | ||
| 3998 | model: null, | ||
| 3999 | status: "running", | ||
| 4000 | childSessionId: null, | ||
| 4001 | result: null, | ||
| 4002 | } | ||
| 4003 | : { | ||
| 4004 | nativeTaskId: "task-generic-1", | ||
| 4005 | prompt: "", | ||
| 4006 | title: null, | ||
| 4007 | model: null, | ||
| 4008 | status: "completed", | ||
| 4009 | childSessionId: null, | ||
| 4010 | result: "SUB_DONE", | ||
| 4011 | }, | ||
| 4012 | makeRuntime: makeMockRuntime({ | ||
| 4013 | childProcessSpawner, | ||
| 4014 | mockAgentPath, | ||
| 4015 | environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, | ||
| 4016 | protocolEvents, | ||
| 4017 | wrapCancel: (cancel) => | ||
| 4018 | Effect.sync(() => { | ||
| 4019 | cancelCalled = true; | ||
| 4020 | }).pipe(Effect.andThen(cancel)), | ||
| 4021 | wrapRuntime: (runtime) => ({ | ||
| 4022 | ...runtime, | ||
| 4023 | prompt: (payload) => | ||
| 4024 | Effect.gen(function* () { | ||
| 4025 | promptPhases.push("prompt-start"); | ||
| 4026 | const result = yield* runtime.prompt(payload); | ||
| 4027 | promptPhases.push("wire-returned"); | ||
| 4028 | yield* Deferred.succeed(promptWireReturned, undefined); | ||
| 4029 | yield* Deferred.await(releasePromptCompletion); | ||
| 4030 | promptPhases.push("completion-released"); | ||
| 4031 | return result; | ||
| 4032 | }), | ||
| 4033 | }), | ||
| 4034 | }), | ||
| 4035 | }, | ||
| 4036 | fileSystem, | ||
| 4037 | idAllocator, | ||
| 4038 | serverConfig, | ||
| 4039 | }); | ||
| 4040 | const threadId = ThreadId.make("thread-acp-settled-soft-admission-race"); | ||
| 4041 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 4042 | runtimeMode: "full-access", | ||
| 4043 | interactionMode: "default", | ||
| 4044 | cwd: process.cwd(), | ||
| 4045 | }); | ||
| 4046 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 4047 | const runtime = yield* adapter.openSession({ | ||
| 4048 | threadId, | ||
| 4049 | providerSessionId: ProviderSessionId.make( | ||
| 4050 | "provider-session-acp-settled-soft-admission-race", | ||
| 4051 | ), | ||
| 4052 | modelSelection, | ||
| 4053 | runtimePolicy, | ||
| 4054 | }); | ||
| 4055 | const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | ||
| 4056 | yield* runtime.events.pipe( | ||
| 4057 | Stream.runForEach((event) => Queue.offer(events, event)), | ||
| 4058 | Effect.forkScoped, | ||
| 4059 | ); | ||
| 4060 | const providerThread = yield* runtime.ensureThread({ | ||
| 4061 | threadId, | ||
| 4062 | modelSelection, | ||
| 4063 | runtimePolicy, | ||
| 4064 | }); | ||
| 4065 | const now = yield* DateTime.now; | ||
| 4066 | yield* runtime.startTurn( | ||
| 4067 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | ||
| 4068 | ); | ||
| 4069 | yield* Deferred.await(promptWireReturned); | ||
| 4070 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 4071 | Stream.filter( | ||
| 4072 | (event) => | ||
| 4073 | event.direction === "incoming" && | ||
| 4074 | event.stage === "raw" && | ||
| 4075 | typeof event.payload === "string" && | ||
| 4076 | event.payload.includes('"stopReason"'), | ||
| 4077 | ), | ||
| 4078 | Stream.runHead, | ||
| 4079 | ); | ||
| 4080 | assert.deepEqual(promptPhases, ["prompt-start", "wire-returned"]); | ||
| 4081 | |||
| 4082 | const firstProviderTurnId = idAllocator.derive.providerTurn({ | ||
| 4083 | driver: ACP_TEST_DRIVER, | ||
| 4084 | nativeTurnId: "mock-session-1:turn:1", | ||
| 4085 | }); | ||
| 4086 | // Fork interrupt while the adapter-side return is still gated. Release so | ||
| 4087 | // promptWireSettled completes (Effect.tap) before/while the completion | ||
| 4088 | // callback contends for runtimeCallbackPermit. Holding the gate forever | ||
| 4089 | // would also block the wire signal (same Effect resolution), so release | ||
| 4090 | // is required; the assertion is cancel skipped after that race window. | ||
| 4091 | const interruptFiber = yield* runtime | ||
| 4092 | .interruptTurn({ providerThread, providerTurnId: firstProviderTurnId }) | ||
| 4093 | .pipe(Effect.forkScoped); | ||
| 4094 | yield* Deferred.succeed(releasePromptCompletion, undefined); | ||
| 4095 | for (let attempt = 0; attempt < 20; attempt += 1) { | ||
| 4096 | if (promptPhases.includes("completion-released")) break; | ||
| 4097 | yield* Effect.yieldNow; | ||
| 4098 | } | ||
| 4099 | yield* TestClock.adjust("10 seconds"); | ||
| 4100 | yield* Fiber.join(interruptFiber); | ||
| 4101 | |||
| 4102 | assert.includeMembers(promptPhases, [ | ||
| 4103 | "prompt-start", | ||
| 4104 | "wire-returned", | ||
| 4105 | "completion-released", | ||
| 4106 | ]); | ||
| 4107 | assert.isFalse( | ||
| 4108 | cancelCalled, | ||
| 4109 | "settled soft steer must skip session/cancel once the prompt wire has settled", | ||
| 4110 | ); | ||
| 4111 | |||
| 4112 | let firstTerminalStatus: string | null = null; | ||
| 4113 | while (firstTerminalStatus === null) { | ||
| 4114 | const event = yield* Queue.take(events); | ||
| 4115 | if (event.type === "turn.terminal" && event.providerTurnId === firstProviderTurnId) { | ||
| 4116 | firstTerminalStatus = event.status; | ||
| 4117 | } | ||
| 4118 | } | ||
| 4119 | assert.equal(firstTerminalStatus, "interrupted"); | ||
| 4120 | assert.equal(subagentPhase, "spawn"); | ||
| 4121 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 4122 | ); | ||
| 4123 | |||
| 4124 | it.effect( | ||
| 4125 | "does not pin hasPendingBackgroundWork when a late TaskOutput re-reports an in-turn-handled task", | ||
| 4126 | () => | ||
| 4127 | Effect.gen(function* () { | ||
| 4128 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 4129 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 4130 | const idAllocator = yield* IdAllocatorV2; | ||
| 4131 | const path = yield* Path.Path; | ||
| 4132 | const serverConfig = yield* ServerConfig; | ||
| 4133 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 4134 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 4135 | ); | ||
| 4136 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 4137 | const continuationRequests: Array<ProviderContinuationRequest> = []; | ||
| 4138 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 4139 | const adapter = makeAcpAdapterV2({ | ||
| 4140 | instanceId, | ||
| 4141 | flavor: { | ||
| 4142 | driver: ACP_TEST_DRIVER, | ||
| 4143 | capabilities: AcpProviderCapabilitiesV2, | ||
| 4144 | enablePostSettleContinuation: true, | ||
| 4145 | extractBackgroundTaskId: (toolCall) => | ||
| 4146 | toolCall.toolCallId === "tool-call-monitor-1" ? "task-monitor-1" : undefined, | ||
| 4147 | extractBackgroundTaskCompletion: (toolCall) => | ||
| 4148 | toolCall.toolCallId === "tool-call-fetch-1" | ||
| 4149 | ? [ | ||
| 4150 | { | ||
| 4151 | taskId: "task-monitor-1", | ||
| 4152 | status: toolCall.status === "completed" ? "completed" : "running", | ||
| 4153 | appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "", | ||
| 4154 | }, | ||
| 4155 | ] | ||
| 4156 | : [], | ||
| 4157 | makeRuntime: makeMockRuntime({ | ||
| 4158 | childProcessSpawner, | ||
| 4159 | mockAgentPath, | ||
| 4160 | environment: { | ||
| 4161 | T3_ACP_EMIT_IN_TURN_TASKOUTPUT_THEN_LATE_DUPLICATE: "1", | ||
| 4162 | }, | ||
| 4163 | protocolEvents, | ||
| 4164 | }), | ||
| 4165 | }, | ||
| 4166 | fileSystem, | ||
| 4167 | idAllocator, | ||
| 4168 | serverConfig, | ||
| 4169 | continuationRequests: { | ||
| 4170 | offer: (request) => | ||
| 4171 | Effect.sync(() => { | ||
| 4172 | continuationRequests.push(request); | ||
| 4173 | }), | ||
| 4174 | }, | ||
| 4175 | }); | ||
| 4176 | const threadId = ThreadId.make("thread-acp-already-handled-wake-pin"); | ||
| 4177 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 4178 | runtimeMode: "full-access", | ||
| 4179 | interactionMode: "default", | ||
| 4180 | cwd: process.cwd(), | ||
| 4181 | }); | ||
| 4182 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 4183 | const runtime = yield* adapter.openSession({ | ||
| 4184 | threadId, | ||
| 4185 | providerSessionId: ProviderSessionId.make( | ||
| 4186 | "provider-session-acp-already-handled-wake-pin", | ||
| 4187 | ), | ||
| 4188 | modelSelection, | ||
| 4189 | runtimePolicy, | ||
| 4190 | }); | ||
| 4191 | if (runtime.hasPendingBackgroundWork === undefined) { | ||
| 4192 | return yield* Effect.die( | ||
| 4193 | "ACP runtime must expose hasPendingBackgroundWork when post-settle continuation is enabled.", | ||
| 4194 | ); | ||
| 4195 | } | ||
| 4196 | const hasPendingBackgroundWork = runtime.hasPendingBackgroundWork; | ||
| 4197 | const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | ||
| 4198 | yield* runtime.events.pipe( | ||
| 4199 | Stream.runForEach((event) => Queue.offer(events, event)), | ||
| 4200 | Effect.forkScoped, | ||
| 4201 | ); | ||
| 4202 | const providerThread = yield* runtime.ensureThread({ | ||
| 4203 | threadId, | ||
| 4204 | modelSelection, | ||
| 4205 | runtimePolicy, | ||
| 4206 | }); | ||
| 4207 | const now = yield* DateTime.now; | ||
| 4208 | yield* runtime.startTurn( | ||
| 4209 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | ||
| 4210 | ); | ||
| 4211 | const providerTurnId = idAllocator.derive.providerTurn({ | ||
| 4212 | driver: ACP_TEST_DRIVER, | ||
| 4213 | nativeTurnId: "mock-session-1:turn:1", | ||
| 4214 | }); | ||
| 4215 | |||
| 4216 | let terminalStatus: string | null = null; | ||
| 4217 | while (terminalStatus === null) { | ||
| 4218 | const event = yield* Queue.take(events); | ||
| 4219 | if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { | ||
| 4220 | terminalStatus = event.status; | ||
| 4221 | } | ||
| 4222 | } | ||
| 4223 | assert.equal(terminalStatus, "completed"); | ||
| 4224 | |||
| 4225 | // Wait for the late post-finalize duplicate TaskOutput frame. | ||
| 4226 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 4227 | Stream.filter( | ||
| 4228 | (event) => | ||
| 4229 | event.direction === "incoming" && | ||
| 4230 | event.stage === "raw" && | ||
| 4231 | typeof event.payload === "string" && | ||
| 4232 | event.payload.includes("MONITOR_LISTING_TOKEN_LATE"), | ||
| 4233 | ), | ||
| 4234 | Stream.runHead, | ||
| 4235 | ); | ||
| 4236 | yield* Effect.yieldNow; | ||
| 4237 | yield* Effect.yieldNow; | ||
| 4238 | yield* Effect.yieldNow; | ||
| 4239 | |||
| 4240 | assert.lengthOf( | ||
| 4241 | continuationRequests, | ||
| 4242 | 0, | ||
| 4243 | "already-handled late TaskOutput must not open a continuation run", | ||
| 4244 | ); | ||
| 4245 | assert.isFalse( | ||
| 4246 | yield* hasPendingBackgroundWork, | ||
| 4247 | "wake buffer must not stay non-empty and pin idle release after an already-handled re-report", | ||
| 4248 | ); | ||
| 4249 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 4250 | ); | ||
| 4251 | |||
| 4252 | it.effect( | ||
| 4253 | "holds a settled turn until the injected monitor report streams instead of finalizing into it", | ||
| 4254 | () => | ||
| 4255 | Effect.gen(function* () { | ||
| 4256 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 4257 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 4258 | const idAllocator = yield* IdAllocatorV2; | ||
| 4259 | const path = yield* Path.Path; | ||
| 4260 | const serverConfig = yield* ServerConfig; | ||
| 4261 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 4262 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 4263 | ); | ||
| 4264 | const triggerDir = yield* fileSystem.makeTempDirectoryScoped(); | ||
| 4265 | const triggerPath = path.join(triggerDir, "report-trigger"); | ||
| 4266 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 4267 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 4268 | const adapter = makeAcpAdapterV2({ | ||
| 4269 | instanceId, | ||
| 4270 | flavor: { | ||
| 4271 | driver: ACP_TEST_DRIVER, | ||
| 4272 | capabilities: AcpProviderCapabilitiesV2, | ||
| 4273 | deferFinalizeForBackgroundWork: true, | ||
| 4274 | extractBackgroundTaskId: (toolCall) => | ||
| 4275 | toolCall.toolCallId === "tool-call-monitor-1" ? "task-monitor-1" : undefined, | ||
| 4276 | extractBackgroundToolMutation: (text) => | ||
| 4277 | text.includes('Monitor "task-monitor-1" ended') | ||
| 4278 | ? [{ taskId: "task-monitor-1", status: "completed", appendOutput: "" }] | ||
| 4279 | : [], | ||
| 4280 | extractBackgroundTaskCompletion: (toolCall) => | ||
| 4281 | toolCall.toolCallId === "tool-call-fetch-1" | ||
| 4282 | ? [ | ||
| 4283 | { | ||
| 4284 | taskId: "task-monitor-1", | ||
| 4285 | status: toolCall.status === "completed" ? "completed" : "running", | ||
| 4286 | appendOutput: toolCall.status === "completed" ? "MONITOR_LISTING_TOKEN" : "", | ||
| 4287 | }, | ||
| 4288 | ] | ||
| 4289 | : [], | ||
| 4290 | makeRuntime: makeMockRuntime({ | ||
| 4291 | childProcessSpawner, | ||
| 4292 | mockAgentPath, | ||
| 4293 | environment: { | ||
| 4294 | T3_ACP_EMIT_POST_SETTLE_MONITOR_FLOW: "1", | ||
| 4295 | T3_ACP_INJECTED_REPORT_TRIGGER_PATH: triggerPath, | ||
| 4296 | }, | ||
| 4297 | protocolEvents, | ||
| 4298 | }), | ||
| 4299 | }, | ||
| 4300 | fileSystem, | ||
| 4301 | idAllocator, | ||
| 4302 | serverConfig, | ||
| 4303 | }); | ||
| 4304 | const threadId = ThreadId.make("thread-acp-injected-report-hold"); | ||
| 4305 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 4306 | runtimeMode: "full-access", | ||
| 4307 | interactionMode: "default", | ||
| 4308 | cwd: process.cwd(), | ||
| 4309 | }); | ||
| 4310 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 4311 | const runtime = yield* adapter.openSession({ | ||
| 4312 | threadId, | ||
| 4313 | providerSessionId: ProviderSessionId.make("provider-session-acp-injected-report"), | ||
| 4314 | modelSelection, | ||
| 4315 | runtimePolicy, | ||
| 4316 | }); | ||
| 4317 | const events = yield* Queue.unbounded<ProviderAdapterV2Event>(); | ||
| 4318 | yield* runtime.events.pipe( | ||
| 4319 | Stream.runForEach((event) => Queue.offer(events, event)), | ||
| 4320 | Effect.forkScoped, | ||
| 4321 | ); | ||
| 4322 | const providerThread = yield* runtime.ensureThread({ | ||
| 4323 | threadId, | ||
| 4324 | modelSelection, | ||
| 4325 | runtimePolicy, | ||
| 4326 | }); | ||
| 4327 | const now = yield* DateTime.now; | ||
| 4328 | yield* runtime.startTurn( | ||
| 4329 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), | ||
| 4330 | ); | ||
| 4331 | const providerTurnId = idAllocator.derive.providerTurn({ | ||
| 4332 | driver: ACP_TEST_DRIVER, | ||
| 4333 | nativeTurnId: "mock-session-1:turn:1", | ||
| 4334 | }); | ||
| 4335 | |||
| 4336 | // Wait (real time) until the post-settle end notice and TaskOutput | ||
| 4337 | // hydration are ingested: the hydrated monitor card carries the | ||
| 4338 | // fetched listing. | ||
| 4339 | let reportSeen = false; | ||
| 4340 | const trackReport = (event: ProviderAdapterV2Event): void => { | ||
| 4341 | if ( | ||
| 4342 | event.type === "turn_item.updated" && | ||
| 4343 | event.turnItem.type === "assistant_message" && | ||
| 4344 | event.turnItem.text.includes("MONITOR_REPORT_TOKEN") | ||
| 4345 | ) { | ||
| 4346 | reportSeen = true; | ||
| 4347 | } | ||
| 4348 | }; | ||
| 4349 | let hydrated = false; | ||
| 4350 | while (!hydrated) { | ||
| 4351 | const event = yield* Queue.take(events); | ||
| 4352 | if ( | ||
| 4353 | event.type === "turn_item.updated" && | ||
| 4354 | event.turnItem.type === "command_execution" && | ||
| 4355 | event.turnItem.status === "completed" && | ||
| 4356 | (event.turnItem.output ?? "").includes("MONITOR_LISTING_TOKEN") | ||
| 4357 | ) { | ||
| 4358 | hydrated = true; | ||
| 4359 | } | ||
| 4360 | } | ||
| 4361 | yield* Effect.yieldNow; | ||
| 4362 | yield* Effect.yieldNow; | ||
| 4363 | yield* Effect.yieldNow; | ||
| 4364 | |||
| 4365 | // Pre-fix the 2s deferred-finalize debounce fires here and the report | ||
| 4366 | // streamed by the injected turn afterwards is dropped on the floor. | ||
| 4367 | yield* TestClock.adjust("3 seconds"); | ||
| 4368 | yield* Effect.yieldNow; | ||
| 4369 | yield* Effect.yieldNow; | ||
| 4370 | yield* Effect.yieldNow; | ||
| 4371 | let drained = yield* Queue.poll(events); | ||
| 4372 | while (Option.isSome(drained)) { | ||
| 4373 | trackReport(drained.value); | ||
| 4374 | assert.notEqual( | ||
| 4375 | drained.value.type, | ||
| 4376 | "turn.terminal", | ||
| 4377 | "deferred finalize must hold while the injected-turn report is owed", | ||
| 4378 | ); | ||
| 4379 | drained = yield* Queue.poll(events); | ||
| 4380 | } | ||
| 4381 | |||
| 4382 | // Release the report, then the normal debounce finalizes the turn. | ||
| 4383 | yield* fileSystem.writeFileString(triggerPath, "go"); | ||
| 4384 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 4385 | Stream.filter( | ||
| 4386 | (event) => | ||
| 4387 | event.direction === "incoming" && | ||
| 4388 | event.stage === "raw" && | ||
| 4389 | typeof event.payload === "string" && | ||
| 4390 | event.payload.includes("MONITOR_REPORT_TOKEN"), | ||
| 4391 | ), | ||
| 4392 | Stream.runHead, | ||
| 4393 | ); | ||
| 4394 | yield* Effect.yieldNow; | ||
| 4395 | yield* Effect.yieldNow; | ||
| 4396 | yield* Effect.yieldNow; | ||
| 4397 | yield* TestClock.adjust("3 seconds"); | ||
| 4398 | |||
| 4399 | let terminalStatus: string | null = null; | ||
| 4400 | while (terminalStatus === null) { | ||
| 4401 | const event = yield* Queue.take(events); | ||
| 4402 | trackReport(event); | ||
| 4403 | if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { | ||
| 4404 | terminalStatus = event.status; | ||
| 4405 | } | ||
| 4406 | } | ||
| 4407 | assert.equal(terminalStatus, "completed"); | ||
| 4408 | assert.isTrue( | ||
| 4409 | reportSeen, | ||
| 4410 | "the injected-turn report must project before the turn finalizes", | ||
| 4411 | ); | ||
| 4412 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 4413 | ); | ||
| 4414 | |||
| 4415 | it.effect("restarts the ACP child process before the next prompt after interrupt", () => | ||
| 4416 | Effect.gen(function* () { | ||
| 4417 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 4418 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 4419 | const idAllocator = yield* IdAllocatorV2; | ||
| 4420 | const path = yield* Path.Path; | ||
| 4421 | const serverConfig = yield* ServerConfig; | ||
| 4422 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 4423 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 4424 | ); | ||
| 4425 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 4426 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 4427 | const adapter = makeAcpAdapterV2({ | ||
| 4428 | instanceId, | ||
| 4429 | flavor: { | ||
| 4430 | driver: ACP_TEST_DRIVER, | ||
| 4431 | capabilities: AcpProviderCapabilitiesV2, | ||
| 4432 | restartRuntimeAfterInterrupt: true, | ||
| 4433 | makeRuntime: makeMockRuntime({ | ||
| 4434 | childProcessSpawner, | ||
| 4435 | mockAgentPath, | ||
| 4436 | protocolEvents, | ||
| 4437 | }), | ||
| 4438 | }, | ||
| 4439 | fileSystem, | ||
| 4440 | idAllocator, | ||
| 4441 | serverConfig, | ||
| 4442 | }); | ||
| 4443 | const threadId = ThreadId.make("thread-acp-restart-after-interrupt"); | ||
| 4444 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 4445 | runtimeMode: "full-access", | ||
| 4446 | interactionMode: "default", | ||
| 4447 | cwd: process.cwd(), | ||
| 4448 | }); | ||
| 4449 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 4450 | const runtime = yield* adapter.openSession({ | ||
| 4451 | threadId, | ||
| 4452 | providerSessionId: ProviderSessionId.make("provider-session-acp-restart-after-interrupt"), | ||
| 4453 | modelSelection, | ||
| 4454 | runtimePolicy, | ||
| 4455 | }); | ||
| 4456 | const providerThread = yield* runtime.ensureThread({ | ||
| 4457 | threadId, | ||
| 4458 | modelSelection, | ||
| 4459 | runtimePolicy, | ||
| 4460 | }); | ||
| 4461 | const now = yield* DateTime.now; | ||
| 4462 | yield* runtime.startTurn( | ||
| 4463 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | ||
| 4464 | ); | ||
| 4465 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 4466 | Stream.filter( | ||
| 4467 | (event) => | ||
| 4468 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | ||
| 4469 | ), | ||
| 4470 | Stream.runHead, | ||
| 4471 | ); | ||
| 4472 | const providerTurnId = idAllocator.derive.providerTurn({ | ||
| 4473 | driver: ACP_TEST_DRIVER, | ||
| 4474 | nativeTurnId: `${providerThread.nativeThreadRef?.nativeId}:turn:1`, | ||
| 4475 | }); | ||
| 4476 | yield* runtime.interruptTurn({ | ||
| 4477 | providerThread, | ||
| 4478 | providerTurnId, | ||
| 4479 | requestRuntimeRestart: true, | ||
| 4480 | }); | ||
| 4481 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 4482 | Stream.filter( | ||
| 4483 | (event) => | ||
| 4484 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/cancel", | ||
| 4485 | ), | ||
| 4486 | Stream.runHead, | ||
| 4487 | ); | ||
| 4488 | |||
| 4489 | yield* runtime.startTurn( | ||
| 4490 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), | ||
| 4491 | ); | ||
| 4492 | const loadAfterRestart = yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 4493 | Stream.filter( | ||
| 4494 | (event) => event.direction === "outgoing" && rawProtocolMethod(event) === "session/load", | ||
| 4495 | ), | ||
| 4496 | Stream.runHead, | ||
| 4497 | ); | ||
| 4498 | assert.isTrue( | ||
| 4499 | Option.isSome(loadAfterRestart), | ||
| 4500 | "post-interrupt startTurn should respawn the runtime and replay session/load", | ||
| 4501 | ); | ||
| 4502 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 4503 | ); | ||
| 4504 | |||
| 4505 | it.effect("Windows teardown is one-shot explicitly with independent finalizer cleanup", () => | ||
| 4506 | Effect.gen(function* () { | ||
| 4507 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 4508 | const path = yield* Path.Path; | ||
| 4509 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 4510 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 4511 | ); | ||
| 4512 | const runtimeScope = yield* Scope.make(); | ||
| 4513 | const taskkillCommands: Array<{ | ||
| 4514 | readonly command: string; | ||
| 4515 | readonly args: ReadonlyArray<string>; | ||
| 4516 | }> = []; | ||
| 4517 | const taskkillSpawner = makeTaskkillSpawner({ exitCode: 0, commands: taskkillCommands }); | ||
| 4518 | const spawner = ChildProcessSpawner.make((command) => { | ||
| 4519 | const value = command as unknown as { readonly command: string }; | ||
| 4520 | return value.command === "taskkill" | ||
| 4521 | ? taskkillSpawner.spawn(command) | ||
| 4522 | : childProcessSpawner.spawn(command); | ||
| 4523 | }); | ||
| 4524 | const context = yield* Layer.build( | ||
| 4525 | AcpSessionRuntime.layer({ | ||
| 4526 | spawn: { | ||
| 4527 | command: process.execPath, | ||
| 4528 | args: [mockAgentPath], | ||
| 4529 | cwd: process.cwd(), | ||
| 4530 | env: { T3_ACP_SESSION_LIFECYCLE: "1" }, | ||
| 4531 | }, | ||
| 4532 | cwd: process.cwd(), | ||
| 4533 | clientInfo: { name: "t3-acp-test", version: "0.0.0" }, | ||
| 4534 | ownDetachedProcessGroup: true, | ||
| 4535 | processGroupPlatform: "win32", | ||
| 4536 | }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))), | ||
| 4537 | ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); | ||
| 4538 | const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | ||
| 4539 | Effect.provide(context), | ||
| 4540 | ); | ||
| 4541 | assert.isDefined(runtime.terminateProcessGroup); | ||
| 4542 | yield* Effect.all([runtime.terminateProcessGroup!, runtime.terminateProcessGroup!], { | ||
| 4543 | concurrency: "unbounded", | ||
| 4544 | }); | ||
| 4545 | assert.equal(taskkillCommands.length, 1); | ||
| 4546 | yield* Scope.close(runtimeScope, Exit.void); | ||
| 4547 | assert.equal(taskkillCommands.length, 1); | ||
| 4548 | }).pipe(Effect.provide(testLayer)), | ||
| 4549 | ); | ||
| 4550 | |||
| 4551 | it("accepts only taskkill exit code zero as successful tree termination", () => { | ||
| 4552 | assert.isTrue(AcpSessionRuntime.windowsTaskkillResultIsSuccess(0, "")); | ||
| 4553 | assert.isFalse( | ||
| 4554 | AcpSessionRuntime.windowsTaskkillResultIsSuccess( | ||
| 4555 | 128, | ||
| 4556 | "FEHLER: Der Prozess wurde nicht gefunden.", | ||
| 4557 | ), | ||
| 4558 | ); | ||
| 4559 | assert.isFalse(AcpSessionRuntime.windowsTaskkillResultIsSuccess(128, "")); | ||
| 4560 | assert.isFalse(AcpSessionRuntime.windowsTaskkillResultIsSuccess(1, "localized failure")); | ||
| 4561 | assert.isFalse(AcpSessionRuntime.windowsTaskkillResultIsSuccess(255, "")); | ||
| 4562 | }); | ||
| 4563 | |||
| 4564 | it.effect("runs the default taskkill path and preserves every failure mode", () => | ||
| 4565 | Effect.gen(function* () { | ||
| 4566 | const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = | ||
| 4567 | []; | ||
| 4568 | yield* AcpSessionRuntime.terminateWindowsProcessTreeWithTaskkill( | ||
| 4569 | makeTaskkillSpawner({ exitCode: 0, commands }), | ||
| 4570 | 4321, | ||
| 4571 | ); | ||
| 4572 | assert.deepEqual(commands, [{ command: "taskkill", args: ["/PID", "4321", "/T", "/F"] }]); | ||
| 4573 | |||
| 4574 | for (const fixture of [ | ||
| 4575 | { exitCode: 128, output: 'ERROR: The process "4321" not found.' }, | ||
| 4576 | { exitCode: 128, output: "FEHLER: Prozess nicht gefunden." }, | ||
| 4577 | { exitCode: 128, output: "" }, | ||
| 4578 | { exitCode: 128, output: "ERROR: Access is denied." }, | ||
| 4579 | { exitCode: 1, output: "generic failure" }, | ||
| 4580 | ]) { | ||
| 4581 | const failed = yield* AcpSessionRuntime.terminateWindowsProcessTreeWithTaskkill( | ||
| 4582 | makeTaskkillSpawner(fixture), | ||
| 4583 | 4321, | ||
| 4584 | ).pipe(Effect.exit); | ||
| 4585 | if (Exit.isSuccess(failed)) assert.fail(`taskkill ${fixture.exitCode} must fail`); | ||
| 4586 | const error = Cause.squash(failed.cause); | ||
| 4587 | assert.instanceOf(error, AcpSessionRuntime.AcpProcessGroupTerminationError); | ||
| 4588 | const termination = error as AcpSessionRuntime.AcpProcessGroupTerminationError; | ||
| 4589 | assert.equal( | ||
| 4590 | termination.detail, | ||
| 4591 | `taskkill exited ${fixture.exitCode} for ACP process tree 4321`, | ||
| 4592 | ); | ||
| 4593 | assert.equal(termination.pid, 4321); | ||
| 4594 | assert.equal(termination.exitCode, fixture.exitCode); | ||
| 4595 | if (fixture.output.length > 0) { | ||
| 4596 | assert.equal(termination.cause, fixture.output); | ||
| 4597 | } | ||
| 4598 | } | ||
| 4599 | |||
| 4600 | for (const fixture of [ | ||
| 4601 | { spawnFailure: true }, | ||
| 4602 | { outputFailure: true }, | ||
| 4603 | { exitFailure: true }, | ||
| 4604 | ]) { | ||
| 4605 | const failed = yield* AcpSessionRuntime.terminateWindowsProcessTreeWithTaskkill( | ||
| 4606 | makeTaskkillSpawner(fixture), | ||
| 4607 | 4321, | ||
| 4608 | ).pipe(Effect.exit); | ||
| 4609 | if (Exit.isSuccess(failed)) assert.fail("taskkill infrastructure failure must fail"); | ||
| 4610 | const error = Cause.squash(failed.cause); | ||
| 4611 | assert.instanceOf(error, AcpSessionRuntime.AcpProcessGroupTerminationError); | ||
| 4612 | assert.equal( | ||
| 4613 | (error as AcpSessionRuntime.AcpProcessGroupTerminationError).detail, | ||
| 4614 | "Failed to run taskkill for ACP process tree 4321", | ||
| 4615 | ); | ||
| 4616 | } | ||
| 4617 | }), | ||
| 4618 | ); | ||
| 4619 | |||
| 4620 | it.effect("Windows process-tree teardown surfaces taskkill failure", () => | ||
| 4621 | Effect.gen(function* () { | ||
| 4622 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 4623 | const path = yield* Path.Path; | ||
| 4624 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 4625 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 4626 | ); | ||
| 4627 | const context = yield* Layer.build( | ||
| 4628 | AcpSessionRuntime.layer({ | ||
| 4629 | spawn: { command: process.execPath, args: [mockAgentPath], cwd: process.cwd() }, | ||
| 4630 | cwd: process.cwd(), | ||
| 4631 | clientInfo: { name: "t3-acp-test", version: "0.0.0" }, | ||
| 4632 | ownDetachedProcessGroup: true, | ||
| 4633 | processGroupPlatform: "win32", | ||
| 4634 | windowsProcessTreeTerminator: () => | ||
| 4635 | Effect.fail( | ||
| 4636 | new AcpSessionRuntime.AcpProcessGroupTerminationError({ | ||
| 4637 | detail: "mock taskkill failure", | ||
| 4638 | }), | ||
| 4639 | ), | ||
| 4640 | }).pipe( | ||
| 4641 | Layer.provide( | ||
| 4642 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), | ||
| 4643 | ), | ||
| 4644 | ), | ||
| 4645 | ); | ||
| 4646 | const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | ||
| 4647 | Effect.provide(context), | ||
| 4648 | ); | ||
| 4649 | const error = yield* Effect.flip(runtime.terminateProcessGroup!); | ||
| 4650 | assert.equal(error.detail, "mock taskkill failure"); | ||
| 4651 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 4652 | ); | ||
| 4653 | |||
| 4654 | it.live("poisons the session when hard teardown defects and blocks replacement work", () => | ||
| 4655 | Effect.gen(function* () { | ||
| 4656 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 4657 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 4658 | const idAllocator = yield* IdAllocatorV2; | ||
| 4659 | const path = yield* Path.Path; | ||
| 4660 | const serverConfig = yield* ServerConfig; | ||
| 4661 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 4662 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 4663 | ); | ||
| 4664 | const commandPidPath = yield* fileSystem.makeTempFileScoped({ | ||
| 4665 | prefix: "t3-acp-failed-teardown-command-", | ||
| 4666 | }); | ||
| 4667 | const residualCallbackDir = yield* fileSystem.makeTempDirectoryScoped(); | ||
| 4668 | const residualCallbackResponseLogPath = path.join(residualCallbackDir, "responses.log"); | ||
| 4669 | const residualCallbackTriggerPath = path.join(residualCallbackDir, "trigger"); | ||
| 4670 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 4671 | const continuationRequests: Array<ProviderContinuationRequest> = []; | ||
| 4672 | const teardownStarted = yield* Deferred.make<void>(); | ||
| 4673 | const releaseTeardown = yield* Deferred.make<void>(); | ||
| 4674 | let terminatorCallCount = 0; | ||
| 4675 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 4676 | const adapter = makeAcpAdapterV2({ | ||
| 4677 | instanceId, | ||
| 4678 | flavor: { | ||
| 4679 | driver: ACP_TEST_DRIVER, | ||
| 4680 | capabilities: AcpProviderCapabilitiesV2, | ||
| 4681 | enablePostSettleContinuation: true, | ||
| 4682 | restartRuntimeAfterInterrupt: true, | ||
| 4683 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 4684 | makeRuntime: makeMockRuntime({ | ||
| 4685 | childProcessSpawner, | ||
| 4686 | mockAgentPath, | ||
| 4687 | ownDetachedProcessGroup: true, | ||
| 4688 | processGroupPlatform: "win32", | ||
| 4689 | windowsProcessTreeTerminator: () => | ||
| 4690 | Effect.gen(function* () { | ||
| 4691 | terminatorCallCount += 1; | ||
| 4692 | yield* Deferred.succeed(teardownStarted, undefined); | ||
| 4693 | yield* Deferred.await(releaseTeardown); | ||
| 4694 | return yield* Effect.die("mock taskkill defect"); | ||
| 4695 | }), | ||
| 4696 | environment: { | ||
| 4697 | T3_ACP_EMIT_RUNNING_COMMAND_THEN_HANG: "1", | ||
| 4698 | T3_ACP_RESIDUAL_CALLBACK_RESPONSE_LOG_PATH: residualCallbackResponseLogPath, | ||
| 4699 | T3_ACP_RESIDUAL_CALLBACK_TRIGGER_PATH: residualCallbackTriggerPath, | ||
| 4700 | T3_ACP_RUNNING_COMMAND_PID_PATH: commandPidPath, | ||
| 4701 | }, | ||
| 4702 | protocolEvents, | ||
| 4703 | }), | ||
| 4704 | }, | ||
| 4705 | fileSystem, | ||
| 4706 | idAllocator, | ||
| 4707 | serverConfig, | ||
| 4708 | continuationRequests: { | ||
| 4709 | offer: (request) => | ||
| 4710 | Effect.sync(() => { | ||
| 4711 | continuationRequests.push(request); | ||
| 4712 | }), | ||
| 4713 | }, | ||
| 4714 | }); | ||
| 4715 | const threadId = ThreadId.make("thread-acp-failed-hard-teardown"); | ||
| 4716 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 4717 | runtimeMode: "full-access", | ||
| 4718 | interactionMode: "default", | ||
| 4719 | cwd: process.cwd(), | ||
| 4720 | }); | ||
| 4721 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 4722 | const sessionScope = yield* Scope.make(); | ||
| 4723 | const runtime = yield* adapter | ||
| 4724 | .openSession({ | ||
| 4725 | threadId, | ||
| 4726 | providerSessionId: ProviderSessionId.make("provider-session-acp-failed-hard-teardown"), | ||
| 4727 | modelSelection, | ||
| 4728 | runtimePolicy, | ||
| 4729 | }) | ||
| 4730 | .pipe(Effect.provideService(Scope.Scope, sessionScope)); | ||
| 4731 | const adapterEvents = yield* Queue.unbounded<ProviderAdapterV2Event>(); | ||
| 4732 | yield* runtime.events.pipe( | ||
| 4733 | Stream.runForEach((event) => Queue.offer(adapterEvents, event)), | ||
| 4734 | Effect.forkIn(sessionScope), | ||
| 4735 | ); | ||
| 4736 | const providerThread = yield* runtime.ensureThread({ | ||
| 4737 | threadId, | ||
| 4738 | modelSelection, | ||
| 4739 | runtimePolicy, | ||
| 4740 | }); | ||
| 4741 | const now = yield* DateTime.now; | ||
| 4742 | yield* runtime | ||
| 4743 | .startTurn( | ||
| 4744 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | ||
| 4745 | ) | ||
| 4746 | .pipe(Effect.forkScoped); | ||
| 4747 | while ((yield* fileSystem.readFileString(commandPidPath)).trim().length === 0) { | ||
| 4748 | yield* Effect.yieldNow; | ||
| 4749 | } | ||
| 4750 | const [commandRootPid, commandSleepPid] = (yield* fileSystem.readFileString(commandPidPath)) | ||
| 4751 | .trim() | ||
| 4752 | .split(/\s+/) | ||
| 4753 | .map(Number); | ||
| 4754 | assert.isTrue( | ||
| 4755 | Option.isSome(yield* waitForProcesses([commandRootPid!, commandSleepPid!])), | ||
| 4756 | "declared failed-teardown Bash and sleep PIDs must both become live", | ||
| 4757 | ); | ||
| 4758 | yield* pollProtocolMethods(protocolEvents); | ||
| 4759 | |||
| 4760 | const providerTurnId = idAllocator.derive.providerTurn({ | ||
| 4761 | driver: ACP_TEST_DRIVER, | ||
| 4762 | nativeTurnId: "mock-session-1:turn:1", | ||
| 4763 | }); | ||
| 4764 | const interruptFiber = yield* runtime | ||
| 4765 | .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }) | ||
| 4766 | .pipe(Effect.exit, Effect.forkScoped); | ||
| 4767 | yield* Deferred.await(teardownStarted); | ||
| 4768 | const startFiber = yield* runtime | ||
| 4769 | .startTurn( | ||
| 4770 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), | ||
| 4771 | ) | ||
| 4772 | .pipe(Effect.exit, Effect.forkScoped); | ||
| 4773 | yield* Effect.yieldNow; | ||
| 4774 | yield* Effect.yieldNow; | ||
| 4775 | assert.isUndefined(startFiber.pollUnsafe()); | ||
| 4776 | const methodsDuringTeardown = yield* pollProtocolMethods(protocolEvents); | ||
| 4777 | yield* Deferred.succeed(releaseTeardown, undefined); | ||
| 4778 | assert.notInclude(methodsDuringTeardown, "session/load"); | ||
| 4779 | assert.notInclude(methodsDuringTeardown, "session/prompt"); | ||
| 4780 | |||
| 4781 | const interruptExit = yield* Fiber.join(interruptFiber); | ||
| 4782 | if (Exit.isSuccess(interruptExit)) assert.fail("hard teardown failure must fail interrupt"); | ||
| 4783 | assert.include(Cause.pretty(interruptExit.cause), "session is poisoned"); | ||
| 4784 | assert.isTrue( | ||
| 4785 | Option.isSome(yield* waitForProcesses([commandRootPid!, commandSleepPid!])), | ||
| 4786 | "failed teardown must leave both declared Bash and sleep PIDs live", | ||
| 4787 | ); | ||
| 4788 | |||
| 4789 | while (Option.isSome(yield* Queue.poll(adapterEvents))) { | ||
| 4790 | // Discard the interrupted turn's expected projection before residual traffic. | ||
| 4791 | } | ||
| 4792 | yield* fileSystem.writeFileString(residualCallbackTriggerPath, "go"); | ||
| 4793 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 4794 | Stream.filter( | ||
| 4795 | (event) => | ||
| 4796 | event.direction === "incoming" && | ||
| 4797 | event.stage === "raw" && | ||
| 4798 | typeof event.payload === "string" && | ||
| 4799 | event.payload.includes('"method":"session/elicitation"'), | ||
| 4800 | ), | ||
| 4801 | Stream.runHead, | ||
| 4802 | ); | ||
| 4803 | yield* Effect.sleep("100 millis"); | ||
| 4804 | assert.isTrue( | ||
| 4805 | Option.isNone(yield* Queue.poll(adapterEvents)), | ||
| 4806 | "residual callbacks must not mutate or emit adapter projection after poison", | ||
| 4807 | ); | ||
| 4808 | assert.isFalse( | ||
| 4809 | yield* fileSystem.exists(residualCallbackResponseLogPath), | ||
| 4810 | "permission and elicitation callbacks must remain unresolved after poison", | ||
| 4811 | ); | ||
| 4812 | assert.lengthOf( | ||
| 4813 | continuationRequests, | ||
| 4814 | 0, | ||
| 4815 | "residual callbacks must not offer a continuation after poison", | ||
| 4816 | ); | ||
| 4817 | |||
| 4818 | const startExit = yield* Fiber.join(startFiber); | ||
| 4819 | if (Exit.isSuccess(startExit)) assert.fail("poisoned session must reject startTurn"); | ||
| 4820 | assert.include(Cause.pretty(startExit.cause), "session is poisoned"); | ||
| 4821 | const resumeExit = yield* runtime | ||
| 4822 | .resumeThread({ providerThread, modelSelection, runtimePolicy }) | ||
| 4823 | .pipe(Effect.exit); | ||
| 4824 | if (Exit.isSuccess(resumeExit)) assert.fail("poisoned session must reject resumeThread"); | ||
| 4825 | assert.include(Cause.pretty(resumeExit.cause), "session is poisoned"); | ||
| 4826 | const snapshotExit = yield* runtime.readThreadSnapshot({ providerThread }).pipe(Effect.exit); | ||
| 4827 | if (Exit.isSuccess(snapshotExit)) { | ||
| 4828 | assert.fail("poisoned session must reject readThreadSnapshot"); | ||
| 4829 | } | ||
| 4830 | assert.include(Cause.pretty(snapshotExit.cause), "session is poisoned"); | ||
| 4831 | const forkExit = yield* runtime | ||
| 4832 | .forkThread({ | ||
| 4833 | sourceProviderThread: providerThread, | ||
| 4834 | targetThreadId: ThreadId.make("thread-acp-poisoned-fork"), | ||
| 4835 | }) | ||
| 4836 | .pipe(Effect.exit); | ||
| 4837 | if (Exit.isSuccess(forkExit)) assert.fail("poisoned session must reject forkThread"); | ||
| 4838 | assert.include(Cause.pretty(forkExit.cause), "session is poisoned"); | ||
| 4839 | yield* pollProtocolMethods(protocolEvents); | ||
| 4840 | const retryInterruptExit = yield* runtime | ||
| 4841 | .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }) | ||
| 4842 | .pipe(Effect.exit); | ||
| 4843 | if (Exit.isSuccess(retryInterruptExit)) { | ||
| 4844 | assert.fail("poisoned session must reject a repeated hard interrupt"); | ||
| 4845 | } | ||
| 4846 | const firstInterruptError = Cause.squash(interruptExit.cause) as Error; | ||
| 4847 | const retryInterruptError = Cause.squash(retryInterruptExit.cause) as Error; | ||
| 4848 | assert.strictEqual(retryInterruptError.cause, firstInterruptError.cause); | ||
| 4849 | assert.equal(terminatorCallCount, 1); | ||
| 4850 | const methodsAfterPoison = yield* pollProtocolMethods(protocolEvents); | ||
| 4851 | assert.notInclude(methodsAfterPoison, "initialize"); | ||
| 4852 | assert.notInclude(methodsAfterPoison, "session/cancel"); | ||
| 4853 | assert.notInclude(methodsAfterPoison, "session/fork"); | ||
| 4854 | assert.notInclude(methodsAfterPoison, "session/load"); | ||
| 4855 | assert.notInclude(methodsAfterPoison, "session/prompt"); | ||
| 4856 | assert.isTrue(processExists(commandRootPid!)); | ||
| 4857 | assert.isTrue(processExists(commandSleepPid!)); | ||
| 4858 | yield* Scope.close(sessionScope, Exit.void); | ||
| 4859 | assert.isTrue( | ||
| 4860 | Option.isSome(yield* waitForProcessesToExit([commandRootPid!, commandSleepPid!])), | ||
| 4861 | "finalizer cleanup must reap both declared Bash and sleep PIDs", | ||
| 4862 | ); | ||
| 4863 | assert.isFalse(processExists(commandRootPid!)); | ||
| 4864 | assert.isFalse(processExists(commandSleepPid!)); | ||
| 4865 | const finalizerMethods = yield* pollProtocolMethods(protocolEvents); | ||
| 4866 | assert.notInclude(finalizerMethods, "session/close"); | ||
| 4867 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 4868 | ); | ||
| 4869 | |||
| 4870 | it.live("durably poisons start and resume when required hard teardown is unavailable", () => | ||
| 4871 | Effect.gen(function* () { | ||
| 4872 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 4873 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 4874 | const idAllocator = yield* IdAllocatorV2; | ||
| 4875 | const path = yield* Path.Path; | ||
| 4876 | const serverConfig = yield* ServerConfig; | ||
| 4877 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 4878 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 4879 | ); | ||
| 4880 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 4881 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 4882 | const adapter = makeAcpAdapterV2({ | ||
| 4883 | instanceId, | ||
| 4884 | flavor: { | ||
| 4885 | driver: ACP_TEST_DRIVER, | ||
| 4886 | capabilities: AcpProviderCapabilitiesV2, | ||
| 4887 | restartRuntimeAfterInterrupt: true, | ||
| 4888 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 4889 | makeRuntime: makeMockRuntime({ | ||
| 4890 | childProcessSpawner, | ||
| 4891 | mockAgentPath, | ||
| 4892 | environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, | ||
| 4893 | protocolEvents, | ||
| 4894 | }), | ||
| 4895 | }, | ||
| 4896 | fileSystem, | ||
| 4897 | idAllocator, | ||
| 4898 | serverConfig, | ||
| 4899 | }); | ||
| 4900 | const threadId = ThreadId.make("thread-acp-missing-hard-teardown"); | ||
| 4901 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 4902 | runtimeMode: "full-access", | ||
| 4903 | interactionMode: "default", | ||
| 4904 | cwd: process.cwd(), | ||
| 4905 | }); | ||
| 4906 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 4907 | const runtime = yield* adapter.openSession({ | ||
| 4908 | threadId, | ||
| 4909 | providerSessionId: ProviderSessionId.make("provider-session-acp-missing-hard-teardown"), | ||
| 4910 | modelSelection, | ||
| 4911 | runtimePolicy, | ||
| 4912 | }); | ||
| 4913 | const providerThread = yield* runtime.ensureThread({ | ||
| 4914 | threadId, | ||
| 4915 | modelSelection, | ||
| 4916 | runtimePolicy, | ||
| 4917 | }); | ||
| 4918 | const now = yield* DateTime.now; | ||
| 4919 | yield* runtime | ||
| 4920 | .startTurn( | ||
| 4921 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | ||
| 4922 | ) | ||
| 4923 | .pipe(Effect.forkScoped); | ||
| 4924 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 4925 | Stream.filter( | ||
| 4926 | (event) => | ||
| 4927 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | ||
| 4928 | ), | ||
| 4929 | Stream.runHead, | ||
| 4930 | ); | ||
| 4931 | const providerTurnId = idAllocator.derive.providerTurn({ | ||
| 4932 | driver: ACP_TEST_DRIVER, | ||
| 4933 | nativeTurnId: "mock-session-1:turn:1", | ||
| 4934 | }); | ||
| 4935 | const interruptExit = yield* runtime | ||
| 4936 | .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }) | ||
| 4937 | .pipe(Effect.exit); | ||
| 4938 | if (Exit.isSuccess(interruptExit)) assert.fail("missing hard teardown must fail interrupt"); | ||
| 4939 | assert.include(Cause.pretty(interruptExit.cause), "session is poisoned"); | ||
| 4940 | |||
| 4941 | const startExit = yield* runtime | ||
| 4942 | .startTurn( | ||
| 4943 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), | ||
| 4944 | ) | ||
| 4945 | .pipe(Effect.exit); | ||
| 4946 | if (Exit.isSuccess(startExit)) assert.fail("poisoned session must reject startTurn"); | ||
| 4947 | assert.include(Cause.pretty(startExit.cause), "session is poisoned"); | ||
| 4948 | const resumeExit = yield* runtime | ||
| 4949 | .resumeThread({ providerThread, modelSelection, runtimePolicy }) | ||
| 4950 | .pipe(Effect.exit); | ||
| 4951 | if (Exit.isSuccess(resumeExit)) assert.fail("poisoned session must reject resumeThread"); | ||
| 4952 | assert.include(Cause.pretty(resumeExit.cause), "session is poisoned"); | ||
| 4953 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 4954 | ); | ||
| 4955 | |||
| 4956 | it.live("holds concurrent startTurn behind successful hard teardown and reloads once", () => | ||
| 4957 | Effect.gen(function* () { | ||
| 4958 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 4959 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 4960 | const idAllocator = yield* IdAllocatorV2; | ||
| 4961 | const path = yield* Path.Path; | ||
| 4962 | const serverConfig = yield* ServerConfig; | ||
| 4963 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 4964 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 4965 | ); | ||
| 4966 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 4967 | const teardownStarted = yield* Deferred.make<void>(); | ||
| 4968 | const releaseTeardown = yield* Deferred.make<void>(); | ||
| 4969 | let runtimeOrdinalSeen = 0; | ||
| 4970 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 4971 | const adapter = makeAcpAdapterV2({ | ||
| 4972 | instanceId, | ||
| 4973 | flavor: { | ||
| 4974 | driver: ACP_TEST_DRIVER, | ||
| 4975 | capabilities: AcpProviderCapabilitiesV2, | ||
| 4976 | interruptPromptOnCancel: true, | ||
| 4977 | restartRuntimeAfterInterrupt: true, | ||
| 4978 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 4979 | makeRuntime: makeMockRuntime({ | ||
| 4980 | childProcessSpawner, | ||
| 4981 | mockAgentPath, | ||
| 4982 | ownDetachedProcessGroup: true, | ||
| 4983 | processGroupPlatform: "win32", | ||
| 4984 | windowsProcessTreeTerminator: () => | ||
| 4985 | Deferred.succeed(teardownStarted, undefined).pipe( | ||
| 4986 | Effect.andThen(Deferred.await(releaseTeardown)), | ||
| 4987 | ), | ||
| 4988 | environment: (runtimeOrdinal) => { | ||
| 4989 | runtimeOrdinalSeen = runtimeOrdinal; | ||
| 4990 | return runtimeOrdinal === 1 ? { T3_ACP_HANG_PROMPT_FOREVER: "1" } : {}; | ||
| 4991 | }, | ||
| 4992 | protocolEvents, | ||
| 4993 | }), | ||
| 4994 | }, | ||
| 4995 | fileSystem, | ||
| 4996 | idAllocator, | ||
| 4997 | serverConfig, | ||
| 4998 | }); | ||
| 4999 | const threadId = ThreadId.make("thread-acp-concurrent-hard-teardown"); | ||
| 5000 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 5001 | runtimeMode: "full-access", | ||
| 5002 | interactionMode: "default", | ||
| 5003 | cwd: process.cwd(), | ||
| 5004 | }); | ||
| 5005 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 5006 | const runtime = yield* adapter.openSession({ | ||
| 5007 | threadId, | ||
| 5008 | providerSessionId: ProviderSessionId.make("provider-session-acp-concurrent-hard-teardown"), | ||
| 5009 | modelSelection, | ||
| 5010 | runtimePolicy, | ||
| 5011 | }); | ||
| 5012 | const providerThread = yield* runtime.ensureThread({ | ||
| 5013 | threadId, | ||
| 5014 | modelSelection, | ||
| 5015 | runtimePolicy, | ||
| 5016 | }); | ||
| 5017 | const now = yield* DateTime.now; | ||
| 5018 | yield* runtime | ||
| 5019 | .startTurn( | ||
| 5020 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | ||
| 5021 | ) | ||
| 5022 | .pipe(Effect.forkScoped); | ||
| 5023 | yield* Effect.yieldNow; | ||
| 5024 | yield* Effect.yieldNow; | ||
| 5025 | yield* Effect.yieldNow; | ||
| 5026 | const providerTurnId = idAllocator.derive.providerTurn({ | ||
| 5027 | driver: ACP_TEST_DRIVER, | ||
| 5028 | nativeTurnId: "mock-session-1:turn:1", | ||
| 5029 | }); | ||
| 5030 | yield* pollProtocolMethods(protocolEvents); | ||
| 5031 | const interruptFiber = yield* runtime | ||
| 5032 | .interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }) | ||
| 5033 | .pipe(Effect.forkScoped); | ||
| 5034 | yield* Deferred.await(teardownStarted); | ||
| 5035 | const startFiber = yield* runtime | ||
| 5036 | .startTurn( | ||
| 5037 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), | ||
| 5038 | ) | ||
| 5039 | .pipe(Effect.forkScoped); | ||
| 5040 | yield* Effect.yieldNow; | ||
| 5041 | yield* Effect.yieldNow; | ||
| 5042 | assert.isUndefined(startFiber.pollUnsafe()); | ||
| 5043 | assert.equal(runtimeOrdinalSeen, 1); | ||
| 5044 | const methodsDuringTeardown = yield* pollProtocolMethods(protocolEvents); | ||
| 5045 | assert.notInclude(methodsDuringTeardown, "session/load"); | ||
| 5046 | assert.notInclude(methodsDuringTeardown, "session/prompt"); | ||
| 5047 | |||
| 5048 | yield* Deferred.succeed(releaseTeardown, undefined); | ||
| 5049 | yield* Fiber.join(interruptFiber); | ||
| 5050 | yield* Fiber.join(startFiber); | ||
| 5051 | assert.equal(runtimeOrdinalSeen, 2); | ||
| 5052 | const replacementMethods = yield* pollProtocolMethods(protocolEvents); | ||
| 5053 | assert.equal(replacementMethods.filter((method) => method === "session/load").length, 1); | ||
| 5054 | if (!replacementMethods.includes("session/prompt")) { | ||
| 5055 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 5056 | Stream.filter( | ||
| 5057 | (event) => | ||
| 5058 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | ||
| 5059 | ), | ||
| 5060 | Stream.runHead, | ||
| 5061 | ); | ||
| 5062 | } | ||
| 5063 | }).pipe(Effect.provide(testLayer), Effect.scoped), | ||
| 5064 | ); | ||
| 5065 | |||
| 5066 | it.live("quarantines old-runtime callbacks after successful hard teardown", () => | ||
| 5067 | Effect.gen(function* () { | ||
| 5068 | const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 5069 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 5070 | const idAllocator = yield* IdAllocatorV2; | ||
| 5071 | const path = yield* Path.Path; | ||
| 5072 | const serverConfig = yield* ServerConfig; | ||
| 5073 | const mockAgentPath = yield* path.fromFileUrl( | ||
| 5074 | new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), | ||
| 5075 | ); | ||
| 5076 | const protocolEvents = yield* Queue.bounded<EffectAcpProtocol.AcpProtocolLogEvent>(256); | ||
| 5077 | const continuationRequests: Array<ProviderContinuationRequest> = []; | ||
| 5078 | const responseLifecycle: Array<string> = []; | ||
| 5079 | type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 5080 | type HandlerRecord = { | ||
| 5081 | sessionUpdate?: Parameters<RuntimeService["handleSessionUpdate"]>[0]; | ||
| 5082 | permission?: Parameters<RuntimeService["handleRequestPermission"]>[0]; | ||
| 5083 | elicitation?: Parameters<RuntimeService["handleElicitation"]>[0]; | ||
| 5084 | requestUserInput?: AcpAdapterV2ExtensionContext["requestUserInput"]; | ||
| 5085 | }; | ||
| 5086 | const handlerRecords: HandlerRecord[] = []; | ||
| 5087 | const runtimeInputs: AcpAdapterV2RuntimeInput[] = []; | ||
| 5088 | const oldPromptCompletion = yield* Deferred.make<EffectAcpSchema.PromptResponse>(); | ||
| 5089 | const transportDrained = yield* Deferred.make<void>(); | ||
| 5090 | const releaseTransportDrain = yield* Deferred.make<void>(); | ||
| 5091 | let extensionOrdinal = 0; | ||
| 5092 | let runtimeOrdinalSeen = 0; | ||
| 5093 | const instanceId = ProviderInstanceId.make("acp-test"); | ||
| 5094 | const makeRuntime = makeMockRuntime({ | ||
| 5095 | childProcessSpawner, | ||
| 5096 | mockAgentPath, | ||
| 5097 | ownDetachedProcessGroup: true, | ||
| 5098 | processGroupPlatform: "win32", | ||
| 5099 | windowsProcessTreeTerminator: () => Effect.void, | ||
| 5100 | environment: (runtimeOrdinal) => { | ||
| 5101 | runtimeOrdinalSeen = runtimeOrdinal; | ||
| 5102 | return { T3_ACP_HANG_PROMPT_FOREVER: "1" }; | ||
| 5103 | }, | ||
| 5104 | protocolEvents, | ||
| 5105 | wrapRuntime: (runtime, runtimeOrdinal) => { | ||
| 5106 | const record: HandlerRecord = {}; | ||
| 5107 | handlerRecords[runtimeOrdinal - 1] = record; | ||
| 5108 | return { | ||
| 5109 | ...runtime, | ||
| 5110 | handleSessionUpdate: (handler) => | ||
| 5111 | Effect.sync(() => { | ||
| 5112 | record.sessionUpdate = handler; | ||
| 5113 | }).pipe(Effect.andThen(runtime.handleSessionUpdate(handler))), | ||
| 5114 | handleRequestPermission: (handler) => | ||
| 5115 | Effect.sync(() => { | ||
| 5116 | record.permission = handler; | ||
| 5117 | }).pipe(Effect.andThen(runtime.handleRequestPermission(handler))), | ||
| 5118 | handleElicitation: (handler) => | ||
| 5119 | Effect.sync(() => { | ||
| 5120 | record.elicitation = handler; | ||
| 5121 | }).pipe(Effect.andThen(runtime.handleElicitation(handler))), | ||
| 5122 | ...(runtimeOrdinal === 1 | ||
| 5123 | ? { | ||
| 5124 | prompt: (payload) => | ||
| 5125 | Effect.gen(function* () { | ||
| 5126 | yield* runtime.prompt(payload).pipe(Effect.ignore, Effect.forkDetach); | ||
| 5127 | return yield* Deferred.await(oldPromptCompletion); | ||
| 5128 | }), | ||
| 5129 | } | ||
| 5130 | : {}), | ||
| 5131 | }; | ||
| 5132 | }, | ||
| 5133 | }); | ||
| 5134 | const adapter = makeAcpAdapterV2({ | ||
| 5135 | instanceId, | ||
| 5136 | flavor: { | ||
| 5137 | driver: ACP_TEST_DRIVER, | ||
| 5138 | capabilities: AcpProviderCapabilitiesV2, | ||
| 5139 | enablePostSettleContinuation: true, | ||
| 5140 | registerExtensions: ({ requestUserInput }) => | ||
| 5141 | Effect.sync(() => { | ||
| 5142 | handlerRecords[extensionOrdinal++]!.requestUserInput = requestUserInput; | ||
| 5143 | }), | ||
| 5144 | restartRuntimeAfterInterrupt: true, | ||
| 5145 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 5146 | makeRuntime: (runtimeInput) => | ||
| 5147 | Effect.sync(() => { | ||
| 5148 | runtimeInputs.push(runtimeInput); | ||
| 5149 | }).pipe(Effect.andThen(makeRuntime(runtimeInput))), | ||
| 5150 | }, | ||
| 5151 | fileSystem, | ||
| 5152 | idAllocator, | ||
| 5153 | serverConfig, | ||
| 5154 | testHooks: { | ||
| 5155 | afterHardTeardownTransportDrained: () => | ||
| 5156 | Deferred.succeed(transportDrained, undefined).pipe( | ||
| 5157 | Effect.andThen(Deferred.await(releaseTransportDrain)), | ||
| 5158 | ), | ||
| 5159 | onNativeResponseLifecycle: (event) => | ||
| 5160 | Effect.sync(() => { | ||
| 5161 | responseLifecycle.push(event.type); | ||
| 5162 | }), | ||
| 5163 | }, | ||
| 5164 | continuationRequests: { | ||
| 5165 | offer: (request) => | ||
| 5166 | Effect.sync(() => { | ||
| 5167 | continuationRequests.push(request); | ||
| 5168 | }), | ||
| 5169 | }, | ||
| 5170 | }); | ||
| 5171 | const threadId = ThreadId.make("thread-acp-successful-teardown-callback-quarantine"); | ||
| 5172 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | ||
| 5173 | runtimeMode: "full-access", | ||
| 5174 | interactionMode: "default", | ||
| 5175 | cwd: process.cwd(), | ||
| 5176 | }); | ||
| 5177 | const modelSelection = { instanceId, model: "default" } as const; | ||
| 5178 | const runtime = yield* adapter.openSession({ | ||
| 5179 | threadId, | ||
| 5180 | providerSessionId: ProviderSessionId.make( | ||
| 5181 | "provider-session-acp-successful-teardown-callback-quarantine", | ||
| 5182 | ), | ||
| 5183 | modelSelection, | ||
| 5184 | runtimePolicy, | ||
| 5185 | }); | ||
| 5186 | const adapterEvents = yield* Queue.unbounded<ProviderAdapterV2Event>(); | ||
| 5187 | yield* runtime.events.pipe( | ||
| 5188 | Stream.runForEach((event) => Queue.offer(adapterEvents, event)), | ||
| 5189 | Effect.forkScoped, | ||
| 5190 | ); | ||
| 5191 | const providerThread = yield* runtime.ensureThread({ | ||
| 5192 | threadId, | ||
| 5193 | modelSelection, | ||
| 5194 | runtimePolicy, | ||
| 5195 | }); | ||
| 5196 | const now = yield* DateTime.now; | ||
| 5197 | yield* runtime | ||
| 5198 | .startTurn( | ||
| 5199 | makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 1 }), | ||
| 5200 | ) | ||
| 5201 | .pipe(Effect.forkScoped); | ||
| 5202 | yield* Stream.fromQueue(protocolEvents).pipe( | ||
| 5203 | Stream.filter( | ||
| 5204 | (event) => | ||
| 5205 | event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", | ||
| 5206 | ), | ||
| 5207 | Stream.runHead, | ||
| 5208 | ); | ||
| 5209 | const providerTurnId = idAllocator.derive.providerTurn({ | ||
| 5210 | driver: ACP_TEST_DRIVER, | ||
| 5211 | nativeTurnId: "mock-session-1:turn:1", | ||
| 5212 | }); | ||
| 5213 | const permissionRequest = { | ||
| 5214 | sessionId: "mock-session-1", | ||
| 5215 | toolCall: { | ||
| 5216 | toolCallId: "stale-generation-1-permission", | ||
| 5217 | title: "Stale generation 1 permission", | ||
| 5218 | }, | ||
| 5219 | options: [{ optionId: "allow", name: "Allow", kind: "allow_once" as const }], | ||
| 5220 | }; | ||
| 5221 | const interruptFiber = yield* runtime | ||
| 5222 | .interruptTurn({ | ||
| 5223 | providerThread, | ||
1396 more lines aren’t shown here.
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | |||
| 18 | type ProviderInstanceId, | 18 | type ProviderInstanceId, |
| 19 | type ProviderDriverKind, | 19 | type ProviderDriverKind, |
| 20 | type ProviderRequestKind, | 20 | type ProviderRequestKind, |
| 21 | type ProviderThreadId, | ||
| 21 | type ProviderUserInputAnswers, | 22 | type ProviderUserInputAnswers, |
| 22 | type RuntimeRequestId, | 23 | type RuntimeRequestId, |
| 23 | type ThreadId, | 24 | type ThreadId, |
| @@ -27,11 +28,14 @@ import * as Cause from "effect/Cause"; · 3 unmodified lines | |||
| 27 | import * as DateTime from "effect/DateTime"; | 28 | import * as DateTime from "effect/DateTime"; |
| 28 | import * as Deferred from "effect/Deferred"; | 29 | import * as Deferred from "effect/Deferred"; |
| 29 | import * as Effect from "effect/Effect"; | 30 | import * as Effect from "effect/Effect"; |
| 31 | import * as Exit from "effect/Exit"; | ||
| 30 | import * as FileSystem from "effect/FileSystem"; | 32 | import * as FileSystem from "effect/FileSystem"; |
| 33 | import * as Fiber from "effect/Fiber"; | ||
| 31 | import * as Option from "effect/Option"; | 34 | import * as Option from "effect/Option"; |
| 32 | import * as Queue from "effect/Queue"; | 35 | import * as Queue from "effect/Queue"; |
| 33 | import * as Ref from "effect/Ref"; | 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 | import * as Stream from "effect/Stream"; | 39 | import * as Stream from "effect/Stream"; |
| 36 | import * as EffectAcpErrors from "effect-acp/errors"; | 40 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 37 | import type * as EffectAcpSchema from "effect-acp/schema"; | 41 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| @@ -52,6 +56,7 @@ import type { · 14 unmodified lines | |||
| 52 | } from "../../provider/acp/AcpSessionRuntime.ts"; | 56 | } from "../../provider/acp/AcpSessionRuntime.ts"; |
| 53 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; | 57 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; |
| 54 | import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts"; | 58 | import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts"; |
| 59 | import { type ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; | ||
| 55 | import { makeProviderFailure } from "../ProviderFailure.ts"; | 60 | import { makeProviderFailure } from "../ProviderFailure.ts"; |
| 56 | import { acpSelectionTransition } from "../ProviderSelectionTransition.ts"; | 61 | import { acpSelectionTransition } from "../ProviderSelectionTransition.ts"; |
| 57 | import { | 62 | import { |
| @@ -87,10 +92,15 @@ export const ACP_PROTOCOL = "acp.ndjson-jsonrpc" as const; · 29 unmodified lines | |||
| 87 | export interface AcpAdapterV2RuntimeInput { | 92 | export interface AcpAdapterV2RuntimeInput { |
| 88 | readonly cwd: string; | 93 | readonly cwd: string; |
| 89 | readonly mcpServers: ReadonlyArray<EffectAcpSchema.McpServer>; | 94 | readonly mcpServers: ReadonlyArray<EffectAcpSchema.McpServer>; |
| 95 | readonly interruptPromptOnCancel?: boolean; | ||
| 90 | readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"]; | 96 | readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"]; |
| 91 | readonly clientInfo: AcpSessionRuntimeOptions["clientInfo"]; | 97 | readonly clientInfo: AcpSessionRuntimeOptions["clientInfo"]; |
| 92 | readonly requestLogger?: NonNullable<AcpSessionRuntimeOptions["requestLogger"]>; | 98 | readonly requestLogger?: NonNullable<AcpSessionRuntimeOptions["requestLogger"]>; |
| 93 | readonly protocolLogging: NonNullable<AcpSessionRuntimeOptions["protocolLogging"]>; | 99 | readonly protocolLogging: NonNullable<AcpSessionRuntimeOptions["protocolLogging"]>; |
| 100 | readonly onIncomingRequest?: AcpSessionRuntimeOptions["onIncomingRequest"]; | ||
| 101 | readonly onTermination: NonNullable<AcpSessionRuntimeOptions["onTermination"]>; | ||
| 102 | readonly onOutgoingResponseFailure?: AcpSessionRuntimeOptions["onOutgoingResponseFailure"]; | ||
| 103 | readonly onOutgoingResponse?: AcpSessionRuntimeOptions["onOutgoingResponse"]; | ||
| 94 | } | 104 | } |
| 95 | 105 | ||
| 96 | export type AcpAdapterV2NativeLogging = Pick< | 106 | export type AcpAdapterV2NativeLogging = Pick< |
| @@ -100,15 +110,79 @@ export type AcpAdapterV2NativeLogging = Pick< · 3 unmodified lines | |||
| 100 | 110 | ||
| 101 | export interface AcpAdapterV2UserInputRequest { | 111 | export interface AcpAdapterV2UserInputRequest { |
| 102 | readonly nativeItemId: string; | 112 | readonly nativeItemId: string; |
| 113 | readonly nativeMethod?: string; | ||
| 103 | readonly nativeRequestId: string; | 114 | readonly nativeRequestId: string; |
| 115 | readonly nativeSessionId?: string; | ||
| 104 | readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>; | 116 | readonly questions: ReadonlyArray<OrchestrationV2UserInputQuestion>; |
| 105 | } | 117 | } |
| 106 | 118 | ||
| 107 | export interface AcpAdapterV2ExtensionContext { | 119 | export interface AcpAdapterV2ExtensionContext { |
| 108 | readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; | 120 | readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; |
| 109 | readonly requestUserInput: ( | 121 | /** |
| 110 | input: AcpAdapterV2UserInputRequest, | 122 | * Session-scoped background-task lifecycle reported via extension |
| 111 | ) => Effect.Effect<ProviderUserInputAnswers | null, EffectAcpErrors.AcpError>; | 123 | * notifications (e.g. Grok `_x.ai/task_backgrounded` after a soft cancel |
| 124 | * detaches foreground work). Mutations for non-root sessions are ignored. | ||
| 125 | */ | ||
| 126 | readonly applyBackgroundTaskMutation: (mutation: { | ||
| 127 | readonly sessionId: string; | ||
| 128 | readonly taskId: string; | ||
| 129 | readonly status: "running" | "completed" | "failed"; | ||
| 130 | }) => Effect.Effect<void>; | ||
| 131 | readonly requestUserInput: (input: AcpAdapterV2UserInputRequest) => Effect.Effect< | ||
| 132 | { | ||
| 133 | readonly acknowledgeNativeResponse: Effect.Effect<void, EffectAcpErrors.AcpError>; | ||
| 134 | readonly answers: ProviderUserInputAnswers | null; | ||
| 135 | }, | ||
| 136 | EffectAcpErrors.AcpError | ||
| 137 | >; | ||
| 138 | } | ||
| 139 | |||
| 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 | export interface AcpAdapterV2Flavor { | 188 | export interface AcpAdapterV2Flavor { |
| @@ -128,7 +202,117 @@ export interface AcpAdapterV2Flavor { · 13 unmodified lines | |||
| 128 | readonly extractSubagentUpdate?: ( | 202 | readonly extractSubagentUpdate?: ( |
| 129 | toolCall: AcpToolCallState, | 203 | toolCall: AcpToolCallState, |
| 130 | ) => AcpAdapterV2SubagentUpdate | undefined; | 204 | ) => AcpAdapterV2SubagentUpdate | undefined; |
| 205 | /** | ||
| 206 | * Optional Grok-style rewrite before tool projection (e.g. keep monitor start | ||
| 207 | * ACKs in the running state until stream end). | ||
| 208 | */ | ||
| 209 | readonly normalizeToolCall?: (toolCall: AcpToolCallState) => AcpToolCallState; | ||
| 210 | /** | ||
| 211 | * Optional mapping from a long-lived background tool start ACK to a task id | ||
| 212 | * (e.g. monitor task uuid) so later synthetic text events can update it. | ||
| 213 | */ | ||
| 214 | readonly extractBackgroundTaskId?: (toolCall: AcpToolCallState) => string | undefined; | ||
| 215 | /** | ||
| 216 | * Optional parse of root-session synthetic text (monitor-event lines, monitor | ||
| 217 | * ended reminders). Returns every task mutation in the chunk so coalesced | ||
| 218 | * progress / end notices are not dropped. | ||
| 219 | */ | ||
| 220 | readonly extractBackgroundToolMutation?: (text: string) => ReadonlyArray<{ | ||
| 221 | readonly taskId: string; | ||
| 222 | readonly status: "running" | "completed" | "failed"; | ||
| 223 | readonly appendOutput: string; | ||
| 224 | }>; | ||
| 225 | /** | ||
| 226 | * Optional parse of root-session synthetic text announcing a background | ||
| 227 | * subagent's end ("Background subagent "<uuid>" ... completed successfully"). | ||
| 228 | * The agent may never hydrate via get_command_or_subagent_output, so this | ||
| 229 | * notice can be the only terminal signal for the subagent row. | ||
| 230 | */ | ||
| 231 | readonly extractSubagentEndNotice?: (text: string) => | ||
| 232 | | { | ||
| 233 | readonly childSessionId: string; | ||
| 234 | readonly status: "completed" | "failed"; | ||
| 235 | } | ||
| 236 | | undefined; | ||
| 237 | /** | ||
| 238 | * Optional hydration when a later tool (e.g. get_command TaskOutput) completes | ||
| 239 | * previously registered background task id(s). | ||
| 240 | */ | ||
| 241 | readonly extractBackgroundTaskCompletion?: (toolCall: AcpToolCallState) => ReadonlyArray<{ | ||
| 242 | readonly taskId: string; | ||
| 243 | readonly status: "running" | "completed" | "failed"; | ||
| 244 | readonly appendOutput: string; | ||
| 245 | }>; | ||
| 246 | /** | ||
| 247 | * Persistent monitors (e.g. Grok `persistent: true`) should not hold root-turn | ||
| 248 | * deferred finalize open forever. Still tracked for post-settle wake. | ||
| 249 | */ | ||
| 250 | readonly isPersistentBackgroundTool?: (toolCall: AcpToolCallState) => boolean; | ||
| 251 | /** | ||
| 252 | * When true, keep the active turn open after session/prompt returns while | ||
| 253 | * background tools/subagents are still running so later monitor/wake traffic | ||
| 254 | * can project (Grok monitors finish after the root prompt settles). | ||
| 255 | */ | ||
| 256 | readonly deferFinalizeForBackgroundWork?: boolean; | ||
| 131 | readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>; | 257 | readonly assertComplete?: Effect.Effect<void, EffectAcpErrors.AcpError>; |
| 258 | /** | ||
| 259 | * When true, schedule speculative local settlement after root session | ||
| 260 | * quiet. Disabled for Grok: short idle windows over-settle preamble-before- | ||
| 261 | * tools turns and `session/cancel` from that path freezes projection while | ||
| 262 | * the agent keeps working. Prefer `session/prompt` return (or a future | ||
| 263 | * root-matched terminal signal). | ||
| 264 | */ | ||
| 265 | readonly settleRootTurnWhenIdle?: boolean; | ||
| 266 | /** Interrupt the local prompt fiber before `session/cancel` (Grok wedged prompts). */ | ||
| 267 | readonly interruptPromptOnCancel?: boolean; | ||
| 268 | /** | ||
| 269 | * Kill and respawn the ACP child process before the next `session/prompt` after a | ||
| 270 | * user interrupt. Grok can keep `task_already_running` state until the process exits. | ||
| 271 | */ | ||
| 272 | readonly restartRuntimeAfterInterrupt?: boolean; | ||
| 273 | /** | ||
| 274 | * When true, every interrupt restarts the runtime, not just those carrying | ||
| 275 | * `requestRuntimeRestart` (user Stop). Leave unset to keep non-Stop | ||
| 276 | * interrupts (steering, restart_active) soft: `session/cancel` plus session | ||
| 277 | * reuse in the same process. | ||
| 278 | */ | ||
| 279 | readonly restartRuntimeOnEveryInterrupt?: boolean; | ||
| 280 | readonly terminateRuntimeProcessGroupOnInterrupt?: boolean; | ||
| 281 | /** | ||
| 282 | * When true, an interrupt without `requestRuntimeRestart` (steering restart) | ||
| 283 | * on a turn whose native prompt already settled skips the hard process-group | ||
| 284 | * kill, the ACP cancel, and the runtime respawn entirely: the turn | ||
| 285 | * terminalizes locally while background subagents keep running in the same | ||
| 286 | * process and carry over into the replacement turn. Verified against the | ||
| 287 | * real Grok CLI (tmp/grok-acp-experiments E1): a new session/prompt is | ||
| 288 | * accepted concurrently while a fire-and-forget subagent is still running, | ||
| 289 | * with no task_already_running. User Stop (`requestRuntimeRestart: true`) | ||
| 290 | * keeps the hard teardown; mid-prompt non-Stop interrupts go soft | ||
| 291 | * (`session/cancel` plus same-session re-prompt) unless | ||
| 292 | * `restartRuntimeOnEveryInterrupt` is set. | ||
| 293 | */ | ||
| 294 | readonly preserveRuntimeOnSettledInterrupt?: boolean; | ||
| 295 | /** | ||
| 296 | * When true (with continuationRequests), post-settle root session/update traffic | ||
| 297 | * buffers and requests a provider continuation run instead of being dropped or | ||
| 298 | * only appended to loaded history. | ||
| 299 | */ | ||
| 300 | readonly enablePostSettleContinuation?: boolean; | ||
| 301 | /** | ||
| 302 | * When true, send image attachment content blocks even if the ACP agent | ||
| 303 | * advertises `promptCapabilities.image: false`. Grok CLI currently accepts | ||
| 304 | * and vision-processes image blocks while still reporting the capability as | ||
| 305 | * false; without this override, screenshot turns fail before `session/prompt`. | ||
| 306 | */ | ||
| 307 | readonly supportsImagePrompts?: boolean; | ||
| 308 | } | ||
| 309 | |||
| 310 | /** Whether image attachment blocks may be included in session/prompt. */ | ||
| 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 | export interface AcpAdapterV2SubagentUpdate { | 318 | export interface AcpAdapterV2SubagentUpdate { |
| @@ -136,9 +320,14 @@ export interface AcpAdapterV2SubagentUpdate { · 1 unmodified line | |||
| 136 | readonly prompt: string; | 320 | readonly prompt: string; |
| 137 | readonly title: string | null; | 321 | readonly title: string | null; |
| 138 | readonly model: string | null; | 322 | readonly model: string | null; |
| 139 | readonly status: "running" | "completed" | "failed"; | 323 | readonly status: "running" | "completed" | "failed" | "interrupted" | "cancelled"; |
| 140 | readonly childSessionId: string | null; | 324 | readonly childSessionId: string | null; |
| 141 | readonly result: string | null; | 325 | readonly result: string | null; |
| 326 | /** | ||
| 327 | * When false, still project a normal tool turn item after the subagent update | ||
| 328 | * (hydration tools like get_command_or_subagent_output). Defaults to true. | ||
| 329 | */ | ||
| 330 | readonly suppressNormalTool?: boolean; | ||
| 142 | } | 331 | } |
| 143 | 332 | ||
| 144 | export interface AcpAdapterV2Options { | 333 | export interface AcpAdapterV2Options { |
| @@ -148,6 +337,36 @@ export interface AcpAdapterV2Options { · 3 unmodified lines | |||
| 148 | readonly idAllocator: IdAllocatorV2Shape; | 337 | readonly idAllocator: IdAllocatorV2Shape; |
| 149 | readonly serverConfig: ServerConfig["Service"]; | 338 | readonly serverConfig: ServerConfig["Service"]; |
| 150 | readonly nativeLogging?: (threadId: ThreadId) => AcpAdapterV2NativeLogging; | 339 | readonly nativeLogging?: (threadId: ThreadId) => AcpAdapterV2NativeLogging; |
| 340 | /** | ||
| 341 | * Shared with ProviderContinuationService so post-settle wake traffic can start | ||
| 342 | * a continuation run. Optional: adapters that omit it keep pre-continuation drop | ||
| 343 | * / history-only behavior for null-activeTurn updates. | ||
| 344 | */ | ||
| 345 | readonly continuationRequests?: { | ||
| 346 | readonly offer: (request: ProviderContinuationRequest) => Effect.Effect<void>; | ||
| 347 | }; | ||
| 348 | readonly testHooks?: { | ||
| 349 | readonly afterNativeResponseTransportClosed?: () => Effect.Effect<void>; | ||
| 350 | readonly afterHardTeardownTransportDrained?: () => Effect.Effect<void>; | ||
| 351 | readonly beforeNativeResponseAdmissionCheck?: ( | ||
| 352 | generation: number, | ||
| 353 | requestId: string, | ||
| 354 | ) => Effect.Effect<void>; | ||
| 355 | readonly onNativeResponseLifecycle?: (event: { | ||
| 356 | readonly generation?: number; | ||
| 357 | readonly requestId?: string; | ||
| 358 | readonly type: | ||
| 359 | | "admission_rejected" | ||
| 360 | | "failed" | ||
| 361 | | "late_noop" | ||
| 362 | | "registered" | ||
| 363 | | "removed" | ||
| 364 | | "timer_exited" | ||
| 365 | | "timer_started" | ||
| 366 | | "watcher_exited" | ||
| 367 | | "watcher_started"; | ||
| 368 | }) => Effect.Effect<void>; | ||
| 369 | }; | ||
| 151 | } | 370 | } |
| 152 | 371 | ||
| 153 | export const AcpProviderCapabilitiesV2 = { | 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 | const session = McpProviderSession.readMcpProviderSession(threadId); | 502 | const session = McpProviderSession.readMcpProviderSession(threadId); |
| 283 | if (session === undefined) { | 503 | if (session === undefined) { |
| 284 | return []; | 504 | return []; |
| @@ -352,14 +572,114 @@ function unknownRecord(value: unknown): Record<string, unknown> | undefined { · 67 unmodified lines | |||
| 352 | : undefined; | 572 | : undefined; |
| 353 | } | 573 | } |
| 354 | 574 | ||
| 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 | function nonEmptyText(value: unknown, fallback: string): string { | 657 | function nonEmptyText(value: unknown, fallback: string): string { |
| 356 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback; | 658 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback; |
| 357 | } | 659 | } |
| 358 | 660 | ||
| 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 | function textFromUnknown(value: unknown): string | undefined { | 675 | function textFromUnknown(value: unknown): string | undefined { |
| 360 | if (typeof value === "string") { | 676 | if (typeof value === "string") { |
| 361 | return value; | 677 | return value; |
| 362 | } | 678 | } |
| 679 | const fromBytes = decodeByteText(value); | ||
| 680 | if (fromBytes !== undefined) { | ||
| 681 | return fromBytes; | ||
| 682 | } | ||
| 363 | if (Array.isArray(value)) { | 683 | if (Array.isArray(value)) { |
| 364 | const parts = value.flatMap((entry) => { | 684 | const parts = value.flatMap((entry) => { |
| 365 | const text = textFromUnknown(entry); | 685 | const text = textFromUnknown(entry); |
| @@ -371,12 +691,33 @@ function textFromUnknown(value: unknown): string | undefined { · 5 unmodified lines | |||
| 371 | if (record === undefined) { | 691 | if (record === undefined) { |
| 372 | return undefined; | 692 | return undefined; |
| 373 | } | 693 | } |
| 374 | for (const key of ["stdout", "stderr", "output", "content", "text", "message"]) { | 694 | // Prefer prompt-facing Grok fields before nested envelopes. |
| 375 | const text = textFromUnknown(record[key]); | 695 | for (const key of [ |
| 696 | "output_for_prompt", | ||
| 697 | "stdout", | ||
| 698 | "stderr", | ||
| 699 | "output", | ||
| 700 | "content", | ||
| 701 | "text", | ||
| 702 | "message", | ||
| 703 | ]) { | ||
| 704 | const direct = record[key]; | ||
| 705 | if (typeof direct === "string" && direct.length > 0) { | ||
| 706 | return direct; | ||
| 707 | } | ||
| 708 | const decoded = decodeByteText(direct); | ||
| 709 | if (decoded !== undefined) { | ||
| 710 | return decoded; | ||
| 711 | } | ||
| 712 | const text = textFromUnknown(direct); | ||
| 376 | if (text !== undefined && text.length > 0) { | 713 | if (text !== undefined && text.length > 0) { |
| 377 | return text; | 714 | return text; |
| 378 | } | 715 | } |
| 379 | } | 716 | } |
| 717 | const result = unknownRecord(record.Result) ?? unknownRecord(record.result); | ||
| 718 | if (result !== undefined) { | ||
| 719 | return textFromUnknown(result); | ||
| 720 | } | ||
| 380 | return undefined; | 721 | return undefined; |
| 381 | } | 722 | } |
| 382 | 723 | ||
| @@ -391,6 +732,21 @@ function commandExitCode(value: unknown): number | undefined { · 8 unmodified lines | |||
| 391 | return undefined; | 732 | return undefined; |
| 392 | } | 733 | } |
| 393 | 734 | ||
| 735 | /** | ||
| 736 | * Project an exit code only when the tool has a terminal native status. | ||
| 737 | * Mid-stream Grok Bash re-reports carry exit_code 0 while still in progress; | ||
| 738 | * interrupted tools must not retain that stale success code. | ||
| 739 | */ | ||
| 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 | function pathFromToolCall(toolCall: AcpToolCallState): string | undefined { | 750 | function pathFromToolCall(toolCall: AcpToolCallState): string | undefined { |
| 395 | const locations = toolCall.data.locations; | 751 | const locations = toolCall.data.locations; |
| 396 | if (Array.isArray(locations)) { | 752 | if (Array.isArray(locations)) { |
| @@ -443,15 +799,14 @@ function toolStatus( · 46 unmodified lines | |||
| 443 | } | 799 | } |
| 444 | } | 800 | } |
| 445 | 801 | ||
| 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 | return status === "pending" ? "running" : status; | 805 | return status === "pending" ? "running" : status; |
| 448 | } | 806 | } |
| 449 | 807 | ||
| 450 | function completedAtForStatus( | 808 | function completedAtForStatus(status: ProjectedToolStatus, now: DateTime.Utc): DateTime.Utc | null { |
| 451 | status: ReturnType<typeof toolStatus>, | 809 | return status === "completed" || status === "failed" || status === "interrupted" ? now : null; |
| 452 | now: DateTime.Utc, | ||
| 453 | ): DateTime.Utc | null { | ||
| 454 | return status === "completed" || status === "failed" ? now : null; | ||
| 455 | } | 810 | } |
| 456 | 811 | ||
| 457 | function selectPermissionOptionId( | 812 | function selectPermissionOptionId( |
| @@ -558,12 +913,175 @@ interface ActiveAcpTurn { · 100 unmodified lines | |||
| 558 | readonly subagents: Map<string, ActiveAcpSubagent>; | 913 | readonly subagents: Map<string, ActiveAcpSubagent>; |
| 559 | readonly subagentsBySessionId: Map<string, ActiveAcpSubagent>; | 914 | readonly subagentsBySessionId: Map<string, ActiveAcpSubagent>; |
| 560 | readonly pendingSubagentNotifications: Map<string, Array<EffectAcpSchema.SessionNotification>>; | 915 | readonly pendingSubagentNotifications: Map<string, Array<EffectAcpSchema.SessionNotification>>; |
| 916 | /** Background monitor/task id → toolCallId for synthetic root text updates. */ | ||
| 917 | readonly toolCallIdsByBackgroundTaskId: Map<string, string>; | ||
| 918 | /** | ||
| 919 | * Persistent monitors registered this turn. Excluded from deferred-finalize | ||
| 920 | * holds so the root turn can settle while they keep streaming post-settle. | ||
| 921 | */ | ||
| 922 | readonly persistentBackgroundTaskIds: Set<string>; | ||
| 923 | /** | ||
| 924 | * Monitor end events often only say "use get_command…"; keep the turn open | ||
| 925 | * until TaskOutput hydration arrives (or the safety timeout elapses). | ||
| 926 | */ | ||
| 927 | readonly awaitingBackgroundHydration: Set<string>; | ||
| 928 | /** | ||
| 929 | * A monitor end notice landed after the prompt settled: the CLI runs an | ||
| 930 | * injected turn whose report never gets a turn_completed marker, so the | ||
| 931 | * report chunk races the deferred-finalize debounce (thread a8e8b0a9 run 5 | ||
| 932 | * dropped the listing this way). Hold finalize until the report streams or | ||
| 933 | * the safety timeout elapses. | ||
| 934 | */ | ||
| 935 | readonly pendingInjectedReport: Set<string>; | ||
| 561 | plan: { | 936 | plan: { |
| 562 | readonly id: OrchestrationV2PlanArtifact["id"]; | 937 | readonly id: OrchestrationV2PlanArtifact["id"]; |
| 563 | readonly startedAt: DateTime.Utc; | 938 | readonly startedAt: DateTime.Utc; |
| 564 | } | null; | 939 | } | null; |
| 565 | interrupted: boolean; | 940 | interrupted: boolean; |
| 566 | finalized: boolean; | 941 | finalized: boolean; |
| 942 | settleScheduleGeneration: number; | ||
| 943 | /** session/prompt already returned; finalize deferred for background work. */ | ||
| 944 | promptSettled: boolean; | ||
| 945 | promptSettledStatus: "completed" | "interrupted" | "failed" | "cancelled" | null; | ||
| 946 | /** | ||
| 947 | * Completed the moment `runtime.prompt` resolves on the wire, before the | ||
| 948 | * completion callback requests `runtimeCallbackPermit`. Failure does not | ||
| 949 | * complete this; settled-soft classification ORs it with `promptSettled`. | ||
| 950 | */ | ||
| 951 | readonly promptWireSettled: Deferred.Deferred<void, never>; | ||
| 952 | backgroundFinalizeGeneration: number; | ||
| 953 | } | ||
| 954 | |||
| 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 | interface ActiveAcpSubagent { | 1087 | interface ActiveAcpSubagent { |
| @@ -572,12 +1090,29 @@ interface ActiveAcpSubagent { · 2 unmodified lines | |||
| 572 | readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; | 1090 | readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; |
| 573 | readonly turnItemId: OrchestrationV2TurnItem["id"]; | 1091 | readonly turnItemId: OrchestrationV2TurnItem["id"]; |
| 574 | readonly turnItemOrdinal: number; | 1092 | readonly turnItemOrdinal: number; |
| 1093 | /** Turn that spawned the subagent; carryover updates keep this lineage. */ | ||
| 1094 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | ||
| 1095 | readonly parentProviderThreadId: ProviderThreadId; | ||
| 575 | childSessionId: string | null; | 1096 | childSessionId: string | null; |
| 576 | assistantText: string; | 1097 | assistantText: string; |
| 577 | nextChildOrdinal: number; | 1098 | nextChildOrdinal: number; |
| 578 | } | 1099 | } |
| 579 | 1100 | ||
| 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 | type PendingRuntimeRequest = { | 1112 | type PendingRuntimeRequest = { |
| 1113 | readonly generation: number; | ||
| 1114 | readonly nativeResponseAcknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>; | ||
| 1115 | readonly transportRequestId: string; | ||
| 581 | readonly requestId: RuntimeRequestId; | 1116 | readonly requestId: RuntimeRequestId; |
| 582 | readonly runtimeRequest: OrchestrationV2RuntimeRequest; | 1117 | readonly runtimeRequest: OrchestrationV2RuntimeRequest; |
| 583 | readonly node: OrchestrationV2ExecutionNode; | 1118 | readonly node: OrchestrationV2ExecutionNode; |
| @@ -603,6 +1138,9 @@ interface SnapshotMessageState { · 19 unmodified lines | |||
| 603 | export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV2Shape { | 1138 | export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV2Shape { |
| 604 | const { flavor, fileSystem, idAllocator, serverConfig } = options; | 1139 | const { flavor, fileSystem, idAllocator, serverConfig } = options; |
| 605 | const driver = flavor.driver; | 1140 | const driver = flavor.driver; |
| 1141 | const continuationRequests = options.continuationRequests; | ||
| 1142 | const postSettleContinuationEnabled = | ||
| 1143 | flavor.enablePostSettleContinuation === true && continuationRequests !== undefined; | ||
| 606 | 1144 | ||
| 607 | return ProviderAdapterV2.of({ | 1145 | return ProviderAdapterV2.of({ |
| 608 | instanceId: options.instanceId, | 1146 | instanceId: options.instanceId, |
| @@ -617,7 +1155,33 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 8 unmodified lines | |||
| 617 | const activeSessionId = yield* Ref.make<string | null>(null); | 1155 | const activeSessionId = yield* Ref.make<string | null>(null); |
| 618 | const activeSessionSetup = yield* Ref.make<AcpSessionRuntimeStartResult | null>(null); | 1156 | const activeSessionSetup = yield* Ref.make<AcpSessionRuntimeStartResult | null>(null); |
| 619 | const activeSelection = yield* Ref.make<ModelSelection | null>(null); | 1157 | const activeSelection = yield* Ref.make<ModelSelection | null>(null); |
| 1158 | const runtimeRestartRequired = yield* Ref.make(false); | ||
| 1159 | const runtimeTeardownState = yield* Ref.make<AcpRuntimeTeardownState>({ _tag: "Idle" }); | ||
| 1160 | const runtimeCallbackGeneration = yield* Ref.make(0); | ||
| 1161 | const runtimeCallbackPermit = yield* Semaphore.make(1); | ||
| 1162 | const runtimeTransitionPermit = yield* Semaphore.make(1); | ||
| 1163 | const nativeTransportRequests = yield* Ref.make< | ||
| 1164 | Array<{ | ||
| 1165 | readonly generation: number; | ||
| 1166 | readonly method: string; | ||
| 1167 | readonly payload: unknown; | ||
| 1168 | readonly requestId: string; | ||
| 1169 | readonly sequence: number; | ||
| 1170 | }> | ||
| 1171 | >([]); | ||
| 1172 | const nextNativeTransportSequence = yield* Ref.make(0); | ||
| 1173 | const nativeResponseAcknowledgements = yield* Ref.make( | ||
| 1174 | new Map< | ||
| 1175 | string, | ||
| 1176 | { | ||
| 1177 | readonly acknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>; | ||
| 1178 | readonly generation: number; | ||
| 1179 | } | ||
| 1180 | >(), | ||
| 1181 | ); | ||
| 620 | const pendingRuntimeRequests = yield* Ref.make(new Map<string, PendingRuntimeRequest>()); | 1182 | const pendingRuntimeRequests = yield* Ref.make(new Map<string, PendingRuntimeRequest>()); |
| 1183 | const emitNativeResponseLifecycle = | ||
| 1184 | options.testHooks?.onNativeResponseLifecycle ?? (() => Effect.void); | ||
| 621 | const nextElicitationOrdinal = yield* Ref.make(0); | 1185 | const nextElicitationOrdinal = yield* Ref.make(0); |
| 622 | const itemOrdinals = yield* Ref.make(new Map<string, number>()); | 1186 | const itemOrdinals = yield* Ref.make(new Map<string, number>()); |
| 623 | const nextItemOrdinalsByTurn = yield* Ref.make(new Map<string, number>()); | 1187 | const nextItemOrdinalsByTurn = yield* Ref.make(new Map<string, number>()); |
| @@ -629,20 +1193,434 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines | |||
| 629 | loadingIndex: 0, | 1193 | loadingIndex: 0, |
| 630 | }); | 1194 | }); |
| 631 | 1195 | ||
| 1196 | const awaitRuntimeTeardown = Effect.fnUntraced(function* () { | ||
| 1197 | const state = yield* Ref.get(runtimeTeardownState); | ||
| 1198 | if (state._tag === "InProgress") { | ||
| 1199 | yield* Deferred.await(state.completed); | ||
| 1200 | } else if (state._tag === "Failed") { | ||
| 1201 | return yield* state.error; | ||
| 1202 | } | ||
| 1203 | }); | ||
| 1204 | const runRuntimeCallbackAtGeneration = <A, E, R>( | ||
| 1205 | generation: number, | ||
| 1206 | effect: Effect.Effect<A, E, R>, | ||
| 1207 | ) => | ||
| 1208 | runtimeCallbackPermit.withPermit( | ||
| 1209 | Effect.gen(function* () { | ||
| 1210 | if ((yield* Ref.get(runtimeTeardownState))._tag !== "Idle") { | ||
| 1211 | return Option.none<A>(); | ||
| 1212 | } | ||
| 1213 | if ((yield* Ref.get(runtimeCallbackGeneration)) !== generation) { | ||
| 1214 | return Option.none<A>(); | ||
| 1215 | } | ||
| 1216 | return Option.some(yield* effect); | ||
| 1217 | }), | ||
| 1218 | ); | ||
| 1219 | const claimNativeTransportRequest = ( | ||
| 1220 | generation: number, | ||
| 1221 | predicate: (request: { readonly method: string; readonly payload: unknown }) => boolean, | ||
| 1222 | ) => | ||
| 1223 | Ref.modify( | ||
| 1224 | nativeTransportRequests, | ||
| 1225 | ( | ||
| 1226 | requests, | ||
| 1227 | ): readonly [ | ||
| 1228 | string | undefined, | ||
| 1229 | Array<{ | ||
| 1230 | readonly generation: number; | ||
| 1231 | readonly method: string; | ||
| 1232 | readonly payload: unknown; | ||
| 1233 | readonly requestId: string; | ||
| 1234 | readonly sequence: number; | ||
| 1235 | }>, | ||
| 1236 | ] => acpClaimNativeTransportRequest(requests, generation, predicate), | ||
| 1237 | ); | ||
| 1238 | const registerNativeResponseAcknowledgement = ( | ||
| 1239 | generation: number, | ||
| 1240 | transportRequestId: string, | ||
| 1241 | acknowledgement: Deferred.Deferred<void, EffectAcpErrors.AcpError>, | ||
| 1242 | ) => | ||
| 1243 | Effect.gen(function* () { | ||
| 1244 | yield* Ref.update(nativeResponseAcknowledgements, (current) => { | ||
| 1245 | const updated = new Map(current); | ||
| 1246 | updated.set(transportRequestId, { acknowledgement, generation }); | ||
| 1247 | return updated; | ||
| 1248 | }); | ||
| 1249 | yield* emitNativeResponseLifecycle({ | ||
| 1250 | type: "registered", | ||
| 1251 | generation, | ||
| 1252 | requestId: transportRequestId, | ||
| 1253 | }); | ||
| 1254 | if (!(yield* Deferred.isDone(acknowledgement))) return; | ||
| 1255 | yield* Ref.update(nativeResponseAcknowledgements, (current) => { | ||
| 1256 | if (current.get(transportRequestId)?.acknowledgement !== acknowledgement) { | ||
| 1257 | return current; | ||
| 1258 | } | ||
| 1259 | const updated = new Map(current); | ||
| 1260 | updated.delete(transportRequestId); | ||
| 1261 | return updated; | ||
| 1262 | }); | ||
| 1263 | }); | ||
| 1264 | const acknowledgeNativeResponse = (generation: number, transportRequestId: string) => | ||
| 1265 | ( | ||
| 1266 | options.testHooks?.beforeNativeResponseAdmissionCheck?.( | ||
| 1267 | generation, | ||
| 1268 | transportRequestId, | ||
| 1269 | ) ?? Effect.void | ||
| 1270 | ).pipe( | ||
| 1271 | Effect.andThen(runRuntimeCallbackAtGeneration(generation, Effect.void)), | ||
| 1272 | Effect.flatMap((registered) => | ||
| 1273 | Option.isSome(registered) | ||
| 1274 | ? Effect.void | ||
| 1275 | : emitNativeResponseLifecycle({ | ||
| 1276 | type: "admission_rejected", | ||
| 1277 | generation, | ||
| 1278 | requestId: transportRequestId, | ||
| 1279 | }).pipe( | ||
| 1280 | Effect.andThen( | ||
| 1281 | new EffectAcpErrors.AcpTransportError({ | ||
| 1282 | detail: "The ACP runtime closed before its response could be admitted", | ||
| 1283 | cause: "Native response registration rejected during teardown", | ||
| 1284 | }), | ||
| 1285 | ), | ||
| 1286 | ), | ||
| 1287 | ), | ||
| 1288 | ); | ||
| 1289 | const awaitNativeResponseAcknowledgements = Effect.fnUntraced(function* ( | ||
| 1290 | acknowledgements: ReadonlyArray< | ||
| 1291 | readonly [string | undefined, Deferred.Deferred<void, EffectAcpErrors.AcpError>] | ||
| 1292 | >, | ||
| 1293 | ) { | ||
| 1294 | if (acknowledgements.length === 0) return true; | ||
| 1295 | const completed = yield* Deferred.make<"settled" | "timeout">(); | ||
| 1296 | yield* emitNativeResponseLifecycle({ type: "watcher_started" }); | ||
| 1297 | const acknowledgementFiber = yield* Effect.forEach( | ||
| 1298 | acknowledgements, | ||
| 1299 | ([, acknowledgement]) => Deferred.await(acknowledgement).pipe(Effect.exit), | ||
| 1300 | { concurrency: "unbounded", discard: true }, | ||
| 1301 | ).pipe( | ||
| 1302 | Effect.andThen(Deferred.succeed(completed, "settled")), | ||
| 1303 | Effect.interruptible, | ||
| 1304 | Effect.ensuring(emitNativeResponseLifecycle({ type: "watcher_exited" })), | ||
| 1305 | Effect.forkDetach, | ||
| 1306 | ); | ||
| 1307 | yield* emitNativeResponseLifecycle({ type: "timer_started" }); | ||
| 1308 | const timerFiber = yield* Effect.sleep("2 seconds").pipe( | ||
| 1309 | Effect.andThen(Deferred.succeed(completed, "timeout")), | ||
| 1310 | Effect.interruptible, | ||
| 1311 | Effect.ensuring(emitNativeResponseLifecycle({ type: "timer_exited" })), | ||
| 1312 | Effect.forkDetach, | ||
| 1313 | ); | ||
| 1314 | const outcome = yield* Deferred.await(completed); | ||
| 1315 | yield* Fiber.interrupt(acknowledgementFiber); | ||
| 1316 | yield* Fiber.interrupt(timerFiber); | ||
| 1317 | if (outcome === "settled") return true; | ||
| 1318 | |||
| 1319 | yield* Ref.update(nativeResponseAcknowledgements, (current) => { | ||
| 1320 | const updated = new Map(current); | ||
| 1321 | for (const [requestId, acknowledgement] of acknowledgements) { | ||
| 1322 | if ( | ||
| 1323 | requestId !== undefined && | ||
| 1324 | updated.get(requestId)?.acknowledgement === acknowledgement | ||
| 1325 | ) { | ||
| 1326 | updated.delete(requestId); | ||
| 1327 | } | ||
| 1328 | } | ||
| 1329 | return updated; | ||
| 1330 | }); | ||
| 1331 | yield* Effect.forEach( | ||
| 1332 | acknowledgements, | ||
| 1333 | ([requestId]) => | ||
| 1334 | emitNativeResponseLifecycle({ | ||
| 1335 | type: "removed", | ||
| 1336 | ...(requestId === undefined ? {} : { requestId }), | ||
| 1337 | }), | ||
| 1338 | { concurrency: "unbounded", discard: true }, | ||
| 1339 | ); | ||
| 1340 | const timeoutError = new EffectAcpErrors.AcpTransportError({ | ||
| 1341 | detail: "Timed out waiting for an admitted ACP response to reach the transport queue", | ||
| 1342 | cause: "Native response acknowledgement timed out", | ||
| 1343 | }); | ||
| 1344 | yield* Effect.forEach( | ||
| 1345 | acknowledgements, | ||
| 1346 | ([requestId, acknowledgement]) => | ||
| 1347 | Deferred.fail(acknowledgement, timeoutError).pipe( | ||
| 1348 | Effect.andThen( | ||
| 1349 | emitNativeResponseLifecycle({ | ||
| 1350 | type: "failed", | ||
| 1351 | ...(requestId === undefined ? {} : { requestId }), | ||
| 1352 | }), | ||
| 1353 | ), | ||
| 1354 | ), | ||
| 1355 | { concurrency: "unbounded", discard: true }, | ||
| 1356 | ); | ||
| 1357 | return false; | ||
| 1358 | }); | ||
| 1359 | const awaitAdmittedNativeResponses = Effect.gen(function* () { | ||
| 1360 | yield* awaitNativeResponseAcknowledgements( | ||
| 1361 | [...(yield* Ref.get(nativeResponseAcknowledgements)).entries()].map( | ||
| 1362 | ([requestId, entry]) => [requestId, entry.acknowledgement] as const, | ||
| 1363 | ), | ||
| 1364 | ); | ||
| 1365 | }); | ||
| 1366 | const quarantineNativeTransportAtGeneration = Effect.fnUntraced(function* ( | ||
| 1367 | generation: number, | ||
| 1368 | ) { | ||
| 1369 | yield* Ref.update(nativeTransportRequests, (requests) => | ||
| 1370 | requests.filter((request) => request.generation !== generation), | ||
| 1371 | ); | ||
| 1372 | const quarantined = yield* Ref.modify(nativeResponseAcknowledgements, (current) => { | ||
| 1373 | const updated = new Map(current); | ||
| 1374 | const acknowledgements: Array<Deferred.Deferred<void, EffectAcpErrors.AcpError>> = []; | ||
| 1375 | for (const [requestId, entry] of updated) { | ||
| 1376 | if (entry.generation !== generation) continue; | ||
| 1377 | updated.delete(requestId); | ||
| 1378 | acknowledgements.push(entry.acknowledgement); | ||
| 1379 | } | ||
| 1380 | return [acknowledgements, updated] as const; | ||
| 1381 | }); | ||
| 1382 | const error = new EffectAcpErrors.AcpTransportError({ | ||
| 1383 | detail: "The ACP runtime was replaced before its response reached the transport queue", | ||
| 1384 | cause: "ACP runtime transport was quarantined during teardown", | ||
| 1385 | }); | ||
| 1386 | yield* Effect.forEach( | ||
| 1387 | quarantined, | ||
| 1388 | (acknowledgement) => Deferred.fail(acknowledgement, error), | ||
| 1389 | { concurrency: "unbounded", discard: true }, | ||
| 1390 | ); | ||
| 1391 | }); | ||
| 1392 | const closeNativeTransport = runtimeCallbackPermit.withPermit( | ||
| 1393 | Effect.gen(function* () { | ||
| 1394 | yield* Ref.update(runtimeCallbackGeneration, (generation) => generation + 1); | ||
| 1395 | yield* Ref.set(nativeTransportRequests, []); | ||
| 1396 | const acknowledgements = yield* Ref.getAndSet( | ||
| 1397 | nativeResponseAcknowledgements, | ||
| 1398 | new Map(), | ||
| 1399 | ); | ||
| 1400 | const error = new EffectAcpErrors.AcpTransportError({ | ||
| 1401 | detail: "The ACP session closed before its admitted response reached the transport", | ||
| 1402 | cause: "ACP session transport closed", | ||
| 1403 | }); | ||
| 1404 | yield* Effect.forEach( | ||
| 1405 | acknowledgements, | ||
| 1406 | ([requestId, entry]) => | ||
| 1407 | Deferred.fail(entry.acknowledgement, error).pipe( | ||
| 1408 | Effect.andThen( | ||
| 1409 | emitNativeResponseLifecycle({ | ||
| 1410 | type: "removed", | ||
| 1411 | generation: entry.generation, | ||
| 1412 | requestId, | ||
| 1413 | }), | ||
| 1414 | ), | ||
| 1415 | Effect.andThen( | ||
| 1416 | emitNativeResponseLifecycle({ | ||
| 1417 | type: "failed", | ||
| 1418 | generation: entry.generation, | ||
| 1419 | requestId, | ||
| 1420 | }), | ||
| 1421 | ), | ||
| 1422 | ), | ||
| 1423 | { concurrency: "unbounded", discard: true }, | ||
| 1424 | ); | ||
| 1425 | return acknowledgements.size > 0; | ||
| 1426 | }), | ||
| 1427 | ); | ||
| 1428 | // Post-settle wake support (Grok async subagent/monitor follow-up). After | ||
| 1429 | // the root turn finalizes, later root session/update traffic buffers here | ||
| 1430 | // until a provider continuation run attaches and drains it. | ||
| 1431 | const lastTurnRoute = yield* Ref.make<{ | ||
| 1432 | readonly threadId: ThreadId; | ||
| 1433 | readonly providerThreadId: ProviderThreadId; | ||
| 1434 | } | null>(null); | ||
| 1435 | const wakeBuffer = yield* Ref.make<Array<EffectAcpSchema.SessionNotification>>([]); | ||
| 1436 | const continuationRequested = yield* Ref.make(false); | ||
| 1437 | const continuationGeneration = yield* Ref.make(0); | ||
| 1438 | const continuationPermit = yield* Semaphore.make(1); | ||
| 1439 | const continuationClosed = yield* Ref.make(false); | ||
| 1440 | // Direct Stop (requestRuntimeRestart) quarantines residual events from the | ||
| 1441 | // stopped run so they cannot wake or attach to a later prompt/run. | ||
| 1442 | const stoppedRunQuarantine = yield* Ref.make(false); | ||
| 1443 | // A steering restart (or any interrupt) can finalize a turn while its | ||
| 1444 | // spawned subagents are still running natively. Carry the live | ||
| 1445 | // lineages into the next turn on the same session so their terminal | ||
| 1446 | // signals can still flip the original turn items instead of leaving | ||
| 1447 | // them running forever. | ||
| 1448 | const carryoverSubagents = yield* Ref.make<{ | ||
| 1449 | readonly sessionId: string; | ||
| 1450 | readonly subagents: ReadonlyArray<ActiveAcpSubagent>; | ||
| 1451 | } | null>(null); | ||
| 1452 | const handledBackgroundTaskIdsInActiveTurn = yield* Ref.make<ReadonlySet<string>>( | ||
| 1453 | new Set(), | ||
| 1454 | ); | ||
| 1455 | // A monitor-event can arrive after its task and the user-facing provider | ||
| 1456 | // turn already completed. Grok starts another internal prompt for that | ||
| 1457 | // stale notification; suppress its agent output until a genuine terminal | ||
| 1458 | // mutation or the next app turn so it cannot create a redundant app | ||
| 1459 | // continuation. Tool frames continue through normal hydration. | ||
| 1460 | const suppressPostSettleMonitorPrompt = yield* Ref.make(false); | ||
| 1461 | // Background tasks (Grok monitors) known to still run at session level. | ||
| 1462 | // Turn contexts are too short-lived to carry this: a continuation run | ||
| 1463 | // finalizes between monitor events, and the next commentary burst must | ||
| 1464 | // not reopen a run while the monitor is still streaming. | ||
| 1465 | const runningBackgroundTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set()); | ||
| 1466 | // Task ids with a GENUINE end signal (monitor-ended reminder or | ||
| 1467 | // TaskOutput completion). Normalized tool statuses are not genuine: | ||
| 1468 | // Grok Bash re-reports carry exit_code 0 mid-stream. A straggler | ||
| 1469 | // monitor-event can land after the real end (the CLI keeps streaming | ||
| 1470 | // while the agent already consumed the output via | ||
| 1471 | // get_command_or_subagent_output); without the tombstone it would | ||
| 1472 | // resurrect the running set and pin offers/idle-release forever. | ||
| 1473 | // A tool-level failed get_command tombstones too: failing open to a | ||
| 1474 | // single continuation beats failing closed to a dead thread. | ||
| 1475 | const endedBackgroundTaskIds = yield* Ref.make<ReadonlySet<string>>(new Set()); | ||
| 1476 | const endedBackgroundTaskIdLimit = 128; | ||
| 1477 | |||
| 1478 | const setBackgroundTaskRunning = (taskId: string, running: boolean) => | ||
| 1479 | Effect.gen(function* () { | ||
| 1480 | if (running && (yield* Ref.get(endedBackgroundTaskIds)).has(taskId)) { | ||
| 1481 | return; | ||
| 1482 | } | ||
| 1483 | yield* Ref.update(runningBackgroundTaskIds, (current) => { | ||
| 1484 | if (current.has(taskId) === running) return current; | ||
| 1485 | const next = new Set(current); | ||
| 1486 | if (running) { | ||
| 1487 | next.add(taskId); | ||
| 1488 | } else { | ||
| 1489 | next.delete(taskId); | ||
| 1490 | } | ||
| 1491 | return next; | ||
| 1492 | }); | ||
| 1493 | }); | ||
| 1494 | |||
| 1495 | const markBackgroundTaskEnded = (taskId: string) => | ||
| 1496 | Ref.update(endedBackgroundTaskIds, (current) => { | ||
| 1497 | if (current.has(taskId)) return current; | ||
| 1498 | const next = new Set(current).add(taskId); | ||
| 1499 | for (const oldest of next) { | ||
| 1500 | if (next.size <= endedBackgroundTaskIdLimit) break; | ||
| 1501 | next.delete(oldest); | ||
| 1502 | } | ||
| 1503 | return next; | ||
| 1504 | }).pipe(Effect.andThen(setBackgroundTaskRunning(taskId, false))); | ||
| 1505 | |||
| 1506 | const applyBackgroundTaskMutationRunning = (mutation: { | ||
| 1507 | readonly taskId: string; | ||
| 1508 | readonly status: "running" | "completed" | "failed"; | ||
| 1509 | }) => | ||
| 1510 | mutation.status === "running" | ||
| 1511 | ? setBackgroundTaskRunning(mutation.taskId, true) | ||
| 1512 | : markBackgroundTaskEnded(mutation.taskId); | ||
| 1513 | |||
| 1514 | const trackRunningBackgroundTools = ( | ||
| 1515 | notification: EffectAcpSchema.SessionNotification, | ||
| 1516 | ): Effect.Effect<void> => | ||
| 1517 | Effect.gen(function* () { | ||
| 1518 | if (flavor.extractBackgroundTaskId === undefined) return; | ||
| 1519 | for (const event of parseSessionUpdateEvent(notification).events) { | ||
| 1520 | if (event._tag !== "ToolCallUpdated") continue; | ||
| 1521 | const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | ||
| 1522 | const taskId = flavor.extractBackgroundTaskId(toolCall); | ||
| 1523 | if (taskId === undefined) continue; | ||
| 1524 | const status = toolStatus(toolCall.status); | ||
| 1525 | yield* setBackgroundTaskRunning(taskId, status === "pending" || status === "running"); | ||
| 1526 | } | ||
| 1527 | }); | ||
| 1528 | |||
| 632 | const emitProviderEvent = (event: ProviderAdapterV2Event) => | 1529 | const emitProviderEvent = (event: ProviderAdapterV2Event) => |
| 633 | Queue.offer(events, event).pipe(Effect.asVoid); | 1530 | Queue.offer(events, event).pipe(Effect.asVoid); |
| 1531 | let scheduleSettleRootTurnWhenIdle = (_context: ActiveAcpTurn) => Effect.void; | ||
| 1532 | let rearmRootTurnRecoveryTimers = (_context: ActiveAcpTurn) => Effect.void; | ||
| 1533 | let scheduleDeferredFinalize: (context: ActiveAcpTurn) => Effect.Effect<void> = () => | ||
| 1534 | Effect.void; | ||
| 634 | 1535 | ||
| 635 | const nativeLogging = options.nativeLogging?.(input.threadId); | 1536 | const nativeLogging = options.nativeLogging?.(input.threadId); |
| 636 | 1537 | const makeRuntimeInput = (runtimeGeneration: number): AcpAdapterV2RuntimeInput => ({ | |
| 637 | const runtime = yield* flavor.makeRuntime({ | ||
| 638 | cwd: input.runtimePolicy.cwd ?? process.cwd(), | 1538 | cwd: input.runtimePolicy.cwd ?? process.cwd(), |
| 639 | mcpServers: acpMcpServers(input.threadId), | 1539 | mcpServers: acpMcpServers(input.threadId), |
| 1540 | interruptPromptOnCancel: flavor.interruptPromptOnCancel ?? false, | ||
| 640 | clientCapabilities: { | 1541 | clientCapabilities: { |
| 641 | fs: { readTextFile: false, writeTextFile: false }, | 1542 | fs: { readTextFile: false, writeTextFile: false }, |
| 642 | terminal: false, | 1543 | terminal: false, |
| 643 | elicitation: { form: {} }, | 1544 | elicitation: { form: {} }, |
| 644 | }, | 1545 | }, |
| 645 | clientInfo: { name: "t3-code", version: "0.0.0" }, | 1546 | clientInfo: { name: "t3-code", version: "0.0.0" }, |
| 1547 | onIncomingRequest: (requestId, method, payload) => | ||
| 1548 | runRuntimeCallbackAtGeneration( | ||
| 1549 | runtimeGeneration, | ||
| 1550 | Effect.gen(function* () { | ||
| 1551 | const sequence = yield* Ref.getAndUpdate( | ||
| 1552 | nextNativeTransportSequence, | ||
| 1553 | (current) => current + 1, | ||
| 1554 | ); | ||
| 1555 | yield* Ref.update(nativeTransportRequests, (current) => [ | ||
| 1556 | ...current, | ||
| 1557 | { generation: runtimeGeneration, method, payload, requestId, sequence }, | ||
| 1558 | ]); | ||
| 1559 | }), | ||
| 1560 | ).pipe(Effect.asVoid), | ||
| 1561 | onTermination: () => | ||
| 1562 | runRuntimeCallbackAtGeneration( | ||
| 1563 | runtimeGeneration, | ||
| 1564 | Ref.set(runtimeRestartRequired, true), | ||
| 1565 | ).pipe(Effect.asVoid), | ||
| 1566 | onOutgoingResponseFailure: (requestId, error) => | ||
| 1567 | Ref.modify(nativeResponseAcknowledgements, (current) => { | ||
| 1568 | const entry = current.get(requestId); | ||
| 1569 | if (entry === undefined || entry.generation !== runtimeGeneration) { | ||
| 1570 | return [ | ||
| 1571 | emitNativeResponseLifecycle({ | ||
| 1572 | type: "late_noop", | ||
| 1573 | generation: runtimeGeneration, | ||
| 1574 | requestId, | ||
| 1575 | }), | ||
| 1576 | current, | ||
| 1577 | ] as const; | ||
| 1578 | } | ||
| 1579 | const updated = new Map(current); | ||
| 1580 | updated.delete(requestId); | ||
| 1581 | return [ | ||
| 1582 | Deferred.fail(entry.acknowledgement, error).pipe( | ||
| 1583 | Effect.andThen( | ||
| 1584 | emitNativeResponseLifecycle({ | ||
| 1585 | type: "removed", | ||
| 1586 | generation: runtimeGeneration, | ||
| 1587 | requestId, | ||
| 1588 | }), | ||
| 1589 | ), | ||
| 1590 | Effect.asVoid, | ||
| 1591 | ), | ||
| 1592 | updated, | ||
| 1593 | ] as const; | ||
| 1594 | }).pipe(Effect.flatten), | ||
| 1595 | onOutgoingResponse: (requestId) => | ||
| 1596 | Ref.modify(nativeResponseAcknowledgements, (current) => { | ||
| 1597 | const entry = current.get(requestId); | ||
| 1598 | if (entry === undefined || entry.generation !== runtimeGeneration) { | ||
| 1599 | return [ | ||
| 1600 | emitNativeResponseLifecycle({ | ||
| 1601 | type: "late_noop", | ||
| 1602 | generation: runtimeGeneration, | ||
| 1603 | requestId, | ||
| 1604 | }), | ||
| 1605 | current, | ||
| 1606 | ] as const; | ||
| 1607 | } | ||
| 1608 | const updated = new Map(current); | ||
| 1609 | updated.delete(requestId); | ||
| 1610 | return [ | ||
| 1611 | Deferred.succeed(entry.acknowledgement, undefined).pipe( | ||
| 1612 | Effect.andThen( | ||
| 1613 | emitNativeResponseLifecycle({ | ||
| 1614 | type: "removed", | ||
| 1615 | generation: runtimeGeneration, | ||
| 1616 | requestId, | ||
| 1617 | }), | ||
| 1618 | ), | ||
| 1619 | Effect.asVoid, | ||
| 1620 | ), | ||
| 1621 | updated, | ||
| 1622 | ] as const; | ||
| 1623 | }).pipe(Effect.flatten), | ||
| 646 | ...(nativeLogging?.requestLogger === undefined | 1624 | ...(nativeLogging?.requestLogger === undefined |
| 647 | ? {} | 1625 | ? {} |
| 648 | : { requestLogger: nativeLogging.requestLogger }), | 1626 | : { requestLogger: nativeLogging.requestLogger }), |
| @@ -652,6 +1630,13 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines | |||
| 652 | logger: () => Effect.void, | 1630 | logger: () => Effect.void, |
| 653 | }, | 1631 | }, |
| 654 | }); | 1632 | }); |
| 1633 | let runtimeScope: Scope.Closeable | undefined; | ||
| 1634 | let runtime!: AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 1635 | yield* Effect.addFinalizer(() => | ||
| 1636 | runtimeScope === undefined | ||
| 1637 | ? Effect.void | ||
| 1638 | : Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore), | ||
| 1639 | ); | ||
| 655 | 1640 | ||
| 656 | const resolveItemOrdinal = Effect.fnUntraced(function* ( | 1641 | const resolveItemOrdinal = Effect.fnUntraced(function* ( |
| 657 | context: ActiveAcpTurn, | 1642 | context: ActiveAcpTurn, |
| @@ -811,6 +1796,9 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 153 unmodified lines | |||
| 811 | if (stream.current === null) return; | 1796 | if (stream.current === null) return; |
| 812 | yield* emitTextSegment(context, kind, true); | 1797 | yield* emitTextSegment(context, kind, true); |
| 813 | stream.current = null; | 1798 | stream.current = null; |
| 1799 | if (kind === "assistant") { | ||
| 1800 | yield* scheduleSettleRootTurnWhenIdle(context); | ||
| 1801 | } | ||
| 814 | }); | 1802 | }); |
| 815 | 1803 | ||
| 816 | const closeTextStreams = Effect.fnUntraced(function* (context: ActiveAcpTurn) { | 1804 | const closeTextStreams = Effect.fnUntraced(function* (context: ActiveAcpTurn) { |
| @@ -883,39 +1871,56 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 66 unmodified lines | |||
| 883 | context: ActiveAcpTurn, | 1871 | context: ActiveAcpTurn, |
| 884 | update: AcpAdapterV2SubagentUpdate, | 1872 | update: AcpAdapterV2SubagentUpdate, |
| 885 | ) { | 1873 | ) { |
| 886 | const existing = context.subagents.get(update.nativeTaskId); | 1874 | // Hydration tools (get_command_or_subagent_output) use a new toolCallId |
| 1875 | // but reference the child via subagent_id / task id. | ||
| 1876 | const existing = | ||
| 1877 | context.subagents.get(update.nativeTaskId) ?? | ||
| 1878 | (update.childSessionId !== null | ||
| 1879 | ? context.subagentsBySessionId.get(update.childSessionId) | ||
| 1880 | : undefined); | ||
| 1881 | // get_command_or_subagent_output may target monitors/bash tasks. Only | ||
| 1882 | // hydrate when we already have a matching subagent lineage. Spawn ACKs | ||
| 1883 | // (non-empty prompt) may create a new lineage; empty-prompt hydration | ||
| 1884 | // with suppressNormalTool must not invent a phantom subagent. | ||
| 1885 | if (existing === undefined) { | ||
| 1886 | const isHydrationOnly = update.prompt === "" && update.title === null; | ||
| 1887 | if (isHydrationOnly || update.suppressNormalTool === false) { | ||
| 1888 | return; | ||
| 1889 | } | ||
| 1890 | } | ||
| 887 | const now = yield* DateTime.now; | 1891 | const now = yield* DateTime.now; |
| 1892 | const nativeTaskId = existing?.task.nativeTaskRef?.nativeId ?? update.nativeTaskId; | ||
| 888 | const nativeItemRef = { | 1893 | const nativeItemRef = { |
| 889 | driver, | 1894 | driver, |
| 890 | nativeId: update.nativeTaskId, | 1895 | nativeId: nativeTaskId, |
| 891 | strength: "strong" as const, | 1896 | strength: "strong" as const, |
| 892 | }; | 1897 | }; |
| 893 | const nodeId = | 1898 | const nodeId = |
| 894 | existing?.task.id ?? | 1899 | existing?.task.id ?? |
| 895 | idAllocator.derive.nodeFromProviderItem({ | 1900 | idAllocator.derive.nodeFromProviderItem({ |
| 896 | driver, | 1901 | driver, |
| 897 | nativeItemId: update.nativeTaskId, | 1902 | nativeItemId: nativeTaskId, |
| 898 | }); | 1903 | }); |
| 899 | const childThreadId = | 1904 | const childThreadId = |
| 900 | existing?.childThreadId ?? | 1905 | existing?.childThreadId ?? |
| 901 | idAllocator.derive.threadFromProviderThread({ | 1906 | idAllocator.derive.threadFromProviderThread({ |
| 902 | driver, | 1907 | driver, |
| 903 | nativeThreadId: `${nativeThreadId(driver, context.input.providerThread)}:task:${update.nativeTaskId}`, | 1908 | nativeThreadId: `${nativeThreadId(driver, context.input.providerThread)}:task:${nativeTaskId}`, |
| 904 | }); | 1909 | }); |
| 905 | const childRootNodeId = | 1910 | const childRootNodeId = |
| 906 | existing?.childRootNodeId ?? | 1911 | existing?.childRootNodeId ?? |
| 907 | idAllocator.derive.nodeFromProviderItem({ | 1912 | idAllocator.derive.nodeFromProviderItem({ |
| 908 | driver, | 1913 | driver, |
| 909 | nativeItemId: `${update.nativeTaskId}:child-root`, | 1914 | nativeItemId: `${nativeTaskId}:child-root`, |
| 910 | }); | 1915 | }); |
| 911 | const turnItemId = | 1916 | const turnItemId = |
| 912 | existing?.turnItemId ?? | 1917 | existing?.turnItemId ?? |
| 913 | idAllocator.derive.turnItemFromProviderItem({ | 1918 | idAllocator.derive.turnItemFromProviderItem({ |
| 914 | driver, | 1919 | driver, |
| 915 | nativeItemId: update.nativeTaskId, | 1920 | nativeItemId: nativeTaskId, |
| 916 | }); | 1921 | }); |
| 917 | const turnItemOrdinal = | 1922 | const turnItemOrdinal = |
| 918 | existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(context, update.nativeTaskId)); | 1923 | existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(context, nativeTaskId)); |
| 919 | const taskStatus = update.status; | 1924 | const taskStatus = update.status; |
| 920 | const task: OrchestrationV2Subagent = { | 1925 | const task: OrchestrationV2Subagent = { |
| 921 | ...(existing?.task ?? { | 1926 | ...(existing?.task ?? { |
| @@ -947,12 +1952,14 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 25 unmodified lines | |||
| 947 | childRootNodeId, | 1952 | childRootNodeId, |
| 948 | turnItemId, | 1953 | turnItemId, |
| 949 | turnItemOrdinal, | 1954 | turnItemOrdinal, |
| 1955 | providerTurnId: context.providerTurnId, | ||
| 1956 | parentProviderThreadId: context.input.providerThread.id, | ||
| 950 | childSessionId: null, | 1957 | childSessionId: null, |
| 951 | assistantText: "", | 1958 | assistantText: "", |
| 952 | nextChildOrdinal: 101, | 1959 | nextChildOrdinal: 101, |
| 953 | }; | 1960 | }; |
| 954 | subagent.task = task; | 1961 | subagent.task = task; |
| 955 | context.subagents.set(update.nativeTaskId, subagent); | 1962 | context.subagents.set(nativeTaskId, subagent); |
| 956 | 1963 | ||
| 957 | if (existing === undefined) { | 1964 | if (existing === undefined) { |
| 958 | yield* emitProviderEvent({ | 1965 | yield* emitProviderEvent({ |
| @@ -979,7 +1986,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 20 unmodified lines | |||
| 979 | creationSource: "provider", | 1986 | creationSource: "provider", |
| 980 | }), | 1987 | }), |
| 981 | }); | 1988 | }); |
| 982 | const promptNativeItemId = `${update.nativeTaskId}:prompt`; | 1989 | const promptNativeItemId = `${nativeTaskId}:prompt`; |
| 983 | const promptArtifacts = makeSubagentConversationArtifacts({ | 1990 | const promptArtifacts = makeSubagentConversationArtifacts({ |
| 984 | messageId: idAllocator.derive.messageFromProviderItem({ | 1991 | messageId: idAllocator.derive.messageFromProviderItem({ |
| 985 | driver, | 1992 | driver, |
| @@ -1064,14 +2071,14 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 78 unmodified lines | |||
| 1064 | node: { | 2071 | node: { |
| 1065 | id: nodeId, | 2072 | id: nodeId, |
| 1066 | threadId: context.input.threadId, | 2073 | threadId: context.input.threadId, |
| 1067 | runId: context.input.runId, | 2074 | runId: subagent.task.runId, |
| 1068 | parentNodeId: context.input.rootNodeId, | 2075 | parentNodeId: subagent.task.parentNodeId, |
| 1069 | rootNodeId: context.input.rootNodeId, | 2076 | rootNodeId: subagent.task.parentNodeId, |
| 1070 | kind: "subagent", | 2077 | kind: "subagent", |
| 1071 | status: taskStatus, | 2078 | status: taskStatus, |
| 1072 | countsForRun: false, | 2079 | countsForRun: false, |
| 1073 | providerThreadId: context.input.providerThread.id, | 2080 | providerThreadId: context.input.providerThread.id, |
| 1074 | providerTurnId: context.providerTurnId, | 2081 | providerTurnId: subagent.providerTurnId, |
| 1075 | nativeItemRef, | 2082 | nativeItemRef, |
| 1076 | runtimeRequestId: null, | 2083 | runtimeRequestId: null, |
| 1077 | checkpointScopeId: null, | 2084 | checkpointScopeId: null, |
| @@ -1107,10 +2114,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 29 unmodified lines | |||
| 1107 | turnItem: { | 2114 | turnItem: { |
| 1108 | id: turnItemId, | 2115 | id: turnItemId, |
| 1109 | threadId: context.input.threadId, | 2116 | threadId: context.input.threadId, |
| 1110 | runId: context.input.runId, | 2117 | runId: subagent.task.runId, |
| 1111 | nodeId, | 2118 | nodeId, |
| 1112 | providerThreadId: context.input.providerThread.id, | 2119 | providerThreadId: context.input.providerThread.id, |
| 1113 | providerTurnId: context.providerTurnId, | 2120 | providerTurnId: subagent.providerTurnId, |
| 1114 | nativeItemRef, | 2121 | nativeItemRef, |
| 1115 | parentItemId: null, | 2122 | parentItemId: null, |
| 1116 | ordinal: turnItemOrdinal, | 2123 | ordinal: turnItemOrdinal, |
| @@ -1131,23 +2138,231 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 14 unmodified lines | |||
| 1131 | }); | 2138 | }); |
| 1132 | }); | 2139 | }); |
| 1133 | 2140 | ||
| 1134 | const emitTool = Effect.fnUntraced(function* ( | 2141 | const toolOutputText = (toolCall: AcpToolCallState): string => { |
| 2142 | if (typeof toolCall.data.rawOutput === "string") { | ||
| 2143 | return toolCall.data.rawOutput; | ||
| 2144 | } | ||
| 2145 | if ( | ||
| 2146 | typeof (toolCall.data.rawOutput as { text?: unknown } | undefined)?.text === "string" | ||
| 2147 | ) { | ||
| 2148 | return String((toolCall.data.rawOutput as { text: string }).text); | ||
| 2149 | } | ||
| 2150 | return textFromUnknown(toolCall.data.rawOutput) ?? ""; | ||
| 2151 | }; | ||
| 2152 | |||
| 2153 | const setToolOutputText = (toolCall: AcpToolCallState, text: string): AcpToolCallState => ({ | ||
| 2154 | ...toolCall, | ||
| 2155 | data: { | ||
| 2156 | ...toolCall.data, | ||
| 2157 | rawOutput: { | ||
| 2158 | type: "Text", | ||
| 2159 | text, | ||
| 2160 | }, | ||
| 2161 | }, | ||
| 2162 | }); | ||
| 2163 | |||
| 2164 | const appendToolOutputText = ( | ||
| 2165 | toolCall: AcpToolCallState, | ||
| 2166 | appendOutput: string, | ||
| 2167 | ): AcpToolCallState => { | ||
| 2168 | if (appendOutput.length === 0) return toolCall; | ||
| 2169 | return setToolOutputText(toolCall, `${toolOutputText(toolCall)}${appendOutput}`); | ||
| 2170 | }; | ||
| 2171 | |||
| 2172 | const isMonitorEndNoticeText = (text: string): boolean => | ||
| 2173 | /Monitor\s+["']?[0-9a-f-]{8,}/i.test(text) && /ended/i.test(text); | ||
| 2174 | |||
| 2175 | const hasDeferredBackgroundWork = (context: ActiveAcpTurn): boolean => { | ||
| 2176 | if (context.awaitingBackgroundHydration.size > 0) return true; | ||
| 2177 | if (context.pendingInjectedReport.size > 0) return true; | ||
| 2178 | for (const [taskId, toolCallId] of context.toolCallIdsByBackgroundTaskId) { | ||
| 2179 | // Persistent monitors stream after root settle; do not pin finalize. | ||
| 2180 | if (context.persistentBackgroundTaskIds.has(taskId)) continue; | ||
| 2181 | const tool = context.tools.get(toolCallId); | ||
| 2182 | if (tool === undefined) continue; | ||
| 2183 | const status = toolStatus(tool.status); | ||
| 2184 | if (status === "pending" || status === "running") return true; | ||
| 2185 | } | ||
| 2186 | for (const subagent of context.subagents.values()) { | ||
| 2187 | if (subagent.task.status === "running" || subagent.task.status === "pending") { | ||
| 2188 | return true; | ||
| 2189 | } | ||
| 2190 | } | ||
| 2191 | return false; | ||
| 2192 | }; | ||
| 2193 | |||
| 2194 | // scheduleDeferredFinalize is declared above openSession body start and | ||
| 2195 | // assigned after finalizeTurn so forked finalize Effect typing stays clean. | ||
| 2196 | const rearmDeferredFinalize = (context: ActiveAcpTurn) => | ||
| 2197 | Effect.gen(function* () { | ||
| 2198 | if (!flavor.deferFinalizeForBackgroundWork) return; | ||
| 2199 | if (!context.promptSettled || context.finalized) return; | ||
| 2200 | if (hasDeferredBackgroundWork(context)) return; | ||
| 2201 | yield* scheduleDeferredFinalize(context); | ||
| 2202 | }); | ||
| 2203 | |||
| 2204 | // `let` breaks circular inference from monitor hydration re-entry. | ||
| 2205 | let emitTool: ( | ||
| 2206 | context: ActiveAcpTurn, | ||
| 2207 | incoming: AcpToolCallState, | ||
| 2208 | projectedStatus?: ProjectedToolStatus, | ||
| 2209 | ) => Effect.Effect<void> = () => Effect.void; | ||
| 2210 | |||
| 2211 | const markAwaitingBackgroundHydration = (context: ActiveAcpTurn, taskId: string) => | ||
| 2212 | Effect.gen(function* () { | ||
| 2213 | if (context.awaitingBackgroundHydration.has(taskId)) return; | ||
| 2214 | context.awaitingBackgroundHydration.add(taskId); | ||
| 2215 | // Safety: do not hold the root turn forever if the agent never hydrates. | ||
| 2216 | yield* Effect.gen(function* () { | ||
| 2217 | yield* Effect.sleep("60000 millis"); | ||
| 2218 | if (context.finalized || !context.awaitingBackgroundHydration.has(taskId)) return; | ||
| 2219 | context.awaitingBackgroundHydration.delete(taskId); | ||
| 2220 | // End notices keep the tool running until hydration; force-complete so | ||
| 2221 | // deferred finalize can proceed when get_command never arrives. | ||
| 2222 | const toolCallId = context.toolCallIdsByBackgroundTaskId.get(taskId); | ||
| 2223 | const tool = toolCallId !== undefined ? context.tools.get(toolCallId) : undefined; | ||
| 2224 | if (tool !== undefined) { | ||
| 2225 | const status = toolStatus(tool.status); | ||
| 2226 | if (status === "pending" || status === "running") { | ||
| 2227 | yield* emitTool(context, { ...tool, status: "completed" }); | ||
| 2228 | } | ||
| 2229 | } | ||
| 2230 | yield* rearmDeferredFinalize(context); | ||
| 2231 | }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); | ||
| 2232 | }); | ||
| 2233 | |||
| 2234 | const markPendingInjectedReport = (context: ActiveAcpTurn, taskId: string) => | ||
| 2235 | Effect.gen(function* () { | ||
| 2236 | // Only the settled-and-held window has the race; mid-turn reports | ||
| 2237 | // are protected by the prompt RPC still being open. | ||
| 2238 | if (!context.promptSettled) return; | ||
| 2239 | if (context.pendingInjectedReport.has(taskId)) return; | ||
| 2240 | context.pendingInjectedReport.add(taskId); | ||
| 2241 | // Safety: the injected turn may end without a report chunk. | ||
| 2242 | yield* Effect.gen(function* () { | ||
| 2243 | yield* Effect.sleep("25000 millis"); | ||
| 2244 | if (context.finalized || !context.pendingInjectedReport.has(taskId)) return; | ||
| 2245 | context.pendingInjectedReport.delete(taskId); | ||
| 2246 | yield* rearmDeferredFinalize(context); | ||
| 2247 | }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); | ||
| 2248 | }); | ||
| 2249 | |||
| 2250 | emitTool = Effect.fnUntraced(function* ( | ||
| 1135 | context: ActiveAcpTurn, | 2251 | context: ActiveAcpTurn, |
| 1136 | incoming: AcpToolCallState, | 2252 | incoming: AcpToolCallState, |
| 2253 | projectedStatus?: ProjectedToolStatus, | ||
| 1137 | ) { | 2254 | ) { |
| 1138 | yield* closeTextStreams(context); | 2255 | yield* closeTextStreams(context); |
| 1139 | const previous = context.tools.get(incoming.toolCallId); | 2256 | const previous = context.tools.get(incoming.toolCallId); |
| 1140 | const toolCall = mergeToolCallState(previous, incoming); | 2257 | const merged = mergeToolCallState(previous, incoming); |
| 2258 | const toolCall = flavor.normalizeToolCall?.(merged) ?? merged; | ||
| 1141 | context.tools.set(toolCall.toolCallId, toolCall); | 2259 | context.tools.set(toolCall.toolCallId, toolCall); |
| 1142 | const subagentUpdate = flavor.extractSubagentUpdate?.(toolCall); | 2260 | const backgroundTaskId = flavor.extractBackgroundTaskId?.(toolCall); |
| 1143 | if (subagentUpdate !== undefined) { | 2261 | if (backgroundTaskId !== undefined) { |
| 1144 | yield* emitSubagent(context, subagentUpdate); | 2262 | context.toolCallIdsByBackgroundTaskId.set(backgroundTaskId, toolCall.toolCallId); |
| 1145 | return; | 2263 | if (flavor.isPersistentBackgroundTool?.(toolCall) === true) { |
| 2264 | context.persistentBackgroundTaskIds.add(backgroundTaskId); | ||
| 2265 | } | ||
| 2266 | const backgroundStatus = projectedStatus ?? toolStatus(toolCall.status); | ||
| 2267 | yield* setBackgroundTaskRunning( | ||
| 2268 | backgroundTaskId, | ||
| 2269 | backgroundStatus === "pending" || backgroundStatus === "running", | ||
| 2270 | ); | ||
| 2271 | // Background tool that reaches a terminal status while the root | ||
| 2272 | // prompt is still open (completed, failed, or interrupted) was | ||
| 2273 | // consumed in-turn. Mark handled so late CLI re-reports and residual | ||
| 2274 | // agent chatter do not open synthetic "Background task completed." | ||
| 2275 | // runs. get_command TaskOutput still marks handled below. | ||
| 2276 | // Only before promptSettled: after STARTED the deferred-finalize | ||
| 2277 | // hold can let a monitor finish while the turn is still active; | ||
| 2278 | // marking then would suppress the legitimate post-settle TaskOutput | ||
| 2279 | // continuation (live: grok-post-settle-continuation-poll). | ||
| 2280 | // Terminal statuses only after normalizeToolCall (start ACKs stay | ||
| 2281 | // inProgress/running). | ||
| 2282 | if ( | ||
| 2283 | !context.promptSettled && | ||
| 2284 | backgroundStatus !== "pending" && | ||
| 2285 | backgroundStatus !== "running" | ||
| 2286 | ) { | ||
| 2287 | yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => | ||
| 2288 | new Set(current).add(backgroundTaskId), | ||
| 2289 | ); | ||
| 2290 | } | ||
| 1146 | } | 2291 | } |
| 1147 | const status = toolStatus(toolCall.status); | 2292 | |
| 1148 | const now = yield* DateTime.now; | 2293 | // get_command TaskOutput for registered monitor(s): hydrate those tools. |
| 1149 | const nativeItemId = `${nativeThreadId(driver, context.input.providerThread)}:tool:${toolCall.toolCallId}`; | 2294 | const backgroundCompletions = |
| 1150 | const ordinal = yield* resolveItemOrdinal(context, nativeItemId); | 2295 | projectedStatus === undefined |
| 2296 | ? (flavor.extractBackgroundTaskCompletion?.(toolCall) ?? []) | ||
| 2297 | : []; | ||
| 2298 | let hydratedRegisteredMonitor = false; | ||
| 2299 | for (const backgroundCompletion of backgroundCompletions) { | ||
| 2300 | // Genuine end signal when terminal: tombstone so straggler | ||
| 2301 | // monitor-event chatter cannot resurrect the running set after the | ||
| 2302 | // task truly ended. A still-running poll keeps the id running. | ||
| 2303 | yield* applyBackgroundTaskMutationRunning(backgroundCompletion); | ||
| 2304 | if (backgroundCompletion.status !== "running") { | ||
| 2305 | yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => | ||
| 2306 | new Set(current).add(backgroundCompletion.taskId), | ||
| 2307 | ); | ||
| 2308 | } | ||
| 2309 | // A still-running fetch must keep the hydration hold (and its | ||
| 2310 | // safety timer) alive until output actually lands. | ||
| 2311 | if (backgroundCompletion.status !== "running") { | ||
| 2312 | context.awaitingBackgroundHydration.delete(backgroundCompletion.taskId); | ||
| 2313 | } | ||
| 2314 | const targetToolCallId = context.toolCallIdsByBackgroundTaskId.get( | ||
| 2315 | backgroundCompletion.taskId, | ||
| 2316 | ); | ||
| 2317 | // Known background-task id (monitor registration) — never open a | ||
| 2318 | // phantom subagent for the same get_output poll. | ||
| 2319 | if (targetToolCallId !== undefined) { | ||
| 2320 | hydratedRegisteredMonitor = true; | ||
| 2321 | } | ||
| 2322 | const target = | ||
| 2323 | targetToolCallId !== undefined ? context.tools.get(targetToolCallId) : undefined; | ||
| 2324 | if (target !== undefined && target.toolCallId !== toolCall.toolCallId) { | ||
| 2325 | const nextStatus = | ||
| 2326 | backgroundCompletion.status === "running" | ||
| 2327 | ? ("inProgress" as const) | ||
| 2328 | : backgroundCompletion.status === "failed" | ||
| 2329 | ? ("failed" as const) | ||
| 2330 | : ("completed" as const); | ||
| 2331 | const hydrated = | ||
| 2332 | backgroundCompletion.appendOutput.length > 0 | ||
| 2333 | ? // TaskOutput is the real stdout; replace end-notice boilerplate | ||
| 2334 | // so the timeline shows the listing, not only "Monitor ended…". | ||
| 2335 | isMonitorEndNoticeText(toolOutputText(target)) || | ||
| 2336 | toolOutputText(target).trim().length === 0 | ||
| 2337 | ? setToolOutputText( | ||
| 2338 | { ...target, status: nextStatus }, | ||
| 2339 | backgroundCompletion.appendOutput, | ||
| 2340 | ) | ||
| 2341 | : appendToolOutputText( | ||
| 2342 | { ...target, status: nextStatus }, | ||
| 2343 | backgroundCompletion.appendOutput, | ||
| 2344 | ) | ||
| 2345 | : { ...target, status: nextStatus }; | ||
| 2346 | yield* emitTool(context, hydrated); | ||
| 2347 | } | ||
| 2348 | } | ||
| 2349 | |||
| 2350 | // Monitor TaskOutput shares the get_command tool shape with subagent | ||
| 2351 | // hydration; do not spawn a phantom subagent for a registered monitor. | ||
| 2352 | const subagentUpdate = hydratedRegisteredMonitor | ||
| 2353 | ? undefined | ||
| 2354 | : flavor.extractSubagentUpdate?.(toolCall); | ||
| 2355 | if (subagentUpdate !== undefined) { | ||
| 2356 | yield* emitSubagent(context, subagentUpdate); | ||
| 2357 | if (subagentUpdate.suppressNormalTool !== false) { | ||
| 2358 | yield* rearmDeferredFinalize(context); | ||
| 2359 | return; | ||
| 2360 | } | ||
| 2361 | } | ||
| 2362 | const status = projectedStatus ?? toolStatus(toolCall.status); | ||
| 2363 | const now = yield* DateTime.now; | ||
| 2364 | const nativeItemId = `${nativeThreadId(driver, context.input.providerThread)}:tool:${toolCall.toolCallId}`; | ||
| 2365 | const ordinal = yield* resolveItemOrdinal(context, nativeItemId); | ||
| 1151 | const nodeId = idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId }); | 2366 | const nodeId = idAllocator.derive.nodeFromProviderItem({ driver, nativeItemId }); |
| 1152 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | 2367 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ |
| 1153 | driver, | 2368 | driver, |
| @@ -1203,6 +2418,30 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 49 unmodified lines | |||
| 1203 | const rawInput = toolCall.data.rawInput; | 2418 | const rawInput = toolCall.data.rawInput; |
| 1204 | const rawOutput = toolCall.data.rawOutput ?? toolCall.data.content; | 2419 | const rawOutput = toolCall.data.rawOutput ?? toolCall.data.content; |
| 1205 | const path = pathFromToolCall(toolCall); | 2420 | const path = pathFromToolCall(toolCall); |
| 2421 | const rawInputRecord = unknownRecord(rawInput); | ||
| 2422 | const inputVariant = | ||
| 2423 | typeof rawInputRecord?.variant === "string" | ||
| 2424 | ? rawInputRecord.variant.trim().toLowerCase() | ||
| 2425 | : ""; | ||
| 2426 | const rawOutputRecord = unknownRecord(rawOutput); | ||
| 2427 | const outputCommand = | ||
| 2428 | typeof rawOutputRecord?.command === "string" && | ||
| 2429 | rawOutputRecord.command.trim().length > 0 | ||
| 2430 | ? rawOutputRecord.command.trim() | ||
| 2431 | : undefined; | ||
| 2432 | const monitorCommand = | ||
| 2433 | (typeof rawInputRecord?.command === "string" && rawInputRecord.command.trim().length > 0 | ||
| 2434 | ? rawInputRecord.command.trim() | ||
| 2435 | : undefined) ?? outputCommand; | ||
| 2436 | const outputIsBashResult = | ||
| 2437 | typeof rawOutputRecord?.type === "string" && | ||
| 2438 | rawOutputRecord.type.trim().toLowerCase() === "bash" && | ||
| 2439 | commandExitCode(rawOutput) !== undefined; | ||
| 2440 | // Grok Monitor tools arrive as generic kind + variant; project like shell | ||
| 2441 | // so stdout is plain text in the timeline (not JSON {type:Text,text:...}). | ||
| 2442 | // Post-settle wake re-reports of a finished monitor carry no rawInput at | ||
| 2443 | // all, only a structured Bash result; project those as commands too. | ||
| 2444 | const projectAsCommandExecution = inputVariant === "monitor" || outputIsBashResult; | ||
| 1206 | let turnItem: OrchestrationV2TurnItem; | 2445 | let turnItem: OrchestrationV2TurnItem; |
| 1207 | switch (toolCall.kind) { | 2446 | switch (toolCall.kind) { |
| 1208 | case "read": | 2447 | case "read": |
| @@ -1225,19 +2464,19 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 16 unmodified lines | |||
| 1225 | }), | 2464 | }), |
| 1226 | }; | 2465 | }; |
| 1227 | break; | 2466 | break; |
| 1228 | case "execute": | 2467 | case "execute": { |
| 2468 | const exitCode = acpProjectedCommandExitCode(status, rawOutput); | ||
| 1229 | turnItem = { | 2469 | turnItem = { |
| 1230 | ...base, | 2470 | ...base, |
| 1231 | type: "command_execution", | 2471 | type: "command_execution", |
| 1232 | input: toolCall.command ?? toolCall.title ?? "Command", | 2472 | input: toolCall.command ?? monitorCommand ?? toolCall.title ?? "Command", |
| 1233 | ...(textFromUnknown(rawOutput) === undefined | 2473 | ...(textFromUnknown(rawOutput) === undefined |
| 1234 | ? {} | 2474 | ? {} |
| 1235 | : { output: textFromUnknown(rawOutput) }), | 2475 | : { output: textFromUnknown(rawOutput) }), |
| 1236 | ...(commandExitCode(rawOutput) === undefined | 2476 | ...(exitCode === undefined ? {} : { exitCode }), |
| 1237 | ? {} | ||
| 1238 | : { exitCode: commandExitCode(rawOutput) }), | ||
| 1239 | }; | 2477 | }; |
| 1240 | break; | 2478 | break; |
| 2479 | } | ||
| 1241 | case "edit": | 2480 | case "edit": |
| 1242 | case "delete": | 2481 | case "delete": |
| 1243 | case "move": | 2482 | case "move": |
| @@ -1270,15 +2509,33 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 26 unmodified lines | |||
| 1270 | }; | 2509 | }; |
| 1271 | break; | 2510 | break; |
| 1272 | default: | 2511 | default: |
| 1273 | turnItem = { | 2512 | if (projectAsCommandExecution) { |
| 1274 | ...base, | 2513 | const exitCode = acpProjectedCommandExitCode(status, rawOutput); |
| 1275 | type: "dynamic_tool", | 2514 | turnItem = { |
| 1276 | toolName: toolCall.title ?? toolCall.kind ?? null, | 2515 | ...base, |
| 1277 | input: rawInput ?? {}, | 2516 | type: "command_execution", |
| 1278 | ...(rawOutput === undefined ? {} : { output: rawOutput }), | 2517 | input: |
| 1279 | }; | 2518 | toolCall.command ?? |
| 2519 | monitorCommand ?? | ||
| 2520 | toolCall.title ?? | ||
| 2521 | (inputVariant === "monitor" ? "Monitor" : "Command"), | ||
| 2522 | ...(textFromUnknown(rawOutput) === undefined | ||
| 2523 | ? {} | ||
| 2524 | : { output: textFromUnknown(rawOutput) }), | ||
| 2525 | ...(exitCode === undefined ? {} : { exitCode }), | ||
| 2526 | }; | ||
| 2527 | } else { | ||
| 2528 | turnItem = { | ||
| 2529 | ...base, | ||
| 2530 | type: "dynamic_tool", | ||
| 2531 | toolName: toolCall.title ?? toolCall.kind ?? null, | ||
| 2532 | input: rawInput ?? {}, | ||
| 2533 | ...(rawOutput === undefined ? {} : { output: rawOutput }), | ||
| 2534 | }; | ||
| 2535 | } | ||
| 1280 | } | 2536 | } |
| 1281 | yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem }); | 2537 | yield* emitProviderEvent({ type: "turn_item.updated", driver, turnItem }); |
| 2538 | yield* rearmDeferredFinalize(context); | ||
| 1282 | }); | 2539 | }); |
| 1283 | 2540 | ||
| 1284 | const emitPlan = Effect.fnUntraced(function* ( | 2541 | const emitPlan = Effect.fnUntraced(function* ( |
| @@ -1417,22 +2674,287 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 132 unmodified lines | |||
| 1417 | }); | 2674 | }); |
| 1418 | }); | 2675 | }); |
| 1419 | 2676 | ||
| 2677 | const offerContinuationRun = Effect.fnUntraced(function* (sessionId: string) { | ||
| 2678 | if (continuationRequests === undefined) { | ||
| 2679 | return; | ||
| 2680 | } | ||
| 2681 | const pending = yield* continuationPermit.withPermit( | ||
| 2682 | Effect.gen(function* () { | ||
| 2683 | if (yield* Ref.get(continuationClosed)) return Option.none(); | ||
| 2684 | if (yield* Ref.get(stoppedRunQuarantine)) return Option.none(); | ||
| 2685 | if (yield* Ref.get(continuationRequested)) return Option.none(); | ||
| 2686 | const route = yield* Ref.get(lastTurnRoute); | ||
| 2687 | if (route === null) return Option.none(); | ||
| 2688 | yield* Ref.set(continuationRequested, true); | ||
| 2689 | const generation = yield* Ref.updateAndGet( | ||
| 2690 | continuationGeneration, | ||
| 2691 | (value) => value + 1, | ||
| 2692 | ); | ||
| 2693 | return Option.some({ route, generation }); | ||
| 2694 | }), | ||
| 2695 | ); | ||
| 2696 | if (Option.isNone(pending)) return; | ||
| 2697 | const { route, generation } = pending.value; | ||
| 2698 | yield* Effect.logInfo("orchestration-v2.acp-wake-turn-detected", { | ||
| 2699 | driver, | ||
| 2700 | providerSessionId: input.providerSessionId, | ||
| 2701 | threadId: route.threadId, | ||
| 2702 | providerThreadId: route.providerThreadId, | ||
| 2703 | }); | ||
| 2704 | yield* continuationRequests.offer({ | ||
| 2705 | threadId: route.threadId, | ||
| 2706 | providerThreadId: route.providerThreadId, | ||
| 2707 | driver, | ||
| 2708 | detail: null, | ||
| 2709 | dispatchIfCurrent: (effect) => | ||
| 2710 | continuationPermit.withPermit( | ||
| 2711 | Effect.gen(function* () { | ||
| 2712 | const clearIfOwner = Effect.gen(function* () { | ||
| 2713 | if ((yield* Ref.get(continuationGeneration)) === generation) { | ||
| 2714 | yield* Ref.set(continuationRequested, false); | ||
| 2715 | } | ||
| 2716 | }); | ||
| 2717 | if (yield* Ref.get(stoppedRunQuarantine)) { | ||
| 2718 | yield* clearIfOwner; | ||
| 2719 | return Option.none(); | ||
| 2720 | } | ||
| 2721 | if ((yield* Ref.get(continuationGeneration)) !== generation) { | ||
| 2722 | // Superseded by a newer offer; do not clear its flag. | ||
| 2723 | return Option.none(); | ||
| 2724 | } | ||
| 2725 | if (!(yield* Ref.get(continuationRequested))) return Option.none(); | ||
| 2726 | // Clear after success or failure so a dropped dispatch does not | ||
| 2727 | // stick the flag and pin hasPendingBackgroundWork forever. | ||
| 2728 | const exit = yield* Effect.exit(effect); | ||
| 2729 | yield* clearIfOwner; | ||
| 2730 | if (Exit.isFailure(exit)) { | ||
| 2731 | return yield* Effect.failCause(exit.cause); | ||
| 2732 | } | ||
| 2733 | return Option.some(exit.value); | ||
| 2734 | }), | ||
| 2735 | ), | ||
| 2736 | }); | ||
| 2737 | }); | ||
| 2738 | |||
| 2739 | const applyLateBackgroundMutation = Effect.fnUntraced(function* ( | ||
| 2740 | sessionId: string, | ||
| 2741 | mutation: { | ||
| 2742 | readonly taskId: string; | ||
| 2743 | readonly status: "running" | "completed" | "failed"; | ||
| 2744 | }, | ||
| 2745 | ) { | ||
| 2746 | const taskAlreadyEnded = (yield* Ref.get(endedBackgroundTaskIds)).has(mutation.taskId); | ||
| 2747 | yield* applyBackgroundTaskMutationRunning(mutation); | ||
| 2748 | if (taskAlreadyEnded && acpPostSettleMonitorPromptShouldSuppress(mutation)) { | ||
| 2749 | yield* Ref.set(suppressPostSettleMonitorPrompt, true); | ||
| 2750 | } | ||
| 2751 | if (mutation.status !== "running") { | ||
| 2752 | yield* Ref.set(suppressPostSettleMonitorPrompt, false); | ||
| 2753 | yield* Ref.update(handledBackgroundTaskIdsInActiveTurn, (current) => { | ||
| 2754 | if (!current.has(mutation.taskId)) return current; | ||
| 2755 | const next = new Set(current); | ||
| 2756 | next.delete(mutation.taskId); | ||
| 2757 | return next; | ||
| 2758 | }); | ||
| 2759 | if ( | ||
| 2760 | postSettleContinuationEnabled && | ||
| 2761 | (yield* Ref.get(activeSessionId)) === sessionId && | ||
| 2762 | (yield* Ref.get(runningBackgroundTaskIds)).size === 0 && | ||
| 2763 | (yield* Ref.get(wakeBuffer)).length > 0 | ||
| 2764 | ) { | ||
| 2765 | yield* offerContinuationRun(sessionId); | ||
| 2766 | } | ||
| 2767 | } | ||
| 2768 | }); | ||
| 2769 | |||
| 2770 | const bufferPostSettleWake = Effect.fnUntraced(function* ( | ||
| 2771 | notification: EffectAcpSchema.SessionNotification, | ||
| 2772 | ) { | ||
| 2773 | if (!postSettleContinuationEnabled || continuationRequests === undefined) { | ||
| 2774 | return false; | ||
| 2775 | } | ||
| 2776 | // Direct Stop quarantine: drop residual wake evidence instead of | ||
| 2777 | // buffering it for a later continuation or follow-up run. | ||
| 2778 | if (yield* Ref.get(stoppedRunQuarantine)) { | ||
| 2779 | return true; | ||
| 2780 | } | ||
| 2781 | const rootSessionId = yield* Ref.get(activeSessionId); | ||
| 2782 | if (rootSessionId === null || notification.sessionId !== rootSessionId) { | ||
| 2783 | return false; | ||
| 2784 | } | ||
| 2785 | if (!acpPostSettleWakeEvidence(notification, flavor)) { | ||
| 2786 | return false; | ||
| 2787 | } | ||
| 2788 | const update = notification.update; | ||
| 2789 | let alreadyHandledToolUpdate = false; | ||
| 2790 | if (update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") { | ||
| 2791 | yield* trackRunningBackgroundTools(notification); | ||
| 2792 | // When the agent consumes the monitor output itself via | ||
| 2793 | // get_command_or_subagent_output (long timeout), the TaskOutput | ||
| 2794 | // completion is the ONLY end signal: no "Monitor ended" reminder | ||
| 2795 | // follows. Without applying it here the running set never clears, | ||
| 2796 | // every offer stays suppressed, and the wake buffer never drains | ||
| 2797 | // (observed live 2026-07-12, thread 8dbe607f). The completion | ||
| 2798 | // frame itself is offer evidence, so clearing the id before the | ||
| 2799 | // gate below lets it open the single continuation run. | ||
| 2800 | for (const event of parseSessionUpdateEvent(notification).events) { | ||
| 2801 | if (event._tag !== "ToolCallUpdated") continue; | ||
| 2802 | const toolCall = flavor.normalizeToolCall?.(event.toolCall) ?? event.toolCall; | ||
| 2803 | const toolTaskId = flavor.extractBackgroundTaskId?.(toolCall); | ||
| 2804 | if ( | ||
| 2805 | toolTaskId !== undefined && | ||
| 2806 | (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(toolTaskId) | ||
| 2807 | ) { | ||
| 2808 | alreadyHandledToolUpdate = true; | ||
| 2809 | } | ||
| 2810 | if (flavor.extractBackgroundTaskCompletion !== undefined) { | ||
| 2811 | for (const completion of flavor.extractBackgroundTaskCompletion(toolCall)) { | ||
| 2812 | alreadyHandledToolUpdate = | ||
| 2813 | alreadyHandledToolUpdate || | ||
| 2814 | (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).has(completion.taskId); | ||
| 2815 | yield* applyBackgroundTaskMutationRunning(completion); | ||
| 2816 | } | ||
| 2817 | } | ||
| 2818 | } | ||
| 2819 | } | ||
| 2820 | const backgroundWorkRunning = (yield* Ref.get(runningBackgroundTaskIds)).size > 0; | ||
| 2821 | // Grok prompts itself for every monitor event after the root turn | ||
| 2822 | // settles. Its assistant/reasoning replies are progress chatter, not | ||
| 2823 | // separate wake results. Retaining them would replay the entire burst | ||
| 2824 | // into the single continuation once the monitor finishes. Keep tool | ||
| 2825 | // state so the final command card still hydrates, then begin retaining | ||
| 2826 | // agent output again after the genuine end signal clears the running | ||
| 2827 | // set. With multiple tasks, text remains best-effort until every task | ||
| 2828 | // ends; their retained tool cards are the authoritative results. | ||
| 2829 | // | ||
| 2830 | // Skip retaining frames for tasks already hydrated in the root turn | ||
| 2831 | // (`handledBackgroundTaskIdsInActiveTurn`). Those re-reports must not | ||
| 2832 | // pin `hasPendingBackgroundWork` via a wake buffer that never drains | ||
| 2833 | // (the already-handled gate below intentionally skips | ||
| 2834 | // `offerContinuationRun` to avoid synthetic "Background task | ||
| 2835 | // completed." spam). | ||
| 2836 | if ( | ||
| 2837 | !alreadyHandledToolUpdate && | ||
| 2838 | acpPostSettleWakeShouldBuffer(notification, backgroundWorkRunning) | ||
| 2839 | ) { | ||
| 2840 | yield* Ref.update(wakeBuffer, (current) => [...current, notification]); | ||
| 2841 | } | ||
| 2842 | // Buffer progress without offering; only completion-like frames open a run. | ||
| 2843 | if (!acpPostSettleContinuationOfferEvidence(notification, flavor)) { | ||
| 2844 | return true; | ||
| 2845 | } | ||
| 2846 | // While a monitor is still streaming, tool re-reports buffer without | ||
| 2847 | // offering and per-event agent commentary is consumed without being | ||
| 2848 | // retained. Grok re-reports a running monitor as Bash frames that | ||
| 2849 | // already carry exit_code 0 mid-stream, so a "terminal" normalized | ||
| 2850 | // status is not evidence the task ended; each burst would otherwise | ||
| 2851 | // reopen a synthetic "Background task completed." run. Retained tool | ||
| 2852 | // frames drain into the single continuation offered once the monitor | ||
| 2853 | // actually ends (end-notice mutation below, or the first frame after | ||
| 2854 | // it). | ||
| 2855 | if (backgroundWorkRunning) { | ||
| 2856 | return true; | ||
| 2857 | } | ||
| 2858 | if (alreadyHandledToolUpdate) { | ||
| 2859 | // Drop leftover wake noise for in-turn-handled work so idle release | ||
| 2860 | // is not pinned forever. Leave the buffer alone when a continuation | ||
| 2861 | // is already outstanding: its startTurn will drain legitimate frames | ||
| 2862 | // from other tasks. | ||
| 2863 | if (!(yield* Ref.get(continuationRequested))) { | ||
| 2864 | yield* Ref.set(wakeBuffer, []); | ||
| 2865 | } | ||
| 2866 | return true; | ||
| 2867 | } | ||
| 2868 | // Residual Grok agent/thought chatter after in-turn-handled background | ||
| 2869 | // work must not open synthetic continuation runs. Tool-path | ||
| 2870 | // alreadyHandled covers re-reports with a task id; this covers | ||
| 2871 | // agent_message_chunk frames that carry no task id (live: | ||
| 2872 | // grok-in-turn-monitor-no-wake). Running work already returned above. | ||
| 2873 | // Do not clear wakeBuffer here: frames for other still-tracked tasks | ||
| 2874 | // must remain drainable when a real (tool) completion later offers. | ||
| 2875 | const handledInTurnCount = (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size; | ||
| 2876 | if ( | ||
| 2877 | handledInTurnCount > 0 && | ||
| 2878 | (update.sessionUpdate === "agent_message_chunk" || | ||
| 2879 | update.sessionUpdate === "agent_thought_chunk") | ||
| 2880 | ) { | ||
| 2881 | return true; | ||
| 2882 | } | ||
| 2883 | yield* offerContinuationRun(notification.sessionId); | ||
| 2884 | return true; | ||
| 2885 | }); | ||
| 2886 | |||
| 1420 | const handleSessionUpdate = Effect.fnUntraced(function* ( | 2887 | const handleSessionUpdate = Effect.fnUntraced(function* ( |
| 1421 | notification: EffectAcpSchema.SessionNotification, | 2888 | notification: EffectAcpSchema.SessionNotification, |
| 1422 | ) { | 2889 | ) { |
| 1423 | const context = yield* Ref.get(activeTurn); | 2890 | const context = yield* Ref.get(activeTurn); |
| 1424 | const update = notification.update; | 2891 | const update = notification.update; |
| 2892 | // Only while a finalized turn is still the active context. When | ||
| 2893 | // activeTurn is null, post-settle agent frames must reach | ||
| 2894 | // bufferPostSettleWake so continuation can attach (context?.finalized | ||
| 2895 | // !== false incorrectly treated null as finalized and dropped them). | ||
| 2896 | if ( | ||
| 2897 | context !== null && | ||
| 2898 | context.finalized && | ||
| 2899 | (yield* Ref.get(handledBackgroundTaskIdsInActiveTurn)).size > 0 && | ||
| 2900 | (update.sessionUpdate === "agent_message_chunk" || | ||
| 2901 | update.sessionUpdate === "agent_thought_chunk") | ||
| 2902 | ) { | ||
| 2903 | yield* Ref.set(suppressPostSettleMonitorPrompt, true); | ||
| 2904 | return; | ||
| 2905 | } | ||
| 2906 | if ( | ||
| 2907 | context !== null && | ||
| 2908 | (yield* Ref.get(suppressPostSettleMonitorPrompt)) && | ||
| 2909 | (update.sessionUpdate === "agent_message_chunk" || | ||
| 2910 | update.sessionUpdate === "agent_thought_chunk") | ||
| 2911 | ) { | ||
| 2912 | return; | ||
| 2913 | } | ||
| 2914 | if ( | ||
| 2915 | context?.finalized === true && | ||
| 2916 | update.sessionUpdate === "user_message_chunk" && | ||
| 2917 | update.content.type === "text" | ||
| 2918 | ) { | ||
| 2919 | const mutations = flavor.extractBackgroundToolMutation?.(update.content.text) ?? []; | ||
| 2920 | for (const mutation of mutations) { | ||
| 2921 | yield* applyLateBackgroundMutation(notification.sessionId, mutation); | ||
| 2922 | } | ||
| 2923 | return; | ||
| 2924 | } | ||
| 1425 | if (context === null) { | 2925 | if (context === null) { |
| 2926 | // Direct Stop: quarantine residual events from the stopped run so | ||
| 2927 | // they cannot become history, wake buffers, or a later run attach. | ||
| 2928 | if (yield* Ref.get(stoppedRunQuarantine)) { | ||
| 2929 | return; | ||
| 2930 | } | ||
| 2931 | // Prefer continuation buffering over history append so the same | ||
| 2932 | // frames are not double-counted once a continuation run attaches. | ||
| 2933 | if (yield* bufferPostSettleWake(notification)) { | ||
| 2934 | return; | ||
| 2935 | } | ||
| 1426 | if ( | 2936 | if ( |
| 1427 | (update.sessionUpdate === "user_message_chunk" || | 2937 | (update.sessionUpdate === "user_message_chunk" || |
| 1428 | update.sessionUpdate === "agent_message_chunk") && | 2938 | update.sessionUpdate === "agent_message_chunk") && |
| 1429 | update.content.type === "text" | 2939 | update.content.type === "text" |
| 1430 | ) { | 2940 | ) { |
| 1431 | yield* appendLoadedHistory( | 2941 | // Late monitor end/event reminders must not become ghost user/assistant |
| 1432 | notification, | 2942 | // history (or OS-facing chatter) after the root turn already finalized. |
| 1433 | update.sessionUpdate === "user_message_chunk" ? "user" : "assistant", | 2943 | const text = update.content.text; |
| 1434 | update.content.text, | 2944 | const lateBackgroundMutations = flavor.extractBackgroundToolMutation?.(text) ?? []; |
| 1435 | ); | 2945 | for (const lateBackgroundMutation of lateBackgroundMutations) { |
| 2946 | yield* applyLateBackgroundMutation(notification.sessionId, lateBackgroundMutation); | ||
| 2947 | } | ||
| 2948 | const lateMonitorChatter = | ||
| 2949 | /<monitor-event\b/i.test(text) || | ||
| 2950 | /Monitor\s+["']?[0-9a-f-]{8,}["']?\s+ended/i.test(text); | ||
| 2951 | if (lateBackgroundMutations.length === 0 && !lateMonitorChatter) { | ||
| 2952 | yield* appendLoadedHistory( | ||
| 2953 | notification, | ||
| 2954 | update.sessionUpdate === "user_message_chunk" ? "user" : "assistant", | ||
| 2955 | text, | ||
| 2956 | ); | ||
| 2957 | } | ||
| 1436 | } else if ( | 2958 | } else if ( |
| 1437 | update.sessionUpdate === "tool_call" || | 2959 | update.sessionUpdate === "tool_call" || |
| 1438 | update.sessionUpdate === "tool_call_update" || | 2960 | update.sessionUpdate === "tool_call_update" || |
| @@ -1444,6 +2966,8 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines | |||
| 1444 | } | 2966 | } |
| 1445 | if (context.finalized) return; | 2967 | if (context.finalized) return; |
| 1446 | if (notification.sessionId !== (yield* Ref.get(activeSessionId))) { | 2968 | if (notification.sessionId !== (yield* Ref.get(activeSessionId))) { |
| 2969 | // Finalize may have completed during the activeSessionId yield. | ||
| 2970 | if (context.finalized) return; | ||
| 1447 | if (flavor.extractSubagentUpdate === undefined) return; | 2971 | if (flavor.extractSubagentUpdate === undefined) return; |
| 1448 | if (update.sessionUpdate !== "agent_message_chunk" || update.content.type !== "text") { | 2972 | if (update.sessionUpdate !== "agent_message_chunk" || update.content.type !== "text") { |
| 1449 | return; | 2973 | return; |
| @@ -1458,17 +2982,90 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 8 unmodified lines | |||
| 1458 | context.pendingSubagentNotifications.set(notification.sessionId, buffered); | 2982 | context.pendingSubagentNotifications.set(notification.sessionId, buffered); |
| 1459 | return; | 2983 | return; |
| 1460 | } | 2984 | } |
| 2985 | // Re-check after the activeSessionId yield: idle/prompt settle can | ||
| 2986 | // finalize the same context object while we waited. | ||
| 2987 | if (context.finalized) return; | ||
| 1461 | switch (update.sessionUpdate) { | 2988 | switch (update.sessionUpdate) { |
| 1462 | case "agent_message_chunk": | 2989 | case "agent_message_chunk": |
| 1463 | if (update.content.type === "text") { | 2990 | if (update.content.type === "text") { |
| 2991 | // The injected-turn report is streaming; the normal debounce | ||
| 2992 | // after the last chunk takes over from here. | ||
| 2993 | if (context.pendingInjectedReport.size > 0) { | ||
| 2994 | context.pendingInjectedReport.clear(); | ||
| 2995 | } | ||
| 1464 | yield* appendText(context, "assistant", update.content.text); | 2996 | yield* appendText(context, "assistant", update.content.text); |
| 1465 | } | 2997 | } |
| 1466 | return; | 2998 | break; |
| 1467 | case "agent_thought_chunk": | 2999 | case "agent_thought_chunk": |
| 1468 | if (update.content.type === "text") { | 3000 | if (update.content.type === "text") { |
| 1469 | yield* appendText(context, "reasoning", update.content.text); | 3001 | yield* appendText(context, "reasoning", update.content.text); |
| 1470 | } | 3002 | } |
| 1471 | return; | 3003 | break; |
| 3004 | case "user_message_chunk": | ||
| 3005 | if (update.content.type === "text" && flavor.extractBackgroundToolMutation) { | ||
| 3006 | for (const mutation of flavor.extractBackgroundToolMutation(update.content.text)) { | ||
| 3007 | const toolCallId = context.toolCallIdsByBackgroundTaskId.get(mutation.taskId); | ||
| 3008 | const previous = | ||
| 3009 | toolCallId !== undefined ? context.tools.get(toolCallId) : undefined; | ||
| 3010 | if (previous !== undefined) { | ||
| 3011 | let nextStatus = | ||
| 3012 | mutation.status === "running" | ||
| 3013 | ? ("inProgress" as const) | ||
| 3014 | : mutation.status === "failed" | ||
| 3015 | ? ("failed" as const) | ||
| 3016 | : ("completed" as const); | ||
| 3017 | // End notices typically omit full stdout (no get_command mention | ||
| 3018 | // either). Keep the tool running and hold finalize until | ||
| 3019 | // TaskOutput hydrates, or the safety timer force-completes. | ||
| 3020 | if (nextStatus !== "inProgress") { | ||
| 3021 | yield* markAwaitingBackgroundHydration(context, mutation.taskId); | ||
| 3022 | nextStatus = "inProgress"; | ||
| 3023 | } | ||
| 3024 | yield* emitTool( | ||
| 3025 | context, | ||
| 3026 | appendToolOutputText( | ||
| 3027 | { ...previous, status: nextStatus }, | ||
| 3028 | mutation.appendOutput, | ||
| 3029 | ), | ||
| 3030 | ); | ||
| 3031 | } | ||
| 3032 | // After emitTool: the hydration hold keeps the tool row at | ||
| 3033 | // inProgress past an end notice, but the offer gate must see | ||
| 3034 | // the mutation's semantic status. Also tracks monitors that | ||
| 3035 | // never surfaced a tool_call row at all. | ||
| 3036 | yield* applyBackgroundTaskMutationRunning(mutation); | ||
| 3037 | if (mutation.status !== "running") { | ||
| 3038 | yield* markPendingInjectedReport(context, mutation.taskId); | ||
| 3039 | } | ||
| 3040 | } | ||
| 3041 | } | ||
| 3042 | if (update.content.type === "text" && flavor.extractSubagentEndNotice) { | ||
| 3043 | const notice = flavor.extractSubagentEndNotice(update.content.text); | ||
| 3044 | const subagent = | ||
| 3045 | notice !== undefined | ||
| 3046 | ? context.subagentsBySessionId.get(notice.childSessionId) | ||
| 3047 | : undefined; | ||
| 3048 | if ( | ||
| 3049 | notice !== undefined && | ||
| 3050 | subagent !== undefined && | ||
| 3051 | (subagent.task.status === "running" || subagent.task.status === "pending") | ||
| 3052 | ) { | ||
| 3053 | // The agent is free to answer with text only and never call | ||
| 3054 | // get_command_or_subagent_output; without this, the subagent | ||
| 3055 | // row stays running and holds deferred finalize open forever. | ||
| 3056 | yield* emitSubagent(context, { | ||
| 3057 | nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? notice.childSessionId, | ||
| 3058 | prompt: subagent.task.prompt, | ||
| 3059 | title: subagent.task.title, | ||
| 3060 | model: subagent.task.model, | ||
| 3061 | status: notice.status, | ||
| 3062 | childSessionId: notice.childSessionId, | ||
| 3063 | result: null, | ||
| 3064 | suppressNormalTool: true, | ||
| 3065 | }); | ||
| 3066 | } | ||
| 3067 | } | ||
| 3068 | break; | ||
| 1472 | default: { | 3069 | default: { |
| 1473 | const parsed = parseSessionUpdateEvent(notification); | 3070 | const parsed = parseSessionUpdateEvent(notification); |
| 1474 | for (const event of parsed.events) { | 3071 | for (const event of parsed.events) { |
| @@ -1480,20 +3077,13 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines | |||
| 1480 | } | 3077 | } |
| 1481 | } | 3078 | } |
| 1482 | } | 3079 | } |
| 3080 | if (acpRootSessionUpdateIngestsOutput(notification)) { | ||
| 3081 | yield* scheduleSettleRootTurnWhenIdle(context); | ||
| 3082 | } | ||
| 3083 | // Keep deferred finalize quiet-window fresh while wake traffic lands. | ||
| 3084 | yield* rearmDeferredFinalize(context); | ||
| 1483 | }); | 3085 | }); |
| 1484 | 3086 | ||
| 1485 | yield* runtime.handleSessionUpdate((notification) => | ||
| 1486 | handleSessionUpdate(notification).pipe( | ||
| 1487 | Effect.mapError( | ||
| 1488 | (cause) => | ||
| 1489 | new EffectAcpErrors.AcpTransportError({ | ||
| 1490 | detail: "Failed to project an ACP session update", | ||
| 1491 | cause, | ||
| 1492 | }), | ||
| 1493 | ), | ||
| 1494 | ), | ||
| 1495 | ); | ||
| 1496 | |||
| 1497 | const activeContext = Effect.gen(function* () { | 3087 | const activeContext = Effect.gen(function* () { |
| 1498 | const context = yield* Ref.get(activeTurn); | 3088 | const context = yield* Ref.get(activeTurn); |
| 1499 | if (context === null) { | 3089 | if (context === null) { |
| @@ -1505,9 +3095,11 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines | |||
| 1505 | return context; | 3095 | return context; |
| 1506 | }); | 3096 | }); |
| 1507 | 3097 | ||
| 1508 | const emitApprovalRequest = Effect.fnUntraced(function* ( | 3098 | const beginApprovalRequest = Effect.fnUntraced(function* ( |
| 1509 | context: ActiveAcpTurn, | 3099 | context: ActiveAcpTurn, |
| 1510 | params: EffectAcpSchema.RequestPermissionRequest, | 3100 | params: EffectAcpSchema.RequestPermissionRequest, |
| 3101 | generation: number, | ||
| 3102 | transportRequestId: string, | ||
| 1511 | ) { | 3103 | ) { |
| 1512 | yield* closeTextStreams(context); | 3104 | yield* closeTextStreams(context); |
| 1513 | const parsed = parsePermissionRequest(params); | 3105 | const parsed = parsePermissionRequest(params); |
| @@ -1518,6 +3110,15 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 4 unmodified lines | |||
| 1518 | nativeRequestId, | 3110 | nativeRequestId, |
| 1519 | }); | 3111 | }); |
| 1520 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); | 3112 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); |
| 3113 | const nativeResponseAcknowledgement = yield* Deferred.make< | ||
| 3114 | void, | ||
| 3115 | EffectAcpErrors.AcpError | ||
| 3116 | >(); | ||
| 3117 | yield* registerNativeResponseAcknowledgement( | ||
| 3118 | generation, | ||
| 3119 | transportRequestId, | ||
| 3120 | nativeResponseAcknowledgement, | ||
| 3121 | ); | ||
| 1521 | const now = yield* DateTime.now; | 3122 | const now = yield* DateTime.now; |
| 1522 | const nodeId = idAllocator.derive.approvalNode({ requestId }); | 3123 | const nodeId = idAllocator.derive.approvalNode({ requestId }); |
| 1523 | const requestKind = providerRequestKind(parsed.kind); | 3124 | const requestKind = providerRequestKind(parsed.kind); |
| @@ -1581,7 +3182,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 57 unmodified lines | |||
| 1581 | const updated = new Map(current); | 3182 | const updated = new Map(current); |
| 1582 | updated.set(String(requestId), { | 3183 | updated.set(String(requestId), { |
| 1583 | type: "approval", | 3184 | type: "approval", |
| 3185 | generation, | ||
| 3186 | nativeResponseAcknowledgement, | ||
| 1584 | requestId, | 3187 | requestId, |
| 3188 | transportRequestId, | ||
| 1585 | decision, | 3189 | decision, |
| 1586 | runtimeRequest, | 3190 | runtimeRequest, |
| 1587 | node, | 3191 | node, |
| @@ -1605,55 +3209,19 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 17 unmodified lines | |||
| 1605 | driver, | 3209 | driver, |
| 1606 | turnItem, | 3210 | turnItem, |
| 1607 | }); | 3211 | }); |
| 1608 | const resolved = yield* Deferred.await(decision).pipe( | 3212 | return { |
| 1609 | Effect.ensuring( | 3213 | context, |
| 1610 | Ref.update(pendingRuntimeRequests, (current) => { | 3214 | decision, |
| 1611 | const updated = new Map(current); | 3215 | nativeResponseAcknowledgement, |
| 1612 | updated.delete(String(requestId)); | 3216 | requestId, |
| 1613 | return updated; | 3217 | transportRequestId, |
| 1614 | }), | 3218 | } as const; |
| 1615 | ), | ||
| 1616 | ); | ||
| 1617 | return resolved; | ||
| 1618 | }); | 3219 | }); |
| 1619 | 3220 | ||
| 1620 | yield* runtime.handleRequestPermission((params) => | 3221 | const beginUserInputRequest = Effect.fnUntraced(function* ( |
| 1621 | Effect.gen(function* () { | ||
| 1622 | const context = yield* activeContext; | ||
| 1623 | const disposition = acpPermissionDisposition(context.input.runtimePolicy, params); | ||
| 1624 | if (disposition === "allow") { | ||
| 1625 | const optionId = selectAutoApprovedPermissionOption(params); | ||
| 1626 | return optionId === undefined | ||
| 1627 | ? ({ outcome: { outcome: "cancelled" } } as const) | ||
| 1628 | : ({ outcome: { outcome: "selected", optionId } } as const); | ||
| 1629 | } | ||
| 1630 | if (disposition === "deny") { | ||
| 1631 | const optionId = selectPermissionOptionId(params, "decline"); | ||
| 1632 | return optionId === undefined | ||
| 1633 | ? ({ outcome: { outcome: "cancelled" } } as const) | ||
| 1634 | : ({ outcome: { outcome: "selected", optionId } } as const); | ||
| 1635 | } | ||
| 1636 | const decision = yield* emitApprovalRequest(context, params); | ||
| 1637 | if (decision === "cancel") { | ||
| 1638 | return { outcome: { outcome: "cancelled" } } as const; | ||
| 1639 | } | ||
| 1640 | const optionId = selectPermissionOptionId(params, decision); | ||
| 1641 | return optionId === undefined | ||
| 1642 | ? ({ outcome: { outcome: "cancelled" } } as const) | ||
| 1643 | : ({ outcome: { outcome: "selected", optionId } } as const); | ||
| 1644 | }).pipe( | ||
| 1645 | Effect.mapError( | ||
| 1646 | (cause) => | ||
| 1647 | new EffectAcpErrors.AcpTransportError({ | ||
| 1648 | detail: "Failed to handle an ACP permission request", | ||
| 1649 | cause, | ||
| 1650 | }), | ||
| 1651 | ), | ||
| 1652 | ), | ||
| 1653 | ); | ||
| 1654 | |||
| 1655 | const requestUserInputInternal = Effect.fnUntraced(function* ( | ||
| 1656 | request: AcpAdapterV2UserInputRequest, | 3222 | request: AcpAdapterV2UserInputRequest, |
| 3223 | generation: number, | ||
| 3224 | transportRequestId: string, | ||
| 1657 | ) { | 3225 | ) { |
| 1658 | const context = yield* activeContext; | 3226 | const context = yield* activeContext; |
| 1659 | yield* closeTextStreams(context); | 3227 | yield* closeTextStreams(context); |
| @@ -1663,6 +3231,15 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines | |||
| 1663 | nativeRequestId: request.nativeRequestId, | 3231 | nativeRequestId: request.nativeRequestId, |
| 1664 | }); | 3232 | }); |
| 1665 | const answers = yield* Deferred.make<ProviderUserInputAnswers | null>(); | 3233 | const answers = yield* Deferred.make<ProviderUserInputAnswers | null>(); |
| 3234 | const nativeResponseAcknowledgement = yield* Deferred.make< | ||
| 3235 | void, | ||
| 3236 | EffectAcpErrors.AcpError | ||
| 3237 | >(); | ||
| 3238 | yield* registerNativeResponseAcknowledgement( | ||
| 3239 | generation, | ||
| 3240 | transportRequestId, | ||
| 3241 | nativeResponseAcknowledgement, | ||
| 3242 | ); | ||
| 1666 | const now = yield* DateTime.now; | 3243 | const now = yield* DateTime.now; |
| 1667 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | 3244 | const nodeId = idAllocator.derive.nodeFromProviderItem({ |
| 1668 | driver, | 3245 | driver, |
| @@ -1736,7 +3313,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 67 unmodified lines | |||
| 1736 | const updated = new Map(current); | 3313 | const updated = new Map(current); |
| 1737 | updated.set(String(requestId), { | 3314 | updated.set(String(requestId), { |
| 1738 | type: "user_input", | 3315 | type: "user_input", |
| 3316 | generation, | ||
| 3317 | nativeResponseAcknowledgement, | ||
| 1739 | requestId, | 3318 | requestId, |
| 3319 | transportRequestId, | ||
| 1740 | answers, | 3320 | answers, |
| 1741 | runtimeRequest, | 3321 | runtimeRequest, |
| 1742 | node, | 3322 | node, |
| @@ -1760,19 +3340,62 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 17 unmodified lines | |||
| 1760 | driver, | 3340 | driver, |
| 1761 | turnItem, | 3341 | turnItem, |
| 1762 | }); | 3342 | }); |
| 1763 | return yield* Deferred.await(answers).pipe( | 3343 | return { |
| 1764 | Effect.ensuring( | 3344 | answers, |
| 1765 | Ref.update(pendingRuntimeRequests, (current) => { | 3345 | context, |
| 1766 | const updated = new Map(current); | 3346 | nativeResponseAcknowledgement, |
| 1767 | updated.delete(String(requestId)); | 3347 | requestId, |
| 1768 | return updated; | 3348 | transportRequestId, |
| 1769 | }), | 3349 | } as const; |
| 1770 | ), | ||
| 1771 | ); | ||
| 1772 | }); | 3350 | }); |
| 1773 | 3351 | ||
| 1774 | const requestUserInput = (request: AcpAdapterV2UserInputRequest) => | 3352 | const requestUserInputWithAdmission = ( |
| 1775 | requestUserInputInternal(request).pipe( | 3353 | generation: number, |
| 3354 | request: Effect.Effect<AcpAdapterV2UserInputRequest>, | ||
| 3355 | transportRequestId: string, | ||
| 3356 | ) => | ||
| 3357 | runRuntimeCallbackAtGeneration( | ||
| 3358 | generation, | ||
| 3359 | request.pipe( | ||
| 3360 | Effect.flatMap((value) => | ||
| 3361 | beginUserInputRequest(value, generation, transportRequestId), | ||
| 3362 | ), | ||
| 3363 | ), | ||
| 3364 | ).pipe( | ||
| 3365 | Effect.flatMap((pending) => { | ||
| 3366 | if (Option.isNone(pending)) return Effect.never; | ||
| 3367 | const { answers, context, requestId, transportRequestId } = pending.value; | ||
| 3368 | return Deferred.await(answers).pipe( | ||
| 3369 | Effect.flatMap((result) => | ||
| 3370 | runRuntimeCallbackAtGeneration(generation, Effect.succeed(result)).pipe( | ||
| 3371 | Effect.flatMap((checked) => | ||
| 3372 | Option.isSome(checked) | ||
| 3373 | ? Effect.succeed({ | ||
| 3374 | acknowledgeNativeResponse: acknowledgeNativeResponse( | ||
| 3375 | generation, | ||
| 3376 | transportRequestId, | ||
| 3377 | ), | ||
| 3378 | answers: checked.value, | ||
| 3379 | }) | ||
| 3380 | : Effect.never, | ||
| 3381 | ), | ||
| 3382 | ), | ||
| 3383 | ), | ||
| 3384 | Effect.ensuring( | ||
| 3385 | runRuntimeCallbackAtGeneration( | ||
| 3386 | generation, | ||
| 3387 | Effect.gen(function* () { | ||
| 3388 | yield* Ref.update(pendingRuntimeRequests, (current) => { | ||
| 3389 | const updated = new Map(current); | ||
| 3390 | updated.delete(String(requestId)); | ||
| 3391 | return updated; | ||
| 3392 | }); | ||
| 3393 | yield* rearmRootTurnRecoveryTimers(context); | ||
| 3394 | }), | ||
| 3395 | ).pipe(Effect.asVoid), | ||
| 3396 | ), | ||
| 3397 | ); | ||
| 3398 | }), | ||
| 1776 | Effect.mapError( | 3399 | Effect.mapError( |
| 1777 | (cause) => | 3400 | (cause) => |
| 1778 | new EffectAcpErrors.AcpTransportError({ | 3401 | new EffectAcpErrors.AcpTransportError({ |
| @@ -1783,7 +3406,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 4 unmodified lines | |||
| 1783 | ); | 3406 | ); |
| 1784 | 3407 | ||
| 1785 | const cancelPendingRuntimeRequests = Effect.fnUntraced(function* () { | 3408 | const cancelPendingRuntimeRequests = Effect.fnUntraced(function* () { |
| 1786 | const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()]; | 3409 | const requests = yield* Ref.modify(pendingRuntimeRequests, (current) => [ |
| 3410 | [...current.values()], | ||
| 3411 | new Map<string, PendingRuntimeRequest>(), | ||
| 3412 | ]); | ||
| 1787 | if (requests.length === 0) return; | 3413 | if (requests.length === 0) return; |
| 1788 | 3414 | ||
| 1789 | const now = yield* DateTime.now; | 3415 | const now = yield* DateTime.now; |
| @@ -1830,61 +3456,423 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 40 unmodified lines | |||
| 1830 | ); | 3456 | ); |
| 1831 | }); | 3457 | }); |
| 1832 | 3458 | ||
| 1833 | yield* runtime.handleElicitation((params) => | 3459 | /** |
| 1834 | Effect.gen(function* () { | 3460 | * Direct Stop after a soft steer clears carryover without an active turn. |
| 1835 | if (params.mode === "url") { | 3461 | * Emit the same interrupted terminal events terminalizeOpenRunOwnedItems |
| 1836 | return { action: { action: "decline" } } as const; | 3462 | * would have, context-free (no ActiveAcpTurn). |
| 3463 | */ | ||
| 3464 | const terminalizeCarryoverSubagents = Effect.fnUntraced(function* ( | ||
| 3465 | carryover: { | ||
| 3466 | readonly sessionId: string; | ||
| 3467 | readonly subagents: ReadonlyArray<ActiveAcpSubagent>; | ||
| 3468 | } | null, | ||
| 3469 | ) { | ||
| 3470 | if (carryover === null) return; | ||
| 3471 | const now = yield* DateTime.now; | ||
| 3472 | for (const subagent of carryover.subagents) { | ||
| 3473 | if (subagent.task.status !== "running" && subagent.task.status !== "pending") { | ||
| 3474 | continue; | ||
| 1837 | } | 3475 | } |
| 1838 | const questions = Object.entries(params.requestedSchema.properties ?? {}).map( | 3476 | const nativeTaskId = subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id; |
| 1839 | ([id, property], index): OrchestrationV2UserInputQuestion => { | 3477 | const nativeItemRef = { |
| 1840 | const record = unknownRecord(property); | 3478 | driver, |
| 1841 | const enumValues = Array.isArray(record?.enum) | 3479 | nativeId: nativeTaskId, |
| 1842 | ? record.enum.filter((value): value is string => typeof value === "string") | 3480 | strength: "strong" as const, |
| 1843 | : []; | 3481 | }; |
| 1844 | const options = | 3482 | const parentProviderThreadId = subagent.parentProviderThreadId; |
| 1845 | enumValues.length > 0 | 3483 | const result = subagent.assistantText || subagent.task.result; |
| 1846 | ? enumValues.map((value) => ({ label: value, description: value })) | 3484 | subagent.task = { |
| 1847 | : record?.type === "boolean" | 3485 | ...subagent.task, |
| 1848 | ? [ | 3486 | status: "interrupted", |
| 1849 | { label: "true", description: "Yes" }, | 3487 | result, |
| 1850 | { label: "false", description: "No" }, | 3488 | completedAt: now, |
| 1851 | ] | 3489 | updatedAt: now, |
| 1852 | : []; | 3490 | }; |
| 1853 | return { | 3491 | yield* emitProviderEvent({ |
| 1854 | id, | 3492 | type: "node.updated", |
| 1855 | header: nonEmptyText(record?.title, `Question ${index + 1}`), | 3493 | driver, |
| 1856 | question: nonEmptyText(record?.description, params.message), | 3494 | node: { |
| 1857 | options, | 3495 | id: subagent.task.id, |
| 1858 | }; | 3496 | threadId: subagent.task.threadId, |
| 3497 | runId: subagent.task.runId, | ||
| 3498 | parentNodeId: subagent.task.parentNodeId, | ||
| 3499 | rootNodeId: subagent.task.parentNodeId, | ||
| 3500 | kind: "subagent", | ||
| 3501 | status: "interrupted", | ||
| 3502 | countsForRun: false, | ||
| 3503 | providerThreadId: parentProviderThreadId, | ||
| 3504 | providerTurnId: subagent.providerTurnId, | ||
| 3505 | nativeItemRef, | ||
| 3506 | runtimeRequestId: null, | ||
| 3507 | checkpointScopeId: null, | ||
| 3508 | startedAt: subagent.task.startedAt, | ||
| 3509 | completedAt: now, | ||
| 3510 | }, | ||
| 3511 | }); | ||
| 3512 | yield* emitProviderEvent({ | ||
| 3513 | type: "node.updated", | ||
| 3514 | driver, | ||
| 3515 | node: { | ||
| 3516 | id: subagent.childRootNodeId, | ||
| 3517 | threadId: subagent.childThreadId, | ||
| 3518 | runId: null, | ||
| 3519 | parentNodeId: null, | ||
| 3520 | rootNodeId: subagent.childRootNodeId, | ||
| 3521 | kind: "root_turn", | ||
| 3522 | status: "interrupted", | ||
| 3523 | countsForRun: false, | ||
| 3524 | providerThreadId: subagent.task.providerThreadId, | ||
| 3525 | providerTurnId: null, | ||
| 3526 | nativeItemRef, | ||
| 3527 | runtimeRequestId: null, | ||
| 3528 | checkpointScopeId: null, | ||
| 3529 | startedAt: subagent.task.startedAt, | ||
| 3530 | completedAt: now, | ||
| 3531 | }, | ||
| 3532 | }); | ||
| 3533 | yield* emitProviderEvent({ | ||
| 3534 | type: "subagent.updated", | ||
| 3535 | driver, | ||
| 3536 | subagent: subagent.task, | ||
| 3537 | }); | ||
| 3538 | yield* emitProviderEvent({ | ||
| 3539 | type: "turn_item.updated", | ||
| 3540 | driver, | ||
| 3541 | turnItem: { | ||
| 3542 | id: subagent.turnItemId, | ||
| 3543 | threadId: subagent.task.threadId, | ||
| 3544 | runId: subagent.task.runId, | ||
| 3545 | nodeId: subagent.task.id, | ||
| 3546 | providerThreadId: parentProviderThreadId, | ||
| 3547 | providerTurnId: subagent.providerTurnId, | ||
| 3548 | nativeItemRef, | ||
| 3549 | parentItemId: null, | ||
| 3550 | ordinal: subagent.turnItemOrdinal, | ||
| 3551 | status: "interrupted", | ||
| 3552 | title: subagent.task.title, | ||
| 3553 | startedAt: subagent.task.startedAt, | ||
| 3554 | completedAt: now, | ||
| 3555 | updatedAt: now, | ||
| 3556 | type: "subagent", | ||
| 3557 | subagentId: subagent.task.id, | ||
| 3558 | origin: "provider_native", | ||
| 3559 | driver, | ||
| 3560 | providerInstanceId: subagent.task.providerInstanceId, | ||
| 3561 | childThreadId: subagent.childThreadId, | ||
| 3562 | prompt: subagent.task.prompt, | ||
| 3563 | result, | ||
| 1859 | }, | 3564 | }, |
| 1860 | ); | ||
| 1861 | const ordinal = yield* Ref.getAndUpdate( | ||
| 1862 | nextElicitationOrdinal, | ||
| 1863 | (current) => current + 1, | ||
| 1864 | ); | ||
| 1865 | const nativeRequestId = `${params.sessionId}:elicitation:${ordinal}`; | ||
| 1866 | const answers = yield* requestUserInput({ | ||
| 1867 | nativeItemId: nativeRequestId, | ||
| 1868 | nativeRequestId, | ||
| 1869 | questions, | ||
| 1870 | }); | 3565 | }); |
| 1871 | return answers === null | 3566 | } |
| 1872 | ? ({ action: { action: "cancel" } } as const) | 3567 | }); |
| 1873 | : ({ | 3568 | |
| 1874 | action: { | 3569 | const wireAcpRuntimeHandlers = Effect.fnUntraced(function* () { |
| 1875 | action: "accept", | 3570 | const handlerGeneration = yield* Ref.get(runtimeCallbackGeneration); |
| 1876 | content: elicitationContent( | 3571 | const requestUserInput = (request: AcpAdapterV2UserInputRequest) => |
| 1877 | answers, | 3572 | Effect.gen(function* () { |
| 1878 | new Set(Object.keys(params.requestedSchema.properties ?? {})), | 3573 | const transportRequestId = yield* claimNativeTransportRequest( |
| 3574 | handlerGeneration, | ||
| 3575 | (transport) => acpNativeUserInputRequestMatches(request, transport), | ||
| 3576 | ); | ||
| 3577 | const correlated = yield* runRuntimeCallbackAtGeneration( | ||
| 3578 | handlerGeneration, | ||
| 3579 | transportRequestId === undefined | ||
| 3580 | ? new EffectAcpErrors.AcpTransportError({ | ||
| 3581 | detail: | ||
| 3582 | "Could not correlate the ACP user input request with its transport ID", | ||
| 3583 | cause: "Could not correlate xAI user input transport request", | ||
| 3584 | }) | ||
| 3585 | : Effect.succeed(transportRequestId), | ||
| 3586 | ); | ||
| 3587 | if (Option.isNone(correlated)) return yield* Effect.never; | ||
| 3588 | return yield* requestUserInputWithAdmission( | ||
| 3589 | handlerGeneration, | ||
| 3590 | Effect.succeed(request), | ||
| 3591 | correlated.value, | ||
| 3592 | ); | ||
| 3593 | }); | ||
| 3594 | yield* runtime.handleSessionUpdate((notification) => | ||
| 3595 | runRuntimeCallbackAtGeneration( | ||
| 3596 | handlerGeneration, | ||
| 3597 | handleSessionUpdate(notification), | ||
| 3598 | ).pipe( | ||
| 3599 | Effect.asVoid, | ||
| 3600 | Effect.mapError( | ||
| 3601 | (cause) => | ||
| 3602 | new EffectAcpErrors.AcpTransportError({ | ||
| 3603 | detail: "Failed to project an ACP session update", | ||
| 3604 | cause, | ||
| 3605 | }), | ||
| 3606 | ), | ||
| 3607 | ), | ||
| 3608 | ); | ||
| 3609 | yield* runtime.handleRequestPermission((params) => | ||
| 3610 | Effect.gen(function* () { | ||
| 3611 | const transportRequestId = yield* claimNativeTransportRequest( | ||
| 3612 | handlerGeneration, | ||
| 3613 | ({ method, payload }) => | ||
| 3614 | method === "session/request_permission" && | ||
| 3615 | unknownRecord(payload)?.sessionId === params.sessionId && | ||
| 3616 | unknownRecord(unknownRecord(payload)?.toolCall)?.toolCallId === | ||
| 3617 | params.toolCall.toolCallId, | ||
| 3618 | ); | ||
| 3619 | const correlated = yield* runRuntimeCallbackAtGeneration( | ||
| 3620 | handlerGeneration, | ||
| 3621 | transportRequestId === undefined | ||
| 3622 | ? new EffectAcpErrors.AcpTransportError({ | ||
| 3623 | detail: | ||
| 3624 | "Could not correlate the ACP permission request with its transport ID", | ||
| 3625 | cause: "Could not correlate session/request_permission transport request", | ||
| 3626 | }) | ||
| 3627 | : Effect.succeed(transportRequestId), | ||
| 3628 | ); | ||
| 3629 | if (Option.isNone(correlated)) return yield* Effect.never; | ||
| 3630 | const correlatedTransportRequestId = correlated.value; | ||
| 3631 | const admitted = yield* runRuntimeCallbackAtGeneration( | ||
| 3632 | handlerGeneration, | ||
| 3633 | Effect.gen(function* () { | ||
| 3634 | const context = yield* activeContext; | ||
| 3635 | const disposition = acpPermissionDisposition(context.input.runtimePolicy, params); | ||
| 3636 | if (disposition === "allow") { | ||
| 3637 | const optionId = selectAutoApprovedPermissionOption(params); | ||
| 3638 | return { | ||
| 3639 | _tag: "Immediate" as const, | ||
| 3640 | response: | ||
| 3641 | optionId === undefined | ||
| 3642 | ? ({ outcome: { outcome: "cancelled" } } as const) | ||
| 3643 | : ({ outcome: { outcome: "selected", optionId } } as const), | ||
| 3644 | }; | ||
| 3645 | } | ||
| 3646 | if (disposition === "deny") { | ||
| 3647 | const optionId = selectPermissionOptionId(params, "decline"); | ||
| 3648 | return { | ||
| 3649 | _tag: "Immediate" as const, | ||
| 3650 | response: | ||
| 3651 | optionId === undefined | ||
| 3652 | ? ({ outcome: { outcome: "cancelled" } } as const) | ||
| 3653 | : ({ outcome: { outcome: "selected", optionId } } as const), | ||
| 3654 | }; | ||
| 3655 | } | ||
| 3656 | return { | ||
| 3657 | _tag: "Pending" as const, | ||
| 3658 | pending: yield* beginApprovalRequest( | ||
| 3659 | context, | ||
| 3660 | params, | ||
| 3661 | handlerGeneration, | ||
| 3662 | correlatedTransportRequestId, | ||
| 1879 | ), | 3663 | ), |
| 1880 | }, | 3664 | }; |
| 1881 | } as const); | 3665 | }), |
| 1882 | }), | 3666 | ); |
| 1883 | ); | 3667 | if (Option.isNone(admitted)) { |
| 3668 | return yield* Effect.never; | ||
| 3669 | } | ||
| 3670 | if (admitted.value._tag === "Immediate") { | ||
| 3671 | const response = admitted.value.response; | ||
| 3672 | const checked = yield* runRuntimeCallbackAtGeneration( | ||
| 3673 | handlerGeneration, | ||
| 3674 | Effect.gen(function* () { | ||
| 3675 | const nativeResponseAcknowledgement = yield* Deferred.make< | ||
| 3676 | void, | ||
| 3677 | EffectAcpErrors.AcpError | ||
| 3678 | >(); | ||
| 3679 | yield* registerNativeResponseAcknowledgement( | ||
| 3680 | handlerGeneration, | ||
| 3681 | correlatedTransportRequestId, | ||
| 3682 | nativeResponseAcknowledgement, | ||
| 3683 | ); | ||
| 3684 | return response; | ||
| 3685 | }), | ||
| 3686 | ); | ||
| 3687 | return Option.isSome(checked) ? checked.value : yield* Effect.never; | ||
| 3688 | } | ||
| 3689 | const { | ||
| 3690 | context, | ||
| 3691 | decision: pendingDecision, | ||
| 3692 | requestId, | ||
| 3693 | transportRequestId: pendingTransportRequestId, | ||
| 3694 | } = admitted.value.pending; | ||
| 3695 | const decision = yield* Deferred.await(pendingDecision).pipe( | ||
| 3696 | Effect.ensuring( | ||
| 3697 | runRuntimeCallbackAtGeneration( | ||
| 3698 | handlerGeneration, | ||
| 3699 | Effect.gen(function* () { | ||
| 3700 | yield* Ref.update(pendingRuntimeRequests, (current) => { | ||
| 3701 | const updated = new Map(current); | ||
| 3702 | updated.delete(String(requestId)); | ||
| 3703 | return updated; | ||
| 3704 | }); | ||
| 3705 | yield* rearmRootTurnRecoveryTimers(context); | ||
| 3706 | }), | ||
| 3707 | ).pipe(Effect.asVoid), | ||
| 3708 | ), | ||
| 3709 | ); | ||
| 3710 | const response = (() => { | ||
| 3711 | if (decision === "cancel") { | ||
| 3712 | return { outcome: { outcome: "cancelled" } } as const; | ||
| 3713 | } | ||
| 3714 | const optionId = selectPermissionOptionId(params, decision); | ||
| 3715 | return optionId === undefined | ||
| 3716 | ? ({ outcome: { outcome: "cancelled" } } as const) | ||
| 3717 | : ({ outcome: { outcome: "selected", optionId } } as const); | ||
| 3718 | })(); | ||
| 3719 | const checked = yield* runRuntimeCallbackAtGeneration( | ||
| 3720 | handlerGeneration, | ||
| 3721 | Effect.succeed(response), | ||
| 3722 | ); | ||
| 3723 | if (Option.isNone(checked)) return yield* Effect.never; | ||
| 3724 | yield* acknowledgeNativeResponse(handlerGeneration, pendingTransportRequestId); | ||
| 3725 | return checked.value; | ||
| 3726 | }).pipe( | ||
| 3727 | Effect.mapError( | ||
| 3728 | (cause) => | ||
| 3729 | new EffectAcpErrors.AcpTransportError({ | ||
| 3730 | detail: "Failed to handle an ACP permission request", | ||
| 3731 | cause, | ||
| 3732 | }), | ||
| 3733 | ), | ||
| 3734 | ), | ||
| 3735 | ); | ||
| 3736 | yield* runtime.handleElicitation((params) => | ||
| 3737 | Effect.gen(function* () { | ||
| 3738 | const transportRequestId = yield* claimNativeTransportRequest( | ||
| 3739 | handlerGeneration, | ||
| 3740 | ({ method, payload }) => { | ||
| 3741 | const record = unknownRecord(payload); | ||
| 3742 | return ( | ||
| 3743 | method === "session/elicitation" && | ||
| 3744 | record?.sessionId === params.sessionId && | ||
| 3745 | record.message === params.message && | ||
| 3746 | record.mode === params.mode && | ||
| 3747 | (params.mode === "url" | ||
| 3748 | ? record.elicitationId === params.elicitationId && record.url === params.url | ||
| 3749 | : acpCanonicalJson(record.requestedSchema) === | ||
| 3750 | acpCanonicalJson(params.requestedSchema)) | ||
| 3751 | ); | ||
| 3752 | }, | ||
| 3753 | ); | ||
| 3754 | const correlated = yield* runRuntimeCallbackAtGeneration( | ||
| 3755 | handlerGeneration, | ||
| 3756 | transportRequestId === undefined | ||
| 3757 | ? new EffectAcpErrors.AcpTransportError({ | ||
| 3758 | detail: | ||
| 3759 | "Could not correlate the ACP elicitation request with its transport ID", | ||
| 3760 | cause: "Could not correlate session/elicitation transport request", | ||
| 3761 | }) | ||
| 3762 | : Effect.succeed(transportRequestId), | ||
| 3763 | ); | ||
| 3764 | if (Option.isNone(correlated)) return yield* Effect.never; | ||
| 3765 | const correlatedTransportRequestId = correlated.value; | ||
| 3766 | if (params.mode === "url") { | ||
| 3767 | const admitted = yield* runRuntimeCallbackAtGeneration( | ||
| 3768 | handlerGeneration, | ||
| 3769 | Effect.gen(function* () { | ||
| 3770 | const nativeResponseAcknowledgement = yield* Deferred.make< | ||
| 3771 | void, | ||
| 3772 | EffectAcpErrors.AcpError | ||
| 3773 | >(); | ||
| 3774 | yield* registerNativeResponseAcknowledgement( | ||
| 3775 | handlerGeneration, | ||
| 3776 | correlatedTransportRequestId, | ||
| 3777 | nativeResponseAcknowledgement, | ||
| 3778 | ); | ||
| 3779 | return { action: { action: "decline" } } as const; | ||
| 3780 | }), | ||
| 3781 | ); | ||
| 3782 | if (Option.isNone(admitted)) return yield* Effect.never; | ||
| 3783 | return admitted.value; | ||
| 3784 | } | ||
| 3785 | const questions = Object.entries(params.requestedSchema.properties ?? {}).map( | ||
| 3786 | ([id, property], index): OrchestrationV2UserInputQuestion => { | ||
| 3787 | const record = unknownRecord(property); | ||
| 3788 | const enumValues = Array.isArray(record?.enum) | ||
| 3789 | ? record.enum.filter((value): value is string => typeof value === "string") | ||
| 3790 | : []; | ||
| 3791 | const options = | ||
| 3792 | enumValues.length > 0 | ||
| 3793 | ? enumValues.map((value) => ({ label: value, description: value })) | ||
| 3794 | : record?.type === "boolean" | ||
| 3795 | ? [ | ||
| 3796 | { label: "true", description: "Yes" }, | ||
| 3797 | { label: "false", description: "No" }, | ||
| 3798 | ] | ||
| 3799 | : []; | ||
| 3800 | return { | ||
| 3801 | id, | ||
| 3802 | header: nonEmptyText(record?.title, `Question ${index + 1}`), | ||
| 3803 | question: nonEmptyText(record?.description, params.message), | ||
| 3804 | options, | ||
| 3805 | }; | ||
| 3806 | }, | ||
| 3807 | ); | ||
| 3808 | const userInput = yield* requestUserInputWithAdmission( | ||
| 3809 | handlerGeneration, | ||
| 3810 | Effect.gen(function* () { | ||
| 3811 | const ordinal = yield* Ref.getAndUpdate( | ||
| 3812 | nextElicitationOrdinal, | ||
| 3813 | (current) => current + 1, | ||
| 3814 | ); | ||
| 3815 | const nativeRequestId = `${params.sessionId}:elicitation:${ordinal}`; | ||
| 3816 | return { | ||
| 3817 | nativeItemId: nativeRequestId, | ||
| 3818 | nativeRequestId, | ||
| 3819 | questions, | ||
| 3820 | }; | ||
| 3821 | }), | ||
| 3822 | correlatedTransportRequestId, | ||
| 3823 | ); | ||
| 3824 | const response = | ||
| 3825 | userInput.answers === null | ||
| 3826 | ? ({ action: { action: "cancel" } } as const) | ||
| 3827 | : ({ | ||
| 3828 | action: { | ||
| 3829 | action: "accept", | ||
| 3830 | content: elicitationContent( | ||
| 3831 | userInput.answers, | ||
| 3832 | new Set(Object.keys(params.requestedSchema.properties ?? {})), | ||
| 3833 | ), | ||
| 3834 | }, | ||
| 3835 | } as const); | ||
| 3836 | yield* userInput.acknowledgeNativeResponse; | ||
| 3837 | return response; | ||
| 3838 | }), | ||
| 3839 | ); | ||
| 3840 | if (flavor.registerExtensions !== undefined) { | ||
| 3841 | yield* flavor.registerExtensions({ | ||
| 3842 | runtime, | ||
| 3843 | requestUserInput, | ||
| 3844 | applyBackgroundTaskMutation: (mutation) => | ||
| 3845 | Effect.gen(function* () { | ||
| 3846 | // Direct Stop quarantine: drop residual task lifecycle from | ||
| 3847 | // the stopped run instead of mutating wake machinery. | ||
| 3848 | if (yield* Ref.get(stoppedRunQuarantine)) return; | ||
| 3849 | // Root-session tasks only: a cancelled subagent's re-run in | ||
| 3850 | // its child session must not gate root wake machinery. | ||
| 3851 | if ((yield* Ref.get(activeSessionId)) !== mutation.sessionId) return; | ||
| 3852 | yield* applyLateBackgroundMutation(mutation.sessionId, mutation); | ||
| 3853 | }), | ||
| 3854 | }); | ||
| 3855 | } | ||
| 3856 | }); | ||
| 3857 | |||
| 3858 | const spawnAcpRuntime = Effect.fnUntraced(function* () { | ||
| 3859 | if (runtimeScope !== undefined) { | ||
| 3860 | yield* Scope.close(runtimeScope, Exit.void); | ||
| 3861 | } | ||
| 3862 | runtimeScope = yield* Scope.make(); | ||
| 3863 | const runtimeGeneration = yield* Ref.get(runtimeCallbackGeneration); | ||
| 3864 | runtime = yield* flavor | ||
| 3865 | .makeRuntime(makeRuntimeInput(runtimeGeneration)) | ||
| 3866 | .pipe(Effect.provideService(Scope.Scope, runtimeScope)); | ||
| 3867 | }); | ||
| 3868 | |||
| 3869 | const restartAcpRuntime = Effect.fnUntraced(function* () { | ||
| 3870 | yield* spawnAcpRuntime(); | ||
| 3871 | yield* wireAcpRuntimeHandlers(); | ||
| 3872 | }); | ||
| 1884 | 3873 | ||
| 1885 | if (flavor.registerExtensions !== undefined) { | 3874 | yield* spawnAcpRuntime(); |
| 1886 | yield* flavor.registerExtensions({ runtime, requestUserInput }); | 3875 | yield* wireAcpRuntimeHandlers(); |
| 1887 | } | ||
| 1888 | 3876 | ||
| 1889 | const started = yield* runtime.start(); | 3877 | const started = yield* runtime.start(); |
| 1890 | yield* Ref.set(activeSessionId, started.sessionId); | 3878 | yield* Ref.set(activeSessionId, started.sessionId); |
| @@ -1893,12 +3881,15 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 2 unmodified lines | |||
| 1893 | const canLoadSession = started.initializeResult.agentCapabilities?.loadSession === true; | 3881 | const canLoadSession = started.initializeResult.agentCapabilities?.loadSession === true; |
| 1894 | const canResumeSession = | 3882 | const canResumeSession = |
| 1895 | started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null; | 3883 | started.initializeResult.agentCapabilities?.sessionCapabilities?.resume != null; |
| 1896 | const supportsImagePrompts = | 3884 | const supportsImagePrompts = acpSupportsImagePrompts({ |
| 1897 | started.initializeResult.agentCapabilities?.promptCapabilities?.image === true; | 3885 | flavorSupportsImagePrompts: flavor.supportsImagePrompts, |
| 3886 | negotiatedImage: | ||
| 3887 | started.initializeResult.agentCapabilities?.promptCapabilities?.image === true, | ||
| 3888 | }); | ||
| 1898 | 3889 | ||
| 1899 | const activateSession = Effect.fnUntraced(function* ( | 3890 | const activateSession = Effect.fnUntraced(function* ( |
| 1900 | sessionId: string, | 3891 | sessionId: string, |
| 1901 | threadId: ThreadId, | 3892 | threadId: ThreadId | null, |
| 1902 | ) { | 3893 | ) { |
| 1903 | const activationOptions = { mcpServers: acpMcpServers(threadId) }; | 3894 | const activationOptions = { mcpServers: acpMcpServers(threadId) }; |
| 1904 | if (canLoadSession) { | 3895 | if (canLoadSession) { |
| @@ -1996,16 +3987,90 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 91 unmodified lines | |||
| 1996 | completedAt, | 3987 | completedAt, |
| 1997 | }); | 3988 | }); |
| 1998 | 3989 | ||
| 3990 | const drainTrailingRootTurnChunks = Effect.fnUntraced(function* () { | ||
| 3991 | if (!flavor.settleRootTurnWhenIdle) return; | ||
| 3992 | // Projected via handleSessionUpdate, not getEvents(). Cooperative yield | ||
| 3993 | // only — replay uses TestClock; Effect.sleep here would stall settlement. | ||
| 3994 | yield* Effect.yieldNow; | ||
| 3995 | yield* Effect.yieldNow; | ||
| 3996 | }); | ||
| 3997 | |||
| 3998 | const terminalizeOpenRunOwnedItems = Effect.fnUntraced(function* ( | ||
| 3999 | context: ActiveAcpTurn, | ||
| 4000 | options: { readonly terminalizeSubagents: boolean }, | ||
| 4001 | ) { | ||
| 4002 | for (const tool of context.tools.values()) { | ||
| 4003 | const status = toolStatus(tool.status); | ||
| 4004 | if (status === "pending" || status === "running") { | ||
| 4005 | yield* emitTool(context, tool, "interrupted"); | ||
| 4006 | } | ||
| 4007 | } | ||
| 4008 | if (!options.terminalizeSubagents) return; | ||
| 4009 | for (const subagent of context.subagents.values()) { | ||
| 4010 | if (subagent.task.status !== "running" && subagent.task.status !== "pending") { | ||
| 4011 | continue; | ||
| 4012 | } | ||
| 4013 | yield* emitSubagent(context, { | ||
| 4014 | nativeTaskId: subagent.task.nativeTaskRef?.nativeId ?? subagent.task.id, | ||
| 4015 | prompt: subagent.task.prompt, | ||
| 4016 | title: subagent.task.title, | ||
| 4017 | model: subagent.task.model, | ||
| 4018 | status: "interrupted", | ||
| 4019 | childSessionId: subagent.childSessionId, | ||
| 4020 | result: subagent.task.result, | ||
| 4021 | suppressNormalTool: true, | ||
| 4022 | }); | ||
| 4023 | } | ||
| 4024 | }); | ||
| 4025 | |||
| 4026 | const terminalizeOpenForegroundTools = Effect.fnUntraced(function* ( | ||
| 4027 | context: ActiveAcpTurn, | ||
| 4028 | ) { | ||
| 4029 | for (const tool of context.tools.values()) { | ||
| 4030 | if (!acpCompletedTurnShouldTerminalizeTool(tool, flavor)) continue; | ||
| 4031 | yield* emitTool(context, tool, "completed"); | ||
| 4032 | } | ||
| 4033 | }); | ||
| 4034 | |||
| 4035 | const quarantineStoppedRun = Effect.fnUntraced(function* () { | ||
| 4036 | yield* continuationPermit.withPermit( | ||
| 4037 | Effect.gen(function* () { | ||
| 4038 | yield* Ref.update(continuationGeneration, (value) => value + 1); | ||
| 4039 | yield* Ref.set(stoppedRunQuarantine, true); | ||
| 4040 | yield* Ref.set(wakeBuffer, []); | ||
| 4041 | yield* Ref.set(continuationRequested, false); | ||
| 4042 | yield* Ref.set(runningBackgroundTaskIds, new Set()); | ||
| 4043 | yield* Ref.set(carryoverSubagents, null); | ||
| 4044 | yield* Ref.set(lastTurnRoute, null); | ||
| 4045 | }), | ||
| 4046 | ); | ||
| 4047 | }); | ||
| 4048 | |||
| 1999 | const finalizeTurn = Effect.fnUntraced(function* ( | 4049 | const finalizeTurn = Effect.fnUntraced(function* ( |
| 2000 | context: ActiveAcpTurn, | 4050 | context: ActiveAcpTurn, |
| 2001 | status: "completed" | "interrupted" | "failed" | "cancelled", | 4051 | status: "completed" | "interrupted" | "failed" | "cancelled", |
| 2002 | failure?: OrchestrationV2ProviderFailure, | 4052 | failure?: OrchestrationV2ProviderFailure, |
| 4053 | options?: { readonly drainTrailingChunks?: boolean }, | ||
| 2003 | ) { | 4054 | ) { |
| 2004 | if (context.finalized) return; | 4055 | if (context.finalized) return; |
| 4056 | const settledStatus = context.interrupted ? "interrupted" : status; | ||
| 2005 | context.finalized = true; | 4057 | context.finalized = true; |
| 4058 | if (options?.drainTrailingChunks === true) { | ||
| 4059 | yield* drainTrailingRootTurnChunks(); | ||
| 4060 | } | ||
| 4061 | const directStopQuarantine = yield* Ref.get(stoppedRunQuarantine); | ||
| 4062 | if (settledStatus === "completed") { | ||
| 4063 | yield* terminalizeOpenForegroundTools(context); | ||
| 4064 | } else if (settledStatus === "interrupted") { | ||
| 4065 | // Direct Stop terminalizes every visible run-owned item. restart_active | ||
| 4066 | // keeps live subagent lineages for in-process replacement carryover. | ||
| 4067 | yield* terminalizeOpenRunOwnedItems(context, { | ||
| 4068 | terminalizeSubagents: directStopQuarantine, | ||
| 4069 | }); | ||
| 4070 | } | ||
| 2006 | yield* closeTextStreams(context); | 4071 | yield* closeTextStreams(context); |
| 2007 | const now = yield* DateTime.now; | 4072 | const now = yield* DateTime.now; |
| 2008 | const turn = providerTurnPayload(context, status, now); | 4073 | const turn = providerTurnPayload(context, settledStatus, now); |
| 2009 | yield* Ref.update(providerTurns, (current) => { | 4074 | yield* Ref.update(providerTurns, (current) => { |
| 2010 | const updated = new Map(current); | 4075 | const updated = new Map(current); |
| 2011 | updated.set(String(turn.id), turn); | 4076 | updated.set(String(turn.id), turn); |
| @@ -2031,7 +4096,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 19 unmodified lines | |||
| 2031 | }, | 4096 | }, |
| 2032 | }); | 4097 | }); |
| 2033 | yield* emitProviderEvent( | 4098 | yield* emitProviderEvent( |
| 2034 | status === "failed" | 4099 | settledStatus === "failed" |
| 2035 | ? { | 4100 | ? { |
| 2036 | type: "turn.terminal", | 4101 | type: "turn.terminal", |
| 2037 | driver, | 4102 | driver, |
| @@ -2042,7 +4107,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 4 unmodified lines | |||
| 2042 | context, | 4107 | context, |
| 2043 | `terminal-failure:${context.providerTurnId}`, | 4108 | `terminal-failure:${context.providerTurnId}`, |
| 2044 | ), | 4109 | ), |
| 2045 | status, | 4110 | status: settledStatus, |
| 2046 | failure: failure ?? makeProviderFailure({ class: "provider_error" }), | 4111 | failure: failure ?? makeProviderFailure({ class: "provider_error" }), |
| 2047 | threadDisposition: "reusable", | 4112 | threadDisposition: "reusable", |
| 2048 | } | 4113 | } |
| @@ -2052,15 +4117,108 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines | |||
| 2052 | providerThreadId: context.input.providerThread.id, | 4117 | providerThreadId: context.input.providerThread.id, |
| 2053 | providerTurnId: context.providerTurnId, | 4118 | providerTurnId: context.providerTurnId, |
| 2054 | runOrdinal: context.input.runOrdinal, | 4119 | runOrdinal: context.input.runOrdinal, |
| 2055 | status, | 4120 | status: settledStatus, |
| 2056 | failure: null, | 4121 | failure: null, |
| 2057 | threadDisposition: "reusable", | 4122 | threadDisposition: "reusable", |
| 2058 | }, | 4123 | }, |
| 2059 | ); | 4124 | ); |
| 4125 | const liveSubagents = [...context.subagents.values()].filter( | ||
| 4126 | (subagent) => subagent.task.status === "running" || subagent.task.status === "pending", | ||
| 4127 | ); | ||
| 4128 | // Direct Stop must not carry residual subagents into a later run. | ||
| 4129 | if (liveSubagents.length > 0 && !directStopQuarantine) { | ||
| 4130 | const sessionId = yield* Ref.get(activeSessionId); | ||
| 4131 | if (sessionId !== null) { | ||
| 4132 | yield* Ref.set(carryoverSubagents, { sessionId, subagents: liveSubagents }); | ||
| 4133 | } | ||
| 4134 | } | ||
| 2060 | yield* Ref.set(activeTurn, null); | 4135 | yield* Ref.set(activeTurn, null); |
| 2061 | yield* Deferred.succeed(context.completed, undefined).pipe(Effect.ignore); | 4136 | yield* Deferred.succeed(context.completed, undefined).pipe(Effect.ignore); |
| 2062 | }); | 4137 | }); |
| 2063 | 4138 | ||
| 4139 | const trySettleRootTurnWhenIdle = Effect.fnUntraced(function* (context: ActiveAcpTurn) { | ||
| 4140 | const pending = yield* Ref.get(pendingRuntimeRequests); | ||
| 4141 | const hasPendingRuntimeRequest = acpTurnHasPendingRuntimeRequest( | ||
| 4142 | context.providerTurnId, | ||
| 4143 | pending, | ||
| 4144 | ); | ||
| 4145 | const hasRunningTool = [...context.tools.values()].some((tool) => { | ||
| 4146 | const status = toolStatus(tool.status); | ||
| 4147 | return status === "pending" || status === "running"; | ||
| 4148 | }); | ||
| 4149 | // Debounce already proved root-session quiescence; open segment handles | ||
| 4150 | // without an explicit close should not block settlement. | ||
| 4151 | const hasRunningSubagent = [...context.subagents.values()].some( | ||
| 4152 | (subagent) => subagent.task.status === "running", | ||
| 4153 | ); | ||
| 4154 | if ( | ||
| 4155 | !acpRootTurnIsIdle({ | ||
| 4156 | finalized: context.finalized, | ||
| 4157 | interrupted: context.interrupted, | ||
| 4158 | assistantStreamOpen: false, | ||
| 4159 | reasoningStreamOpen: false, | ||
| 4160 | hasRunningTool, | ||
| 4161 | hasPendingRuntimeRequest, | ||
| 4162 | hasToolHistory: context.tools.size > 0, | ||
| 4163 | hasRunningSubagent, | ||
| 4164 | hasOutput: context.assistant.nextSegment > 0, | ||
| 4165 | }) | ||
| 4166 | ) { | ||
| 4167 | return; | ||
| 4168 | } | ||
| 4169 | // Never session/cancel here. Speculative settle must not kill in-flight | ||
| 4170 | // Grok work; late tools would arrive with activeTurn null and drop. | ||
| 4171 | yield* finalizeTurn(context, "completed", undefined, { drainTrailingChunks: true }); | ||
| 4172 | }); | ||
| 4173 | |||
| 4174 | scheduleDeferredFinalize = (context) => | ||
| 4175 | Effect.gen(function* () { | ||
| 4176 | if (!flavor.deferFinalizeForBackgroundWork) return; | ||
| 4177 | if (!context.promptSettled || context.finalized || context.interrupted) return; | ||
| 4178 | if (hasDeferredBackgroundWork(context)) return; | ||
| 4179 | context.backgroundFinalizeGeneration += 1; | ||
| 4180 | const generation = context.backgroundFinalizeGeneration; | ||
| 4181 | // Minimal quiet for all models (no per-model carveouts). Defer + | ||
| 4182 | // awaitingBackgroundHydration hold the turn through monitors; this | ||
| 4183 | // is only a short debounce after the last rearm so a slightly late | ||
| 4184 | // first post-hydration assistant chunk is not dropped. Multi-second | ||
| 4185 | // floors (4–20s) only prolonged Working on grok-4.5. | ||
| 4186 | yield* Effect.gen(function* () { | ||
| 4187 | yield* Effect.sleep("2000 millis"); | ||
| 4188 | if ( | ||
| 4189 | context.finalized || | ||
| 4190 | context.interrupted || | ||
| 4191 | context.backgroundFinalizeGeneration !== generation | ||
| 4192 | ) { | ||
| 4193 | return; | ||
| 4194 | } | ||
| 4195 | if (hasDeferredBackgroundWork(context)) return; | ||
| 4196 | const status = context.promptSettledStatus ?? "completed"; | ||
| 4197 | yield* finalizeTurn(context, status, undefined, { drainTrailingChunks: true }); | ||
| 4198 | }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); | ||
| 4199 | }); | ||
| 4200 | |||
| 4201 | scheduleSettleRootTurnWhenIdle = (context) => | ||
| 4202 | Effect.gen(function* () { | ||
| 4203 | if (!flavor.settleRootTurnWhenIdle) return; | ||
| 4204 | context.settleScheduleGeneration += 1; | ||
| 4205 | const generation = context.settleScheduleGeneration; | ||
| 4206 | yield* Effect.gen(function* () { | ||
| 4207 | yield* Effect.sleep(`${acpRootTurnSettleDebounceMs} millis`); | ||
| 4208 | if (context.finalized || context.interrupted) return; | ||
| 4209 | if (context.settleScheduleGeneration !== generation) return; | ||
| 4210 | const active = yield* Ref.get(activeTurn); | ||
| 4211 | if (active !== context) return; | ||
| 4212 | yield* trySettleRootTurnWhenIdle(context); | ||
| 4213 | }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); | ||
| 4214 | }); | ||
| 4215 | |||
| 4216 | rearmRootTurnRecoveryTimers = (context) => | ||
| 4217 | Effect.gen(function* () { | ||
| 4218 | if (!acpRootTurnShouldRearmRecoveryTimers(context)) return; | ||
| 4219 | yield* scheduleSettleRootTurnWhenIdle(context); | ||
| 4220 | }); | ||
| 4221 | |||
| 2064 | const resolvePromptParts = Effect.fnUntraced(function* ( | 4222 | const resolvePromptParts = Effect.fnUntraced(function* ( |
| 2065 | turnInput: ProviderAdapterV2TurnInput, | 4223 | turnInput: ProviderAdapterV2TurnInput, |
| 2066 | ) { | 4224 | ) { |
| @@ -2110,8 +4268,26 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 43 unmodified lines | |||
| 2110 | return prompt; | 4268 | return prompt; |
| 2111 | }); | 4269 | }); |
| 2112 | 4270 | ||
| 2113 | const startTurn = Effect.fn("AcpAdapterV2.startTurn")( | 4271 | const restartRuntimeAfterTeardownIfRequired = Effect.fnUntraced(function* () { |
| 4272 | const restartRequired = yield* Ref.get(runtimeRestartRequired); | ||
| 4273 | if (!restartRequired) return false; | ||
| 4274 | yield* restartAcpRuntime(); | ||
| 4275 | yield* Ref.set(runtimeRestartRequired, false); | ||
| 4276 | yield* Ref.set(activeSessionId, null); | ||
| 4277 | yield* Ref.set(activeSessionSetup, null); | ||
| 4278 | yield* Ref.set(activeSelection, null); | ||
| 4279 | yield* Ref.set(snapshot, { | ||
| 4280 | order: [], | ||
| 4281 | messages: new Map(), | ||
| 4282 | loadingRole: null, | ||
| 4283 | loadingIndex: 0, | ||
| 4284 | }); | ||
| 4285 | return true; | ||
| 4286 | }); | ||
| 4287 | |||
| 4288 | const startTurnUnlocked = Effect.fn("AcpAdapterV2.startTurn")( | ||
| 2114 | function* (turnInput: ProviderAdapterV2TurnInput) { | 4289 | function* (turnInput: ProviderAdapterV2TurnInput) { |
| 4290 | yield* awaitRuntimeTeardown(); | ||
| 2115 | const existing = yield* Ref.get(activeTurn); | 4291 | const existing = yield* Ref.get(activeTurn); |
| 2116 | if (existing !== null) { | 4292 | if (existing !== null) { |
| 2117 | return yield* new ProviderAdapterProtocolError({ | 4293 | return yield* new ProviderAdapterProtocolError({ |
| @@ -2120,7 +4296,10 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 2 unmodified lines | |||
| 2120 | }); | 4296 | }); |
| 2121 | } | 4297 | } |
| 2122 | const requestedSessionId = nativeThreadId(driver, turnInput.providerThread); | 4298 | const requestedSessionId = nativeThreadId(driver, turnInput.providerThread); |
| 2123 | if ((yield* Ref.get(activeSessionId)) !== requestedSessionId) { | 4299 | const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired(); |
| 4300 | const needsSessionActivation = | ||
| 4301 | (yield* Ref.get(activeSessionId)) !== requestedSessionId || restartAfterInterrupt; | ||
| 4302 | if (needsSessionActivation) { | ||
| 2124 | const activated = yield* activateSession(requestedSessionId, turnInput.threadId); | 4303 | const activated = yield* activateSession(requestedSessionId, turnInput.threadId); |
| 2125 | yield* Ref.set(activeSessionId, activated.sessionId); | 4304 | yield* Ref.set(activeSessionId, activated.sessionId); |
| 2126 | yield* Ref.set(activeSessionSetup, activated); | 4305 | yield* Ref.set(activeSessionSetup, activated); |
| @@ -2147,11 +4326,32 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 20 unmodified lines | |||
| 2147 | yield* Ref.set(activeSelection, turnInput.modelSelection); | 4326 | yield* Ref.set(activeSelection, turnInput.modelSelection); |
| 2148 | } | 4327 | } |
| 2149 | } | 4328 | } |
| 2150 | const prompt = yield* resolvePromptParts(turnInput); | 4329 | yield* Ref.set(lastTurnRoute, { |
| 4330 | threadId: turnInput.threadId, | ||
| 4331 | providerThreadId: turnInput.providerThread.id, | ||
| 4332 | }); | ||
| 4333 | yield* Ref.set(suppressPostSettleMonitorPrompt, false); | ||
| 4334 | yield* Ref.set(handledBackgroundTaskIdsInActiveTurn, new Set()); | ||
| 4335 | // Continuation turns attach to wake traffic the agent already produced | ||
| 4336 | // after the prior root turn settled; do not re-prompt the ACP session. | ||
| 4337 | const isContinuationTurn = | ||
| 4338 | postSettleContinuationEnabled && | ||
| 4339 | turnInput.message.createdBy === "agent" && | ||
| 4340 | turnInput.message.creationSource === "provider"; | ||
| 4341 | // Drop a sticky continuation offer when any new turn starts so idle | ||
| 4342 | // pin and further offers cannot wed on a completed or failed dispatch. | ||
| 4343 | yield* continuationPermit.withPermit( | ||
| 4344 | Effect.gen(function* () { | ||
| 4345 | yield* Ref.update(continuationGeneration, (value) => value + 1); | ||
| 4346 | yield* Ref.set(continuationRequested, false); | ||
| 4347 | }), | ||
| 4348 | ); | ||
| 4349 | const prompt = isContinuationTurn ? null : yield* resolvePromptParts(turnInput); | ||
| 2151 | const startedAt = yield* DateTime.now; | 4350 | const startedAt = yield* DateTime.now; |
| 2152 | const nativeTurnId = `${requestedSessionId}:turn:${turnInput.providerTurnOrdinal}`; | 4351 | const nativeTurnId = `${requestedSessionId}:turn:${turnInput.providerTurnOrdinal}`; |
| 2153 | const providerTurnId = idAllocator.derive.providerTurn({ driver, nativeTurnId }); | 4352 | const providerTurnId = idAllocator.derive.providerTurn({ driver, nativeTurnId }); |
| 2154 | const completed = yield* Deferred.make<void, never>(); | 4353 | const completed = yield* Deferred.make<void, never>(); |
| 4354 | const promptWireSettled = yield* Deferred.make<void, never>(); | ||
| 2155 | const context: ActiveAcpTurn = { | 4355 | const context: ActiveAcpTurn = { |
| 2156 | input: turnInput, | 4356 | input: turnInput, |
| 2157 | providerTurnId, | 4357 | providerTurnId, |
| @@ -2165,11 +4365,35 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 7 unmodified lines | |||
| 2165 | subagents: new Map(), | 4365 | subagents: new Map(), |
| 2166 | subagentsBySessionId: new Map(), | 4366 | subagentsBySessionId: new Map(), |
| 2167 | pendingSubagentNotifications: new Map(), | 4367 | pendingSubagentNotifications: new Map(), |
| 4368 | toolCallIdsByBackgroundTaskId: new Map(), | ||
| 4369 | persistentBackgroundTaskIds: new Set(), | ||
| 4370 | awaitingBackgroundHydration: new Set(), | ||
| 4371 | pendingInjectedReport: new Set(), | ||
| 2168 | plan: null, | 4372 | plan: null, |
| 2169 | interrupted: false, | 4373 | interrupted: false, |
| 2170 | finalized: false, | 4374 | finalized: false, |
| 4375 | settleScheduleGeneration: 0, | ||
| 4376 | promptSettled: false, | ||
| 4377 | promptSettledStatus: null, | ||
| 4378 | promptWireSettled, | ||
| 4379 | backgroundFinalizeGeneration: 0, | ||
| 2171 | }; | 4380 | }; |
| 4381 | const carryover = yield* Ref.getAndSet(carryoverSubagents, null); | ||
| 4382 | if (carryover !== null && carryover.sessionId === requestedSessionId) { | ||
| 4383 | for (const subagent of carryover.subagents) { | ||
| 4384 | const nativeId = subagent.task.nativeTaskRef?.nativeId ?? null; | ||
| 4385 | if (nativeId !== null) { | ||
| 4386 | context.subagents.set(nativeId, subagent); | ||
| 4387 | } | ||
| 4388 | if (subagent.childSessionId !== null) { | ||
| 4389 | context.subagentsBySessionId.set(subagent.childSessionId, subagent); | ||
| 4390 | } | ||
| 4391 | } | ||
| 4392 | } | ||
| 2172 | yield* Ref.set(activeTurn, context); | 4393 | yield* Ref.set(activeTurn, context); |
| 4394 | // Direct Stop closes and recreates the old runtime before reaching | ||
| 4395 | // this reset. The quarantine remains session-scoped by design. | ||
| 4396 | yield* Ref.set(stoppedRunQuarantine, false); | ||
| 2173 | const runningTurn = providerTurnPayload(context, "running", null); | 4397 | const runningTurn = providerTurnPayload(context, "running", null); |
| 2174 | yield* Ref.update(providerTurns, (current) => { | 4398 | yield* Ref.update(providerTurns, (current) => { |
| 2175 | const updated = new Map(current); | 4399 | const updated = new Map(current); |
| @@ -2206,35 +4430,100 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 30 unmodified lines | |||
| 2206 | createdAt: startedAt, | 4430 | createdAt: startedAt, |
| 2207 | updatedAt: startedAt, | 4431 | updatedAt: startedAt, |
| 2208 | }); | 4432 | }); |
| 2209 | yield* runtime.prompt({ prompt }).pipe( | 4433 | if (isContinuationTurn) { |
| 2210 | Effect.flatMap((result) => { | 4434 | const drained = yield* Ref.modify(wakeBuffer, (current) => { |
| 2211 | const status = | 4435 | const next: Array<EffectAcpSchema.SessionNotification> = []; |
| 2212 | result.stopReason === "cancelled" | 4436 | return [current.slice(), next] as const; |
| 2213 | ? context.interrupted | 4437 | }); |
| 2214 | ? "interrupted" | 4438 | yield* Ref.set(continuationRequested, false); |
| 2215 | : "cancelled" | 4439 | // Treat attach mode as prompt-settled so deferred finalize / quiet |
| 2216 | : "completed"; | 4440 | // windows can complete the continuation after wake traffic drains. |
| 2217 | return finalizeTurn(context, status); | 4441 | context.promptSettled = true; |
| 2218 | }), | 4442 | context.promptSettledStatus = "completed"; |
| 4443 | if (drained.length === 0) { | ||
| 4444 | yield* finalizeTurn(context, "completed", undefined, { | ||
| 4445 | drainTrailingChunks: true, | ||
| 4446 | }); | ||
| 4447 | return; | ||
| 4448 | } | ||
| 4449 | for (const notification of drained) { | ||
| 4450 | yield* handleSessionUpdate(notification); | ||
| 4451 | } | ||
| 4452 | if (!context.finalized) { | ||
| 4453 | if (hasDeferredBackgroundWork(context)) { | ||
| 4454 | yield* rearmDeferredFinalize(context); | ||
| 4455 | } else { | ||
| 4456 | yield* scheduleDeferredFinalize(context); | ||
| 4457 | } | ||
| 4458 | } | ||
| 4459 | return; | ||
| 4460 | } | ||
| 4461 | const promptGeneration = yield* Ref.get(runtimeCallbackGeneration); | ||
| 4462 | yield* runtime.prompt({ prompt: prompt! }).pipe( | ||
| 4463 | // Wire settlement precedes the completion callback's permit request so | ||
| 4464 | // settled-soft classification can observe the native return even when | ||
| 4465 | // the completion fiber has not yet set promptSettled under the permit. | ||
| 4466 | Effect.tap(() => | ||
| 4467 | Deferred.succeed(context.promptWireSettled, undefined).pipe(Effect.asVoid), | ||
| 4468 | ), | ||
| 4469 | Effect.flatMap((result) => | ||
| 4470 | runRuntimeCallbackAtGeneration( | ||
| 4471 | promptGeneration, | ||
| 4472 | Effect.gen(function* () { | ||
| 4473 | if (context.finalized) return; | ||
| 4474 | const status = | ||
| 4475 | result.stopReason === "cancelled" | ||
| 4476 | ? context.interrupted | ||
| 4477 | ? "interrupted" | ||
| 4478 | : "cancelled" | ||
| 4479 | : "completed"; | ||
| 4480 | // Grok monitors (and async subagents) keep working after the root | ||
| 4481 | // prompt RPC returns. Defer finalize so their later updates and | ||
| 4482 | // wake-turn traffic still project onto this run. | ||
| 4483 | if ( | ||
| 4484 | flavor.deferFinalizeForBackgroundWork === true && | ||
| 4485 | !context.interrupted && | ||
| 4486 | hasDeferredBackgroundWork(context) | ||
| 4487 | ) { | ||
| 4488 | context.promptSettled = true; | ||
| 4489 | context.promptSettledStatus = status; | ||
| 4490 | return; | ||
| 4491 | } | ||
| 4492 | // Only completed turns drain trailing chunks. Interrupted turns | ||
| 4493 | // must not wait for residual output from a stopped prompt. | ||
| 4494 | yield* finalizeTurn(context, status, undefined, { | ||
| 4495 | drainTrailingChunks: status === "completed", | ||
| 4496 | }); | ||
| 4497 | }), | ||
| 4498 | ).pipe(Effect.asVoid), | ||
| 4499 | ), | ||
| 4500 | // Prompt failure is not wire-settled: only a successful resolve marks | ||
| 4501 | // the signal. catchCause must not complete promptWireSettled. | ||
| 2219 | Effect.catchCause((cause) => | 4502 | Effect.catchCause((cause) => |
| 2220 | finalizeTurn( | 4503 | runRuntimeCallbackAtGeneration( |
| 2221 | context, | 4504 | promptGeneration, |
| 2222 | context.interrupted ? "interrupted" : "failed", | 4505 | Effect.gen(function* () { |
| 2223 | makeProviderFailure({ | 4506 | if (context.finalized) return; |
| 2224 | cause: Cause.squash(cause), | 4507 | yield* finalizeTurn( |
| 2225 | class: "provider_error", | 4508 | context, |
| 4509 | context.interrupted ? "interrupted" : "failed", | ||
| 4510 | makeProviderFailure({ | ||
| 4511 | cause: Cause.squash(cause), | ||
| 4512 | class: "provider_error", | ||
| 4513 | }), | ||
| 4514 | ).pipe( | ||
| 4515 | Effect.andThen( | ||
| 4516 | Effect.logWarning("orchestration-v2.acp-prompt-failed", { | ||
| 4517 | driver, | ||
| 4518 | providerSessionId: input.providerSessionId, | ||
| 4519 | providerThreadId: turnInput.providerThread.id, | ||
| 4520 | providerTurnId, | ||
| 4521 | cause, | ||
| 4522 | }), | ||
| 4523 | ), | ||
| 4524 | ); | ||
| 2226 | }), | 4525 | }), |
| 2227 | ).pipe( | 4526 | ).pipe(Effect.asVoid), |
| 2228 | Effect.andThen( | ||
| 2229 | Effect.logWarning("orchestration-v2.acp-prompt-failed", { | ||
| 2230 | driver, | ||
| 2231 | providerSessionId: input.providerSessionId, | ||
| 2232 | providerThreadId: turnInput.providerThread.id, | ||
| 2233 | providerTurnId, | ||
| 2234 | cause, | ||
| 2235 | }), | ||
| 2236 | ), | ||
| 2237 | ), | ||
| 2238 | ), | 4527 | ), |
| 2239 | Effect.forkIn(sessionScope), | 4528 | Effect.forkIn(sessionScope), |
| 2240 | ); | 4529 | ); |
| @@ -2254,8 +4543,20 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 13 unmodified lines | |||
| 2254 | ), | 4543 | ), |
| 2255 | ); | 4544 | ); |
| 2256 | 4545 | ||
| 4546 | const startTurn = Effect.fn("AcpAdapterV2.startTurn.transition")(function* ( | ||
| 4547 | turnInput: ProviderAdapterV2TurnInput, | ||
| 4548 | ) { | ||
| 4549 | return yield* runtimeTransitionPermit.withPermit(startTurnUnlocked(turnInput)); | ||
| 4550 | }); | ||
| 4551 | |||
| 2257 | yield* Effect.addFinalizer(() => | 4552 | yield* Effect.addFinalizer(() => |
| 2258 | Effect.gen(function* () { | 4553 | Effect.gen(function* () { |
| 4554 | yield* continuationPermit.withPermit( | ||
| 4555 | Effect.gen(function* () { | ||
| 4556 | yield* Ref.set(continuationClosed, true); | ||
| 4557 | yield* Ref.update(continuationGeneration, (value) => value + 1); | ||
| 4558 | }), | ||
| 4559 | ); | ||
| 2259 | const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()]; | 4560 | const requests = [...(yield* Ref.get(pendingRuntimeRequests)).values()]; |
| 2260 | yield* Effect.forEach( | 4561 | yield* Effect.forEach( |
| 2261 | requests, | 4562 | requests, |
| @@ -2265,14 +4566,40 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines | |||
| 2265 | : Deferred.succeed(request.answers, null).pipe(Effect.ignore), | 4566 | : Deferred.succeed(request.answers, null).pipe(Effect.ignore), |
| 2266 | { discard: true }, | 4567 | { discard: true }, |
| 2267 | ); | 4568 | ); |
| 4569 | const closingError = new EffectAcpErrors.AcpTransportError({ | ||
| 4570 | detail: "The ACP session closed before its admitted response reached the transport", | ||
| 4571 | cause: "ACP session transport closed", | ||
| 4572 | }); | ||
| 4573 | yield* Effect.forEach( | ||
| 4574 | requests, | ||
| 4575 | (request) => Deferred.fail(request.nativeResponseAcknowledgement, closingError), | ||
| 4576 | { discard: true }, | ||
| 4577 | ); | ||
| 4578 | const transportHadOutstandingResponses = yield* closeNativeTransport; | ||
| 4579 | yield* options.testHooks?.afterNativeResponseTransportClosed?.() ?? Effect.void; | ||
| 2268 | const sessionCapabilities = | 4580 | const sessionCapabilities = |
| 2269 | started.initializeResult.agentCapabilities?.sessionCapabilities; | 4581 | started.initializeResult.agentCapabilities?.sessionCapabilities; |
| 2270 | if (sessionCapabilities?.close != null) { | 4582 | if (sessionCapabilities?.close != null) { |
| 2271 | yield* runtime.closeSession().pipe(Effect.ignore); | 4583 | yield* runtimeTransitionPermit.withPermitsIfAvailable(1)( |
| 4584 | Effect.gen(function* () { | ||
| 4585 | const teardownState = yield* Ref.get(runtimeTeardownState); | ||
| 4586 | const restartRequired = yield* Ref.get(runtimeRestartRequired); | ||
| 4587 | if ( | ||
| 4588 | teardownState._tag === "Idle" && | ||
| 4589 | !restartRequired && | ||
| 4590 | !transportHadOutstandingResponses | ||
| 4591 | ) { | ||
| 4592 | yield* runtime.closeSession().pipe(Effect.ignore); | ||
| 4593 | } | ||
| 4594 | }), | ||
| 4595 | ); | ||
| 2272 | } | 4596 | } |
| 2273 | if (flavor.assertComplete !== undefined) { | 4597 | if (flavor.assertComplete !== undefined) { |
| 2274 | yield* flavor.assertComplete.pipe(Effect.orDie); | 4598 | yield* flavor.assertComplete.pipe(Effect.orDie); |
| 2275 | } | 4599 | } |
| 4600 | if (runtimeScope !== undefined) { | ||
| 4601 | yield* Scope.close(runtimeScope, Exit.void).pipe(Effect.ignore); | ||
| 4602 | } | ||
| 2276 | }), | 4603 | }), |
| 2277 | ); | 4604 | ); |
| 2278 | 4605 | ||
| @@ -2282,6 +4609,16 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 3 unmodified lines | |||
| 2282 | providerSessionId: input.providerSessionId, | 4609 | providerSessionId: input.providerSessionId, |
| 2283 | providerSession, | 4610 | providerSession, |
| 2284 | events: Stream.fromEffectRepeat(Queue.take(events)), | 4611 | events: Stream.fromEffectRepeat(Queue.take(events)), |
| 4612 | ...(postSettleContinuationEnabled | ||
| 4613 | ? { | ||
| 4614 | hasPendingBackgroundWork: Effect.gen(function* () { | ||
| 4615 | if ((yield* Ref.get(wakeBuffer)).length > 0) return true; | ||
| 4616 | if (yield* Ref.get(continuationRequested)) return true; | ||
| 4617 | if ((yield* Ref.get(runningBackgroundTaskIds)).size > 0) return true; | ||
| 4618 | return false; | ||
| 4619 | }), | ||
| 4620 | } | ||
| 4621 | : {}), | ||
| 2285 | ensureThread: Effect.fn("AcpAdapterV2.ensureThread")( | 4622 | ensureThread: Effect.fn("AcpAdapterV2.ensureThread")( |
| 2286 | function* (threadInput: ProviderAdapterV2EnsureThreadInput) { | 4623 | function* (threadInput: ProviderAdapterV2EnsureThreadInput) { |
| 2287 | const now = yield* DateTime.now; | 4624 | const now = yield* DateTime.now; |
| @@ -2320,35 +4657,41 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 32 unmodified lines | |||
| 2320 | readonly modelSelection?: ModelSelection; | 4657 | readonly modelSelection?: ModelSelection; |
| 2321 | readonly runtimePolicy?: ProviderAdapterV2RuntimePolicy; | 4658 | readonly runtimePolicy?: ProviderAdapterV2RuntimePolicy; |
| 2322 | }) { | 4659 | }) { |
| 2323 | const sessionId = nativeThreadId(driver, threadInput.providerThread); | 4660 | return yield* runtimeTransitionPermit.withPermit( |
| 2324 | if ((yield* Ref.get(activeSessionId)) !== sessionId) { | 4661 | Effect.gen(function* () { |
| 2325 | yield* Ref.set(snapshot, { | 4662 | yield* awaitRuntimeTeardown(); |
| 2326 | order: [], | 4663 | const restartAfterInterrupt = yield* restartRuntimeAfterTeardownIfRequired(); |
| 2327 | messages: new Map(), | 4664 | const sessionId = nativeThreadId(driver, threadInput.providerThread); |
| 2328 | loadingRole: null, | 4665 | if ((yield* Ref.get(activeSessionId)) !== sessionId || restartAfterInterrupt) { |
| 2329 | loadingIndex: 0, | 4666 | yield* Ref.set(snapshot, { |
| 2330 | }); | 4667 | order: [], |
| 2331 | const activated = yield* activateSession( | 4668 | messages: new Map(), |
| 2332 | sessionId, | 4669 | loadingRole: null, |
| 2333 | threadInput.providerThread.appThreadId, | 4670 | loadingIndex: 0, |
| 2334 | ); | 4671 | }); |
| 2335 | yield* Ref.set(activeSessionId, activated.sessionId); | 4672 | const activated = yield* activateSession( |
| 2336 | yield* Ref.set(activeSessionSetup, activated); | 4673 | sessionId, |
| 2337 | const nextSelection = threadInput.modelSelection ?? input.modelSelection; | 4674 | threadInput.providerThread.appThreadId, |
| 2338 | yield* configureSession( | 4675 | ); |
| 2339 | activated, | 4676 | yield* Ref.set(activeSessionId, activated.sessionId); |
| 2340 | nextSelection, | 4677 | yield* Ref.set(activeSessionSetup, activated); |
| 2341 | threadInput.runtimePolicy ?? input.runtimePolicy, | 4678 | const nextSelection = threadInput.modelSelection ?? input.modelSelection; |
| 2342 | ); | 4679 | yield* configureSession( |
| 2343 | yield* Ref.set(activeSelection, nextSelection); | 4680 | activated, |
| 2344 | } | 4681 | nextSelection, |
| 2345 | const now = yield* DateTime.now; | 4682 | threadInput.runtimePolicy ?? input.runtimePolicy, |
| 2346 | return { | 4683 | ); |
| 2347 | ...threadInput.providerThread, | 4684 | yield* Ref.set(activeSelection, nextSelection); |
| 2348 | providerSessionId: input.providerSessionId, | 4685 | } |
| 2349 | status: "idle" as const, | 4686 | const now = yield* DateTime.now; |
| 2350 | updatedAt: now, | 4687 | return { |
| 2351 | }; | 4688 | ...threadInput.providerThread, |
| 4689 | providerSessionId: input.providerSessionId, | ||
| 4690 | status: "idle" as const, | ||
| 4691 | updatedAt: now, | ||
| 4692 | }; | ||
| 4693 | }), | ||
| 4694 | ); | ||
| 2352 | }, | 4695 | }, |
| 2353 | (effect, threadInput) => | 4696 | (effect, threadInput) => |
| 2354 | effect.pipe( | 4697 | effect.pipe( |
| @@ -2373,25 +4716,292 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 18 unmodified lines | |||
| 2373 | ), | 4716 | ), |
| 2374 | interruptTurn: Effect.fn("AcpAdapterV2.interruptTurn")( | 4717 | interruptTurn: Effect.fn("AcpAdapterV2.interruptTurn")( |
| 2375 | function* (turnInput: ProviderAdapterV2InterruptInput) { | 4718 | function* (turnInput: ProviderAdapterV2InterruptInput) { |
| 2376 | const context = yield* Ref.get(activeTurn); | 4719 | return yield* Effect.uninterruptibleMask((restore) => |
| 2377 | if (context?.providerTurnId !== turnInput.providerTurnId) { | 4720 | Effect.gen(function* () { |
| 2378 | return yield* new ProviderAdapterProtocolError({ | 4721 | yield* restore(runtimeTransitionPermit.take(1)); |
| 2379 | driver, | 4722 | return yield* Effect.gen(function* () { |
| 2380 | detail: `ACP provider turn ${turnInput.providerTurnId} is not active`, | 4723 | const transition = yield* Effect.gen(function* () { |
| 2381 | }); | 4724 | const interruptContext = yield* Ref.get(activeTurn); |
| 2382 | } | 4725 | // Settled steering restart: the native prompt already |
| 2383 | context.interrupted = true; | 4726 | // returned and this is not a user Stop, so keep the |
| 2384 | yield* runtime.cancel.pipe(Effect.ensuring(cancelPendingRuntimeRequests())); | 4727 | // process (and its background subagents) alive instead |
| 2385 | const stopped = yield* Deferred.await(context.completed).pipe( | 4728 | // of hard-killing it. The turn still terminalizes below. |
| 2386 | Effect.timeoutOption("10 seconds"), | 4729 | const softSteerCandidate = |
| 4730 | flavor.preserveRuntimeOnSettledInterrupt === true && | ||
| 4731 | turnInput.requestRuntimeRestart !== true && | ||
| 4732 | interruptContext?.providerTurnId === turnInput.providerTurnId; | ||
| 4733 | // Settlement is read under the callback permit after | ||
| 4734 | // draining admitted responses: the native prompt can have | ||
| 4735 | // returned on the wire while its completion callback has | ||
| 4736 | // not yet run, and a cancel sent in that window is exactly | ||
| 4737 | // what settled-soft mode exists to avoid. Also treat the | ||
| 4738 | // turn as settled when the wire signal is already done | ||
| 4739 | // (non-blocking; do not await under the permit). | ||
| 4740 | let settledSoftInterrupt = false; | ||
| 4741 | if (softSteerCandidate && interruptContext !== null) { | ||
| 4742 | const promptSettledUnderPermit = yield* runtimeCallbackPermit.withPermit( | ||
| 4743 | awaitAdmittedNativeResponses.pipe( | ||
| 4744 | Effect.andThen( | ||
| 4745 | Effect.sync(() => interruptContext.promptSettled === true), | ||
| 4746 | ), | ||
| 4747 | ), | ||
| 4748 | ); | ||
| 4749 | const wireSettled = yield* Deferred.isDone( | ||
| 4750 | interruptContext.promptWireSettled, | ||
| 4751 | ); | ||
| 4752 | settledSoftInterrupt = promptSettledUnderPermit || wireSettled; | ||
| 4753 | } | ||
| 4754 | const restartRuntime = | ||
| 4755 | !settledSoftInterrupt && | ||
| 4756 | (turnInput.requestRuntimeRestart === true || | ||
| 4757 | flavor.restartRuntimeOnEveryInterrupt === true); | ||
| 4758 | const hardRestart = | ||
| 4759 | restartRuntime && flavor.terminateRuntimeProcessGroupOnInterrupt === true; | ||
| 4760 | if (hardRestart) { | ||
| 4761 | const teardownState = yield* Ref.get(runtimeTeardownState); | ||
| 4762 | if (teardownState._tag === "Failed") { | ||
| 4763 | return yield* teardownState.error; | ||
| 4764 | } | ||
| 4765 | if (teardownState._tag === "InProgress") { | ||
| 4766 | // Concurrent Stop/restart: wait for the in-flight hard | ||
| 4767 | // teardown instead of failing the durable effect. | ||
| 4768 | yield* Effect.logInfo( | ||
| 4769 | "ACP interrupt awaiting in-progress hard teardown", | ||
| 4770 | { | ||
| 4771 | driver, | ||
| 4772 | providerTurnId: turnInput.providerTurnId, | ||
| 4773 | }, | ||
| 4774 | ); | ||
| 4775 | yield* Deferred.await(teardownState.completed); | ||
| 4776 | return undefined; | ||
| 4777 | } | ||
| 4778 | } | ||
| 4779 | const context = interruptContext; | ||
| 4780 | if (context?.providerTurnId !== turnInput.providerTurnId) { | ||
| 4781 | // A soft steering interrupt can clear the turn while | ||
| 4782 | // intentionally leaving the process alive. A queued user | ||
| 4783 | // Stop must still contain that orphan runtime. With a | ||
| 4784 | // different live turn active this stays a pure no-op | ||
| 4785 | // success instead: quarantine and teardown are | ||
| 4786 | // session-global and would maim the replacement turn. | ||
| 4787 | const containOrphanRuntime = | ||
| 4788 | context === null && | ||
| 4789 | hardRestart && | ||
| 4790 | turnInput.requestRuntimeRestart === true; | ||
| 4791 | // Transport death or a prior Stop already cleared the | ||
| 4792 | // turn. Failing here caused effect-worker retries while | ||
| 4793 | // the process was already gone; treat as success. | ||
| 4794 | yield* Effect.logWarning( | ||
| 4795 | containOrphanRuntime | ||
| 4796 | ? "ACP Stop raced a soft interrupt that cleared the turn; containing the orphan runtime" | ||
| 4797 | : "ACP interrupt raced transport teardown or a prior Stop; treating as already interrupted", | ||
| 4798 | { | ||
| 4799 | driver, | ||
| 4800 | requestedProviderTurnId: turnInput.providerTurnId, | ||
| 4801 | activeProviderTurnId: context?.providerTurnId ?? null, | ||
| 4802 | hardRestart, | ||
| 4803 | requestRuntimeRestart: turnInput.requestRuntimeRestart === true, | ||
| 4804 | teardownState: (yield* Ref.get(runtimeTeardownState))._tag, | ||
| 4805 | }, | ||
| 4806 | ); | ||
| 4807 | if (containOrphanRuntime) { | ||
| 4808 | const teardownBarrier = yield* Deferred.make< | ||
| 4809 | void, | ||
| 4810 | ProviderAdapterProtocolError | ||
| 4811 | >(); | ||
| 4812 | yield* runtimeCallbackPermit.withPermit( | ||
| 4813 | Effect.gen(function* () { | ||
| 4814 | yield* awaitAdmittedNativeResponses; | ||
| 4815 | const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration); | ||
| 4816 | yield* quarantineNativeTransportAtGeneration(stoppedGeneration); | ||
| 4817 | yield* Ref.set(runtimeTeardownState, { | ||
| 4818 | _tag: "InProgress", | ||
| 4819 | completed: teardownBarrier, | ||
| 4820 | }); | ||
| 4821 | yield* Ref.update( | ||
| 4822 | runtimeCallbackGeneration, | ||
| 4823 | (generation) => generation + 1, | ||
| 4824 | ); | ||
| 4825 | }), | ||
| 4826 | ); | ||
| 4827 | // Capture before quarantineStoppedRun clears carryover. | ||
| 4828 | const orphanCarryover = yield* Ref.getAndSet(carryoverSubagents, null); | ||
| 4829 | yield* quarantineStoppedRun(); | ||
| 4830 | // Match the main hard-restart path: cancel pending | ||
| 4831 | // approvals/elicitations after quarantine, before kill. | ||
| 4832 | yield* cancelPendingRuntimeRequests(); | ||
| 4833 | yield* terminalizeCarryoverSubagents(orphanCarryover); | ||
| 4834 | if (runtime.terminateProcessGroup === undefined) { | ||
| 4835 | const error = new ProviderAdapterProtocolError({ | ||
| 4836 | driver, | ||
| 4837 | detail: | ||
| 4838 | "ACP runtime does not expose its required process-group teardown; the session is poisoned", | ||
| 4839 | }); | ||
| 4840 | yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); | ||
| 4841 | yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore); | ||
| 4842 | return yield* error; | ||
| 4843 | } | ||
| 4844 | const teardownExit = yield* runtime.terminateProcessGroup.pipe( | ||
| 4845 | Effect.exit, | ||
| 4846 | ); | ||
| 4847 | if (Exit.isFailure(teardownExit)) { | ||
| 4848 | const error = new ProviderAdapterProtocolError({ | ||
| 4849 | driver, | ||
| 4850 | detail: | ||
| 4851 | "ACP orphan runtime process-group teardown failed; the session is poisoned", | ||
| 4852 | payload: Cause.squash(teardownExit.cause), | ||
| 4853 | }); | ||
| 4854 | yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); | ||
| 4855 | yield* Deferred.fail(teardownBarrier, error).pipe(Effect.ignore); | ||
| 4856 | return yield* error; | ||
| 4857 | } | ||
| 4858 | yield* Ref.set(runtimeRestartRequired, true); | ||
| 4859 | yield* Ref.set(runtimeTeardownState, { _tag: "Idle" }); | ||
| 4860 | yield* Deferred.succeed(teardownBarrier, undefined).pipe(Effect.ignore); | ||
| 4861 | } | ||
| 4862 | return undefined; | ||
| 4863 | } | ||
| 4864 | const teardownBarrier = hardRestart | ||
| 4865 | ? yield* Deferred.make<void, ProviderAdapterProtocolError>() | ||
| 4866 | : null; | ||
| 4867 | if (teardownBarrier !== null) { | ||
| 4868 | yield* runtimeCallbackPermit.withPermit( | ||
| 4869 | Effect.gen(function* () { | ||
| 4870 | yield* awaitAdmittedNativeResponses; | ||
| 4871 | const stoppedGeneration = yield* Ref.get(runtimeCallbackGeneration); | ||
| 4872 | yield* quarantineNativeTransportAtGeneration(stoppedGeneration); | ||
| 4873 | yield* Ref.set(runtimeTeardownState, { | ||
| 4874 | _tag: "InProgress", | ||
| 4875 | completed: teardownBarrier, | ||
| 4876 | }); | ||
| 4877 | yield* Ref.update( | ||
| 4878 | runtimeCallbackGeneration, | ||
| 4879 | (generation) => generation + 1, | ||
| 4880 | ); | ||
| 4881 | yield* ( | ||
| 4882 | options.testHooks?.afterHardTeardownTransportDrained?.() ?? | ||
| 4883 | Effect.void | ||
| 4884 | ); | ||
| 4885 | }), | ||
| 4886 | ); | ||
| 4887 | } | ||
| 4888 | return { | ||
| 4889 | context, | ||
| 4890 | restartRuntime, | ||
| 4891 | hardRestart, | ||
| 4892 | teardownBarrier, | ||
| 4893 | settledSoftInterrupt, | ||
| 4894 | }; | ||
| 4895 | }); | ||
| 4896 | // Concurrent hard teardown already completed above. | ||
| 4897 | if (transition === undefined) return; | ||
| 4898 | const { | ||
| 4899 | context, | ||
| 4900 | restartRuntime, | ||
| 4901 | hardRestart, | ||
| 4902 | teardownBarrier, | ||
| 4903 | settledSoftInterrupt, | ||
| 4904 | } = transition; | ||
| 4905 | const poisonTeardown = Effect.fnUntraced(function* ( | ||
| 4906 | detail: string, | ||
| 4907 | payload?: unknown, | ||
| 4908 | ) { | ||
| 4909 | const error = new ProviderAdapterProtocolError({ | ||
| 4910 | driver, | ||
| 4911 | detail, | ||
| 4912 | ...(payload === undefined ? {} : { payload }), | ||
| 4913 | }); | ||
| 4914 | yield* Ref.set(runtimeTeardownState, { _tag: "Failed", error }); | ||
| 4915 | yield* Deferred.fail(teardownBarrier!, error).pipe(Effect.ignore); | ||
| 4916 | return error; | ||
| 4917 | }); | ||
| 4918 | const runTransition = Effect.gen(function* () { | ||
| 4919 | context.interrupted = true; | ||
| 4920 | // Quarantine only when this run is discarded (user Stop / | ||
| 4921 | // hard process kill). Soft in-process restarts must keep | ||
| 4922 | // carryoverSubagents so a still-running subagent can | ||
| 4923 | // complete into the replacement turn. | ||
| 4924 | if (hardRestart || turnInput.requestRuntimeRestart === true) { | ||
| 4925 | yield* quarantineStoppedRun(); | ||
| 4926 | } | ||
| 4927 | yield* cancelPendingRuntimeRequests(); | ||
| 4928 | // Finalize before process-group kill so projection/UI cannot | ||
| 4929 | // lag a dead transport, and concurrent interrupt effects see | ||
| 4930 | // activeTurn cleared (idempotent success) rather than racing. | ||
| 4931 | if ( | ||
| 4932 | (hardRestart || context.promptSettled || settledSoftInterrupt) && | ||
| 4933 | !context.finalized | ||
| 4934 | ) { | ||
| 4935 | // hardRestart: always terminalize locally. | ||
| 4936 | // promptSettled / settledSoftInterrupt (incl. wire-settled): | ||
| 4937 | // native prompt already returned; only deferred background | ||
| 4938 | // work remains, so session/cancel has nothing to acknowledge. | ||
| 4939 | yield* finalizeTurn(context, "interrupted"); | ||
| 4940 | } | ||
| 4941 | if (hardRestart) { | ||
| 4942 | if (runtime.terminateProcessGroup === undefined) { | ||
| 4943 | return yield* poisonTeardown( | ||
| 4944 | "ACP runtime does not expose its required process-group teardown; the session is poisoned", | ||
| 4945 | ).pipe(Effect.flatMap(Effect.fail)); | ||
| 4946 | } | ||
| 4947 | const teardownExit = yield* runtime.terminateProcessGroup!.pipe( | ||
| 4948 | Effect.exit, | ||
| 4949 | ); | ||
| 4950 | if (Exit.isFailure(teardownExit)) { | ||
| 4951 | return yield* poisonTeardown( | ||
| 4952 | "ACP runtime process-group teardown failed; the session is poisoned", | ||
| 4953 | Cause.squash(teardownExit.cause), | ||
| 4954 | ).pipe(Effect.flatMap(Effect.fail)); | ||
| 4955 | } | ||
| 4956 | // Process group is gone; the next turn must spawn a | ||
| 4957 | // replacement even if the flavor only set hardRestart | ||
| 4958 | // without restartRuntimeAfterInterrupt. | ||
| 4959 | yield* Ref.set(runtimeRestartRequired, true); | ||
| 4960 | yield* Ref.set(runtimeTeardownState, { _tag: "Idle" }); | ||
| 4961 | yield* Deferred.succeed(teardownBarrier!, undefined).pipe(Effect.ignore); | ||
| 4962 | } else { | ||
| 4963 | // Settled soft interrupt: the native prompt already | ||
| 4964 | // returned, so session/cancel has nothing to | ||
| 4965 | // acknowledge and would only threaten still-running | ||
| 4966 | // background subagents. Skip it and leave the runtime | ||
| 4967 | // untouched for the replacement turn. | ||
| 4968 | if (!settledSoftInterrupt) { | ||
| 4969 | yield* runtime.cancel; | ||
| 4970 | } | ||
| 4971 | if (restartRuntime && flavor.restartRuntimeAfterInterrupt === true) { | ||
| 4972 | yield* Ref.set(runtimeRestartRequired, true); | ||
| 4973 | } | ||
| 4974 | } | ||
| 4975 | const stopped = yield* Deferred.await(context.completed).pipe( | ||
| 4976 | Effect.timeoutOption("10 seconds"), | ||
| 4977 | ); | ||
| 4978 | if (Option.isNone(stopped)) { | ||
| 4979 | return yield* new ProviderAdapterProtocolError({ | ||
| 4980 | driver, | ||
| 4981 | detail: `ACP provider turn ${turnInput.providerTurnId} did not acknowledge cancellation before the interrupt timeout`, | ||
| 4982 | }); | ||
| 4983 | } | ||
| 4984 | }); | ||
| 4985 | if (!hardRestart) { | ||
| 4986 | return yield* restore(runTransition); | ||
| 4987 | } | ||
| 4988 | return yield* runTransition.pipe( | ||
| 4989 | Effect.catchCause((cause) => | ||
| 4990 | Effect.gen(function* () { | ||
| 4991 | const state = yield* Ref.get(runtimeTeardownState); | ||
| 4992 | if (state._tag === "InProgress") { | ||
| 4993 | yield* poisonTeardown( | ||
| 4994 | "ACP hard teardown failed unexpectedly; the session is poisoned", | ||
| 4995 | Cause.squash(cause), | ||
| 4996 | ); | ||
| 4997 | } | ||
| 4998 | return yield* Effect.failCause(cause); | ||
| 4999 | }), | ||
| 5000 | ), | ||
| 5001 | ); | ||
| 5002 | }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1))); | ||
| 5003 | }), | ||
| 2387 | ); | 5004 | ); |
| 2388 | if (Option.isNone(stopped)) { | ||
| 2389 | yield* Ref.update(activeTurn, (current) => (current === context ? null : current)); | ||
| 2390 | return yield* new ProviderAdapterProtocolError({ | ||
| 2391 | driver, | ||
| 2392 | detail: `ACP provider turn ${turnInput.providerTurnId} did not acknowledge cancellation before the interrupt timeout`, | ||
| 2393 | }); | ||
| 2394 | } | ||
| 2395 | }, | 5005 | }, |
| 2396 | (effect, turnInput) => | 5006 | (effect, turnInput) => |
| 2397 | effect.pipe( | 5007 | effect.pipe( |
| @@ -2407,28 +5017,43 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 9 unmodified lines | |||
| 2407 | ), | 5017 | ), |
| 2408 | ), | 5018 | ), |
| 2409 | respondToRuntimeRequest: (requestInput) => | 5019 | respondToRuntimeRequest: (requestInput) => |
| 2410 | Effect.gen(function* () { | 5020 | Effect.uninterruptibleMask((restore) => |
| 2411 | const pending = (yield* Ref.get(pendingRuntimeRequests)).get( | 5021 | Effect.gen(function* () { |
| 2412 | String(requestInput.requestId), | 5022 | yield* restore(runtimeTransitionPermit.take(1)); |
| 2413 | ); | 5023 | return yield* Effect.gen(function* () { |
| 2414 | if (pending === undefined) { | 5024 | yield* awaitRuntimeTeardown(); |
| 2415 | return yield* new ProviderAdapterProtocolError({ | 5025 | const generation = yield* Ref.get(runtimeCallbackGeneration); |
| 2416 | driver, | 5026 | const pending = (yield* Ref.get(pendingRuntimeRequests)).get( |
| 2417 | detail: `No pending ACP runtime request ${requestInput.requestId}`, | 5027 | String(requestInput.requestId), |
| 2418 | }); | 5028 | ); |
| 2419 | } | 5029 | if (pending === undefined || pending.generation !== generation) { |
| 2420 | if (pending.type === "user_input") { | 5030 | return yield* new ProviderAdapterProtocolError({ |
| 2421 | yield* Deferred.succeed(pending.answers, requestInput.answers ?? null); | 5031 | driver, |
| 2422 | return; | 5032 | detail: `No pending ACP runtime request ${requestInput.requestId}`, |
| 2423 | } | 5033 | }); |
| 2424 | if (requestInput.decision === undefined) { | 5034 | } |
| 2425 | return yield* new ProviderAdapterProtocolError({ | 5035 | const settled = |
| 2426 | driver, | 5036 | pending.type === "user_input" |
| 2427 | detail: `ACP approval request ${requestInput.requestId} requires a decision`, | 5037 | ? yield* Deferred.succeed(pending.answers, requestInput.answers ?? null) |
| 2428 | }); | 5038 | : requestInput.decision === undefined |
| 2429 | } | 5039 | ? yield* new ProviderAdapterProtocolError({ |
| 2430 | yield* Deferred.succeed(pending.decision, requestInput.decision); | 5040 | driver, |
| 2431 | }).pipe( | 5041 | detail: `ACP approval request ${requestInput.requestId} requires a decision`, |
| 5042 | }) | ||
| 5043 | : yield* Deferred.succeed(pending.decision, requestInput.decision); | ||
| 5044 | if (!settled) { | ||
| 5045 | return yield* new ProviderAdapterProtocolError({ | ||
| 5046 | driver, | ||
| 5047 | detail: `ACP runtime request ${requestInput.requestId} was already resolved`, | ||
| 5048 | }); | ||
| 5049 | } | ||
| 5050 | yield* awaitNativeResponseAcknowledgements([ | ||
| 5051 | [pending.transportRequestId, pending.nativeResponseAcknowledgement], | ||
| 5052 | ]); | ||
| 5053 | yield* Deferred.await(pending.nativeResponseAcknowledgement); | ||
| 5054 | }).pipe(Effect.ensuring(runtimeTransitionPermit.release(1))); | ||
| 5055 | }), | ||
| 5056 | ).pipe( | ||
| 2432 | Effect.mapError( | 5057 | Effect.mapError( |
| 2433 | (cause) => | 5058 | (cause) => |
| 2434 | new ProviderAdapterRuntimeRequestResponseError({ | 5059 | new ProviderAdapterRuntimeRequestResponseError({ |
| @@ -2440,44 +5065,50 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 5 unmodified lines | |||
| 2440 | ), | 5065 | ), |
| 2441 | readThreadSnapshot: Effect.fn("AcpAdapterV2.readThreadSnapshot")( | 5066 | readThreadSnapshot: Effect.fn("AcpAdapterV2.readThreadSnapshot")( |
| 2442 | function* (snapshotInput) { | 5067 | function* (snapshotInput) { |
| 2443 | const sessionId = nativeThreadId(driver, snapshotInput.providerThread); | 5068 | return yield* runtimeTransitionPermit.withPermit( |
| 2444 | if ((yield* Ref.get(activeSessionId)) !== sessionId) { | 5069 | Effect.gen(function* () { |
| 2445 | if (!capabilities.threads.canReadThreadSnapshot) { | 5070 | yield* awaitRuntimeTeardown(); |
| 2446 | return yield* new ProviderAdapterProtocolError({ | 5071 | yield* restartRuntimeAfterTeardownIfRequired(); |
| 2447 | driver, | 5072 | const sessionId = nativeThreadId(driver, snapshotInput.providerThread); |
| 2448 | detail: "ACP driver does not support session/load snapshots", | 5073 | if ((yield* Ref.get(activeSessionId)) !== sessionId) { |
| 2449 | }); | 5074 | if (!capabilities.threads.canReadThreadSnapshot) { |
| 2450 | } | 5075 | return yield* new ProviderAdapterProtocolError({ |
| 2451 | yield* Ref.set(snapshot, { | 5076 | driver, |
| 2452 | order: [], | 5077 | detail: "ACP driver does not support session/load snapshots", |
| 2453 | messages: new Map(), | 5078 | }); |
| 2454 | loadingRole: null, | 5079 | } |
| 2455 | loadingIndex: 0, | 5080 | yield* Ref.set(snapshot, { |
| 2456 | }); | 5081 | order: [], |
| 2457 | const activated = yield* runtime.loadSession(sessionId, { | 5082 | messages: new Map(), |
| 2458 | mcpServers: acpMcpServers(snapshotInput.providerThread.appThreadId), | 5083 | loadingRole: null, |
| 2459 | }); | 5084 | loadingIndex: 0, |
| 2460 | yield* Ref.set(activeSessionId, activated.sessionId); | 5085 | }); |
| 2461 | yield* Ref.set(activeSessionSetup, activated); | 5086 | const activated = yield* runtime.loadSession(sessionId, { |
| 2462 | yield* Ref.set(activeSelection, null); | 5087 | mcpServers: acpMcpServers(snapshotInput.providerThread.appThreadId), |
| 2463 | } | 5088 | }); |
| 2464 | const state = yield* Ref.get(snapshot); | 5089 | yield* Ref.set(activeSessionId, activated.sessionId); |
| 2465 | const now = yield* DateTime.now; | 5090 | yield* Ref.set(activeSessionSetup, activated); |
| 2466 | return { | 5091 | yield* Ref.set(activeSelection, null); |
| 2467 | providerThread: { | 5092 | } |
| 2468 | ...snapshotInput.providerThread, | 5093 | const state = yield* Ref.get(snapshot); |
| 2469 | providerSessionId: input.providerSessionId, | 5094 | const now = yield* DateTime.now; |
| 2470 | status: "idle" as const, | 5095 | return { |
| 2471 | updatedAt: now, | 5096 | providerThread: { |
| 2472 | }, | 5097 | ...snapshotInput.providerThread, |
| 2473 | providerTurns: [...(yield* Ref.get(providerTurns)).values()], | 5098 | providerSessionId: input.providerSessionId, |
| 2474 | messages: state.order.flatMap((key) => { | 5099 | status: "idle" as const, |
| 2475 | const message = state.messages.get(key); | 5100 | updatedAt: now, |
| 2476 | return message === undefined ? [] : [message]; | 5101 | }, |
| 5102 | providerTurns: [...(yield* Ref.get(providerTurns)).values()], | ||
| 5103 | messages: state.order.flatMap((key) => { | ||
| 5104 | const message = state.messages.get(key); | ||
| 5105 | return message === undefined ? [] : [message]; | ||
| 5106 | }), | ||
| 5107 | runtimeRequests: [], | ||
| 5108 | providerPayload: { protocol: ACP_PROTOCOL, sessionId }, | ||
| 5109 | }; | ||
| 2477 | }), | 5110 | }), |
| 2478 | runtimeRequests: [], | 5111 | ); |
| 2479 | providerPayload: { protocol: ACP_PROTOCOL, sessionId }, | ||
| 2480 | }; | ||
| 2481 | }, | 5112 | }, |
| 2482 | (effect, snapshotInput) => | 5113 | (effect, snapshotInput) => |
| 2483 | effect.pipe( | 5114 | effect.pipe( |
| @@ -2502,44 +5133,50 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV · 18 unmodified lines | |||
| 2502 | ), | 5133 | ), |
| 2503 | forkThread: Effect.fn("AcpAdapterV2.forkThread")( | 5134 | forkThread: Effect.fn("AcpAdapterV2.forkThread")( |
| 2504 | function* (forkInput) { | 5135 | function* (forkInput) { |
| 2505 | if (!capabilities.threads.canForkThread) { | 5136 | return yield* runtimeTransitionPermit.withPermit( |
| 2506 | return yield* new ProviderAdapterProtocolError({ | 5137 | Effect.gen(function* () { |
| 2507 | driver, | 5138 | yield* awaitRuntimeTeardown(); |
| 2508 | detail: "ACP driver did not negotiate session/fork", | 5139 | yield* restartRuntimeAfterTeardownIfRequired(); |
| 2509 | }); | 5140 | if (!capabilities.threads.canForkThread) { |
| 2510 | } | 5141 | return yield* new ProviderAdapterProtocolError({ |
| 2511 | if (forkInput.providerTurnId !== undefined) { | 5142 | driver, |
| 2512 | return yield* new ProviderAdapterProtocolError({ | 5143 | detail: "ACP driver did not negotiate session/fork", |
| 2513 | driver, | 5144 | }); |
| 2514 | detail: "ACP session/fork can only fork the current session head", | 5145 | } |
| 2515 | }); | 5146 | if (forkInput.providerTurnId !== undefined) { |
| 2516 | } | 5147 | return yield* new ProviderAdapterProtocolError({ |
| 2517 | const sourceSessionId = nativeThreadId(driver, forkInput.sourceProviderThread); | 5148 | driver, |
| 2518 | const forked = yield* runtime.forkSession(sourceSessionId, { | 5149 | detail: "ACP session/fork can only fork the current session head", |
| 2519 | mcpServers: acpMcpServers(forkInput.targetThreadId), | 5150 | }); |
| 2520 | }); | 5151 | } |
| 2521 | yield* Ref.set(activeSessionId, forked.sessionId); | 5152 | const sourceSessionId = nativeThreadId(driver, forkInput.sourceProviderThread); |
| 2522 | yield* Ref.set(activeSessionSetup, forked); | 5153 | const forked = yield* runtime.forkSession(sourceSessionId, { |
| 2523 | yield* Ref.set(activeSelection, null); | 5154 | mcpServers: acpMcpServers(forkInput.targetThreadId), |
| 2524 | const now = yield* DateTime.now; | 5155 | }); |
| 2525 | return makeProviderThread({ | 5156 | yield* Ref.set(activeSessionId, forked.sessionId); |
| 2526 | driver, | 5157 | yield* Ref.set(activeSessionSetup, forked); |
| 2527 | providerInstanceId: options.instanceId, | 5158 | yield* Ref.set(activeSelection, null); |
| 2528 | idAllocator, | 5159 | const now = yield* DateTime.now; |
| 2529 | appThreadId: forkInput.targetThreadId, | 5160 | return makeProviderThread({ |
| 2530 | providerSessionId: input.providerSessionId, | 5161 | driver, |
| 2531 | nativeThreadId: forked.sessionId, | 5162 | providerInstanceId: options.instanceId, |
| 2532 | ...(forkInput.ownerNodeId === undefined | 5163 | idAllocator, |
| 2533 | ? {} | 5164 | appThreadId: forkInput.targetThreadId, |
| 2534 | : { ownerNodeId: forkInput.ownerNodeId }), | 5165 | providerSessionId: input.providerSessionId, |
| 2535 | forkedFrom: { | 5166 | nativeThreadId: forked.sessionId, |
| 2536 | providerThreadId: forkInput.sourceProviderThread.id, | 5167 | ...(forkInput.ownerNodeId === undefined |
| 2537 | ...(forkInput.providerTurnId === undefined | 5168 | ? {} |
| 2538 | ? {} | 5169 | : { ownerNodeId: forkInput.ownerNodeId }), |
| 2539 | : { providerTurnId: forkInput.providerTurnId }), | 5170 | forkedFrom: { |
| 2540 | }, | 5171 | providerThreadId: forkInput.sourceProviderThread.id, |
| 2541 | now, | 5172 | ...(forkInput.providerTurnId === undefined |
| 2542 | }); | 5173 | ? {} |
| 5174 | : { providerTurnId: forkInput.providerTurnId }), | ||
| 5175 | }, | ||
| 5176 | now, | ||
| 5177 | }); | ||
| 5178 | }), | ||
| 5179 | ); | ||
| 2543 | }, | 5180 | }, |
| 2544 | (effect, forkInput) => | 5181 | (effect, forkInput) => |
| 2545 | effect.pipe( | 5182 | effect.pipe( |
| @@ -146,6 +146,46 @@ describe("CodexAdapterV2 assistant message streaming", () => { · 145 unmodified lines | |||
| 146 | assert.equal((yield* Ref.get(updates)).length, 2); | 146 | assert.equal((yield* Ref.get(updates)).length, 2); |
| 147 | }), | 147 | }), |
| 148 | ); | 148 | ); |
| 149 | |||
| 150 | it.effect("retains buffered text until completion updates are emitted", () => | ||
| 151 | Effect.gen(function* () { | ||
| 152 | const updates = yield* Ref.make<ReadonlyArray<CodexAgentMessageDeltaUpdate>>([]); | ||
| 153 | const failNext = yield* Ref.make(true); | ||
| 154 | const coalescer = yield* makeCodexAgentMessageDeltaCoalescer({ | ||
| 155 | flushIntervalMs: 50, | ||
| 156 | emit: (update) => | ||
| 157 | Ref.getAndSet(failNext, false).pipe( | ||
| 158 | Effect.flatMap((shouldFail) => | ||
| 159 | shouldFail | ||
| 160 | ? Effect.die("projection unavailable") | ||
| 161 | : Ref.update(updates, (current) => [...current, update]), | ||
| 162 | ), | ||
| 163 | ), | ||
| 164 | }); | ||
| 165 | |||
| 166 | yield* coalescer.append({ turnId: "turn-1", itemId: "message-1", delta: "turn final" }); | ||
| 167 | const failedFlush = yield* coalescer.flushTurn("turn-1").pipe(Effect.exit); | ||
| 168 | assert.equal(failedFlush._tag, "Failure"); | ||
| 169 | yield* coalescer.flushTurn("turn-1"); | ||
| 170 | |||
| 171 | yield* coalescer.append({ turnId: "turn-1", itemId: "message-2", delta: "item final" }); | ||
| 172 | yield* Ref.set(failNext, true); | ||
| 173 | const failedComplete = yield* coalescer | ||
| 174 | .complete({ turnId: "turn-1", itemId: "message-2" }) | ||
| 175 | .pipe(Effect.exit); | ||
| 176 | assert.equal(failedComplete._tag, "Failure"); | ||
| 177 | const completedText = yield* coalescer.complete({ | ||
| 178 | turnId: "turn-1", | ||
| 179 | itemId: "message-2", | ||
| 180 | }); | ||
| 181 | |||
| 182 | assert.equal(completedText, "item final"); | ||
| 183 | assert.deepEqual(yield* Ref.get(updates), [ | ||
| 184 | { turnId: "turn-1", itemId: "message-1", text: "turn final", completed: true }, | ||
| 185 | { turnId: "turn-1", itemId: "message-2", text: "item final", completed: true }, | ||
| 186 | ]); | ||
| 187 | }), | ||
| 188 | ); | ||
| 149 | }); | 189 | }); |
| 150 | 190 | ||
| 151 | describe("CodexAdapterV2 runtime policy", () => { | 191 | describe("CodexAdapterV2 runtime policy", () => { |
| @@ -868,31 +868,37 @@ export const makeCodexAgentMessageDeltaCoalescer = Effect.fn( · 867 unmodified lines | |||
| 868 | }) => | 868 | }) => |
| 869 | flushLock.withPermit( | 869 | flushLock.withPermit( |
| 870 | Effect.gen(function* () { | 870 | Effect.gen(function* () { |
| 871 | const updates = yield* Ref.modify(buffered, (current) => { | 871 | const current = yield* Ref.get(buffered); |
| 872 | const updates: Array<CodexAgentMessageDeltaUpdate> = []; | ||
| 873 | for (const message of current.values()) { | ||
| 874 | if (!options.predicate(message) || (options.onlyDirty && !message.dirty)) { | ||
| 875 | continue; | ||
| 876 | } | ||
| 877 | updates.push({ | ||
| 878 | turnId: message.turnId, | ||
| 879 | itemId: message.itemId, | ||
| 880 | text: message.text, | ||
| 881 | completed: options.completed, | ||
| 882 | }); | ||
| 883 | } | ||
| 884 | const emitUpdates = Effect.forEach(updates, input.emit, { discard: true }); | ||
| 885 | yield* options.releaseSchedule === true | ||
| 886 | ? emitUpdates.pipe(Effect.ensuring(Ref.set(flushScheduled, false))) | ||
| 887 | : emitUpdates; | ||
| 888 | yield* Ref.update(buffered, (current) => { | ||
| 872 | const next = new Map(current); | 889 | const next = new Map(current); |
| 873 | const pending: Array<CodexAgentMessageDeltaUpdate> = []; | ||
| 874 | for (const [key, message] of current) { | 890 | for (const [key, message] of current) { |
| 875 | if (!options.predicate(message) || (options.onlyDirty && !message.dirty)) { | 891 | if (!options.predicate(message) || (options.onlyDirty && !message.dirty)) { |
| 876 | continue; | 892 | continue; |
| 877 | } | 893 | } |
| 878 | pending.push({ | ||
| 879 | turnId: message.turnId, | ||
| 880 | itemId: message.itemId, | ||
| 881 | text: message.text, | ||
| 882 | completed: options.completed, | ||
| 883 | }); | ||
| 884 | if (options.completed) { | 894 | if (options.completed) { |
| 885 | next.delete(key); | 895 | next.delete(key); |
| 886 | } else { | 896 | } else { |
| 887 | next.set(key, { ...message, dirty: false }); | 897 | next.set(key, { ...message, dirty: false }); |
| 888 | } | 898 | } |
| 889 | } | 899 | } |
| 890 | return [pending, next] as const; | 900 | return next; |
| 891 | }); | 901 | }); |
| 892 | if (options.releaseSchedule === true) { | ||
| 893 | yield* Ref.set(flushScheduled, false); | ||
| 894 | } | ||
| 895 | yield* Effect.forEach(updates, input.emit, { discard: true }); | ||
| 896 | }), | 902 | }), |
| 897 | ); | 903 | ); |
| 898 | 904 | ||
| @@ -936,14 +942,15 @@ export const makeCodexAgentMessageDeltaCoalescer = Effect.fn( · 37 unmodified lines | |||
| 936 | complete: ({ turnId, itemId, finalText }) => | 942 | complete: ({ turnId, itemId, finalText }) => |
| 937 | flushLock.withPermit( | 943 | flushLock.withPermit( |
| 938 | Effect.gen(function* () { | 944 | Effect.gen(function* () { |
| 939 | const text = yield* Ref.modify(buffered, (current) => { | 945 | const key = codexAgentMessageBufferKey(turnId, itemId); |
| 940 | const key = codexAgentMessageBufferKey(turnId, itemId); | 946 | const existing = (yield* Ref.get(buffered)).get(key); |
| 941 | const existing = current.get(key); | 947 | const text = finalText && finalText.length > 0 ? finalText : (existing?.text ?? ""); |
| 948 | yield* input.emit({ turnId, itemId, text, completed: true }); | ||
| 949 | yield* Ref.update(buffered, (current) => { | ||
| 942 | const next = new Map(current); | 950 | const next = new Map(current); |
| 943 | next.delete(key); | 951 | next.delete(key); |
| 944 | return [finalText && finalText.length > 0 ? finalText : (existing?.text ?? ""), next]; | 952 | return next; |
| 945 | }); | 953 | }); |
| 946 | yield* input.emit({ turnId, itemId, text, completed: true }); | ||
| 947 | return text; | 954 | return text; |
| 948 | }), | 955 | }), |
| 949 | ), | 956 | ), |
| @@ -1,9 +1,25 @@ | |||
| 1 | import { assert, describe, it } from "@effect/vitest"; | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 2 | import type * as EffectAcpSchema from "effect-acp/schema"; | 3 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 3 | 4 | ||
| 4 | import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; | 5 | import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; |
| 5 | import { AcpProviderCapabilitiesV2, acpPermissionDisposition } from "./AcpAdapterV2.ts"; | 6 | import { |
| 6 | import { GrokProviderCapabilitiesV2 } from "./GrokAdapterV2.ts"; | 7 | AcpProviderCapabilitiesV2, |
| 8 | acpCompletedTurnShouldTerminalizeTool, | ||
| 9 | acpPermissionDisposition, | ||
| 10 | acpRootSessionUpdateIngestsOutput, | ||
| 11 | acpRootTurnCompletionDrainMs, | ||
| 12 | acpRootTurnHasIngestedOutput, | ||
| 13 | acpRootTurnIsIdle, | ||
| 14 | acpRootTurnSettleDebounceMs, | ||
| 15 | acpRootTurnShouldRearmRecoveryTimers, | ||
| 16 | acpSupportsImagePrompts, | ||
| 17 | } from "./AcpAdapterV2.ts"; | ||
| 18 | import { | ||
| 19 | makeGrokAcpAdapterFlavor, | ||
| 20 | GrokProviderCapabilitiesV2, | ||
| 21 | type GrokAdapterV2Options, | ||
| 22 | } from "./GrokAdapterV2.ts"; | ||
| 7 | 23 | ||
| 8 | function permissionRequest( | 24 | function permissionRequest( |
| 9 | kind: EffectAcpSchema.ToolKind, | 25 | kind: EffectAcpSchema.ToolKind, |
| @@ -37,7 +53,221 @@ function runtimePolicy(input: { · 27 unmodified lines | |||
| 37 | }); | 53 | }); |
| 38 | } | 54 | } |
| 39 | 55 | ||
| 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 | describe("GrokAdapterV2 capabilities", () => { | 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 | it("keeps optional protocol features conservative until a flavor or handshake confirms them", () => { | 271 | it("keeps optional protocol features conservative until a flavor or handshake confirms them", () => { |
| 42 | assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsModelSwitchInSession); | 272 | assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsModelSwitchInSession); |
| 43 | assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsRuntimeModeSwitchInSession); | 273 | assert.isFalse(AcpProviderCapabilitiesV2.sessions.supportsRuntimeModeSwitchInSession); |
| @@ -45,6 +275,27 @@ describe("GrokAdapterV2 capabilities", () => { · 1 unmodified line | |||
| 45 | assert.isFalse(AcpProviderCapabilitiesV2.tools.supportsMcpTools); | 275 | assert.isFalse(AcpProviderCapabilitiesV2.tools.supportsMcpTools); |
| 46 | }); | 276 | }); |
| 47 | 277 | ||
| 278 | it("overrides ACP image capability false so screenshot attachments can prompt", () => { | ||
| 279 | // Handshake alone would refuse attachments (Grok advertises image:false). | ||
| 280 | assert.isFalse( | ||
| 281 | acpSupportsImagePrompts({ | ||
| 282 | negotiatedImage: false, | ||
| 283 | }), | ||
| 284 | ); | ||
| 285 | // Flavor override unblocks image content blocks for Grok. | ||
| 286 | assert.isTrue( | ||
| 287 | acpSupportsImagePrompts({ | ||
| 288 | flavorSupportsImagePrompts: true, | ||
| 289 | negotiatedImage: false, | ||
| 290 | }), | ||
| 291 | ); | ||
| 292 | assert.isTrue( | ||
| 293 | acpSupportsImagePrompts({ | ||
| 294 | negotiatedImage: true, | ||
| 295 | }), | ||
| 296 | ); | ||
| 297 | }); | ||
| 298 | |||
| 48 | it("declares Grok Task envelopes as native subagents", () => { | 299 | it("declares Grok Task envelopes as native subagents", () => { |
| 49 | assert.isFalse(GrokProviderCapabilitiesV2.threads.canForkThread); | 300 | assert.isFalse(GrokProviderCapabilitiesV2.threads.canForkThread); |
| 50 | assert.isTrue(GrokProviderCapabilitiesV2.subagents.supportsSubagents); | 301 | assert.isTrue(GrokProviderCapabilitiesV2.subagents.supportsSubagents); |
| @@ -21,17 +21,26 @@ import { · 20 unmodified lines | |||
| 21 | resolveGrokAcpBaseModelId, | 21 | resolveGrokAcpBaseModelId, |
| 22 | } from "../../provider/acp/GrokAcpSupport.ts"; | 22 | } from "../../provider/acp/GrokAcpSupport.ts"; |
| 23 | import { | 23 | import { |
| 24 | extractXAiAcpBackgroundToolMutation, | ||
| 25 | extractXAiAcpSubagentEndNotice, | ||
| 26 | extractXAiAcpSubagentUpdate, | ||
| 24 | extractXAiAskUserQuestionIdentity, | 27 | extractXAiAskUserQuestionIdentity, |
| 25 | extractXAiAskUserQuestions, | 28 | extractXAiAskUserQuestions, |
| 26 | extractXAiAcpSubagentUpdate, | 29 | extractXAiBackgroundTaskCompletion, |
| 30 | extractXAiKilledBackgroundTasks, | ||
| 31 | extractXAiMonitorTaskId, | ||
| 32 | isXAiPersistentMonitor, | ||
| 27 | makeXAiAskUserQuestionCancelledResponse, | 33 | makeXAiAskUserQuestionCancelledResponse, |
| 28 | makeXAiAskUserQuestionResponse, | 34 | makeXAiAskUserQuestionResponse, |
| 35 | normalizeXAiAcpToolCallState, | ||
| 36 | registerXAiBackgroundTaskTracking, | ||
| 29 | XAiAskUserQuestionRequest, | 37 | XAiAskUserQuestionRequest, |
| 30 | } from "../../provider/acp/XAiAcpExtension.ts"; | 38 | } from "../../provider/acp/XAiAcpExtension.ts"; |
| 31 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; | 39 | import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; |
| 32 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; | 40 | import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; |
| 33 | import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; | 41 | import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; |
| 34 | import { IdAllocatorV2 } from "../IdAllocator.ts"; | 42 | import { IdAllocatorV2 } from "../IdAllocator.ts"; |
| 43 | import { ProviderContinuationRequests } from "../ProviderContinuationRequests.ts"; | ||
| 35 | import { ProviderAdapterV2 } from "../ProviderAdapter.ts"; | 44 | import { ProviderAdapterV2 } from "../ProviderAdapter.ts"; |
| 36 | import { | 45 | import { |
| 37 | ProviderAdapterDriverCreateError, | 46 | ProviderAdapterDriverCreateError, |
| @@ -41,6 +50,7 @@ import { · 3 unmodified lines | |||
| 41 | import { | 50 | import { |
| 42 | AcpProviderCapabilitiesV2, | 51 | AcpProviderCapabilitiesV2, |
| 43 | makeAcpAdapterV2, | 52 | makeAcpAdapterV2, |
| 53 | type AcpAdapterV2ExtensionContext, | ||
| 44 | type AcpAdapterV2Flavor, | 54 | type AcpAdapterV2Flavor, |
| 45 | type AcpAdapterV2RuntimeInput, | 55 | type AcpAdapterV2RuntimeInput, |
| 46 | } from "./AcpAdapterV2.ts"; | 56 | } from "./AcpAdapterV2.ts"; |
| @@ -88,6 +98,7 @@ export interface GrokAdapterV2Options { · 41 unmodified lines | |||
| 88 | readonly idAllocator: IdAllocatorV2["Service"]; | 98 | readonly idAllocator: IdAllocatorV2["Service"]; |
| 89 | readonly serverConfig: ServerConfig["Service"]; | 99 | readonly serverConfig: ServerConfig["Service"]; |
| 90 | readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"]; | 100 | readonly nativeLogging?: Parameters<typeof makeAcpAdapterV2>[0]["nativeLogging"]; |
| 101 | readonly continuationRequests?: Parameters<typeof makeAcpAdapterV2>[0]["continuationRequests"]; | ||
| 91 | readonly makeRuntime?: ( | 102 | readonly makeRuntime?: ( |
| 92 | input: AcpAdapterV2RuntimeInput, | 103 | input: AcpAdapterV2RuntimeInput, |
| 93 | ) => Effect.Effect< | 104 | ) => Effect.Effect< |
| @@ -101,7 +112,16 @@ export interface GrokAdapterV2Options { · 7 unmodified lines | |||
| 101 | export const registerGrokAcpExtensions: NonNullable<AcpAdapterV2Flavor["registerExtensions"]> = ({ | 112 | export const registerGrokAcpExtensions: NonNullable<AcpAdapterV2Flavor["registerExtensions"]> = ({ |
| 102 | runtime, | 113 | runtime, |
| 103 | requestUserInput, | 114 | requestUserInput, |
| 115 | applyBackgroundTaskMutation, | ||
| 104 | }) => | 116 | }) => |
| 117 | registerXAiBackgroundTaskTracking(runtime, applyBackgroundTaskMutation).pipe( | ||
| 118 | Effect.andThen(registerGrokAskUserQuestionExtensions({ runtime, requestUserInput })), | ||
| 119 | ); | ||
| 120 | |||
| 121 | const registerGrokAskUserQuestionExtensions = ({ | ||
| 122 | runtime, | ||
| 123 | requestUserInput, | ||
| 124 | }: Pick<AcpAdapterV2ExtensionContext, "runtime" | "requestUserInput">) => | ||
| 105 | Effect.forEach( | 125 | Effect.forEach( |
| 106 | ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const, | 126 | ["x.ai/ask_user_question", "_x.ai/ask_user_question"] as const, |
| 107 | (method) => | 127 | (method) => |
| @@ -115,37 +135,78 @@ export const registerGrokAcpExtensions: NonNullable<AcpAdapterV2Flavor["register · 7 unmodified lines | |||
| 115 | })); | 135 | })); |
| 116 | return requestUserInput({ | 136 | return requestUserInput({ |
| 117 | nativeItemId: `${identity.sessionId}:xai-question:${identity.toolCallId}`, | 137 | nativeItemId: `${identity.sessionId}:xai-question:${identity.toolCallId}`, |
| 138 | nativeMethod: method, | ||
| 118 | nativeRequestId: identity.toolCallId, | 139 | nativeRequestId: identity.toolCallId, |
| 140 | nativeSessionId: identity.sessionId, | ||
| 119 | questions, | 141 | questions, |
| 120 | }).pipe( | 142 | }).pipe( |
| 121 | Effect.map((answers) => | 143 | Effect.flatMap(({ acknowledgeNativeResponse, answers }) => |
| 122 | answers === null | 144 | Effect.succeed( |
| 123 | ? makeXAiAskUserQuestionCancelledResponse() | 145 | answers === null |
| 124 | : makeXAiAskUserQuestionResponse(params, answers), | 146 | ? makeXAiAskUserQuestionCancelledResponse() |
| 147 | : makeXAiAskUserQuestionResponse(params, answers), | ||
| 148 | ).pipe(Effect.tap(() => acknowledgeNativeResponse)), | ||
| 125 | ), | 149 | ), |
| 126 | ); | 150 | ); |
| 127 | }), | 151 | }), |
| 128 | { discard: true }, | 152 | { discard: true }, |
| 129 | ); | 153 | ); |
| 130 | 154 | ||
| 131 | export function makeGrokAdapterV2(options: GrokAdapterV2Options) { | 155 | export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdapterV2Flavor { |
| 132 | const flavor: AcpAdapterV2Flavor = { | 156 | return { |
| 133 | driver: GROK_PROVIDER, | 157 | driver: GROK_PROVIDER, |
| 134 | capabilities: GrokProviderCapabilitiesV2, | 158 | capabilities: GrokProviderCapabilitiesV2, |
| 159 | // Idle settle over-settled preamble-before-tools turns and cancelled the | ||
| 160 | // prompt while Grok continued, freezing T3 projection mid-turn. | ||
| 161 | settleRootTurnWhenIdle: false, | ||
| 162 | interruptPromptOnCancel: false, | ||
| 163 | // User Stop (requestRuntimeRestart) still hard-kills the process group | ||
| 164 | // and respawns: Grok `session/cancel` is detach-and-continue (the CLI | ||
| 165 | // re-runs cancelled foreground work as a background task no client RPC | ||
| 166 | // can kill, E3 harness 2026-07-18), so only a process kill truly stops | ||
| 167 | // work. Non-Stop interrupts (mid-prompt steering, restart_active) omit | ||
| 168 | // requestRuntimeRestart and stay soft: the session survives in the same | ||
| 169 | // process, cancelled work backgrounds, and the model itself decides via | ||
| 170 | // kill_command_or_subagent whether it keeps running. | ||
| 171 | restartRuntimeAfterInterrupt: true, | ||
| 172 | terminateRuntimeProcessGroupOnInterrupt: true, | ||
| 173 | // Steering restarts on a settled turn additionally skip session/cancel so | ||
| 174 | // fire-and-forget subagents survive the steer (E1 harness confirmed the | ||
| 175 | // Grok CLI accepts a concurrent session/prompt in that state). | ||
| 176 | preserveRuntimeOnSettledInterrupt: true, | ||
| 177 | // Grok ACP initialize reports promptCapabilities.image:false but the agent | ||
| 178 | // still accepts image content blocks (verified with real screenshots). | ||
| 179 | supportsImagePrompts: true, | ||
| 135 | resolveModelId: (selection) => resolveGrokAcpBaseModelId(selection.model), | 180 | resolveModelId: (selection) => resolveGrokAcpBaseModelId(selection.model), |
| 136 | makeRuntime: | 181 | makeRuntime: |
| 137 | options.makeRuntime ?? | 182 | options.makeRuntime ?? |
| 138 | ((input) => | 183 | ((input) => |
| 139 | makeGrokAcpRuntime({ | 184 | makeGrokAcpRuntime({ |
| 140 | ...input, | 185 | ...input, |
| 186 | interruptPromptOnCancel: input.interruptPromptOnCancel ?? false, | ||
| 141 | grokSettings: options.settings, | 187 | grokSettings: options.settings, |
| 142 | environment: options.environment, | 188 | environment: options.environment, |
| 143 | childProcessSpawner: options.childProcessSpawner, | 189 | childProcessSpawner: options.childProcessSpawner, |
| 144 | })), | 190 | })), |
| 145 | registerExtensions: registerGrokAcpExtensions, | 191 | registerExtensions: registerGrokAcpExtensions, |
| 146 | extractSubagentUpdate: extractXAiAcpSubagentUpdate, | 192 | extractSubagentUpdate: extractXAiAcpSubagentUpdate, |
| 193 | extractSubagentEndNotice: extractXAiAcpSubagentEndNotice, | ||
| 194 | normalizeToolCall: normalizeXAiAcpToolCallState, | ||
| 195 | extractBackgroundTaskId: extractXAiMonitorTaskId, | ||
| 196 | extractBackgroundToolMutation: extractXAiAcpBackgroundToolMutation, | ||
| 197 | extractBackgroundTaskCompletion: (toolCall) => [ | ||
| 198 | ...extractXAiBackgroundTaskCompletion(toolCall), | ||
| 199 | ...extractXAiKilledBackgroundTasks(toolCall), | ||
| 200 | ], | ||
| 201 | isPersistentBackgroundTool: isXAiPersistentMonitor, | ||
| 202 | deferFinalizeForBackgroundWork: true, | ||
| 203 | enablePostSettleContinuation: true, | ||
| 147 | ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), | 204 | ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), |
| 148 | }; | 205 | }; |
| 206 | } | ||
| 207 | |||
| 208 | export function makeGrokAdapterV2(options: GrokAdapterV2Options) { | ||
| 209 | const flavor = makeGrokAcpAdapterFlavor(options); | ||
| 149 | return makeAcpAdapterV2({ | 210 | return makeAcpAdapterV2({ |
| 150 | instanceId: options.instanceId, | 211 | instanceId: options.instanceId, |
| 151 | flavor, | 212 | flavor, |
| @@ -153,6 +214,9 @@ export function makeGrokAdapterV2(options: GrokAdapterV2Options) { · 1 unmodified line | |||
| 153 | idAllocator: options.idAllocator, | 214 | idAllocator: options.idAllocator, |
| 154 | serverConfig: options.serverConfig, | 215 | serverConfig: options.serverConfig, |
| 155 | ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), | 216 | ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), |
| 217 | ...(options.continuationRequests === undefined | ||
| 218 | ? {} | ||
| 219 | : { continuationRequests: options.continuationRequests }), | ||
| 156 | }); | 220 | }); |
| 157 | } | 221 | } |
| 158 | 222 | ||
| @@ -176,6 +240,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapte · 17 unmodified lines | |||
| 176 | const idAllocator = yield* IdAllocatorV2; | 240 | const idAllocator = yield* IdAllocatorV2; |
| 177 | const providerEventLoggers = yield* ProviderEventLoggers; | 241 | const providerEventLoggers = yield* ProviderEventLoggers; |
| 178 | const serverConfig = yield* ServerConfig; | 242 | const serverConfig = yield* ServerConfig; |
| 243 | const continuationRequests = yield* ProviderContinuationRequests; | ||
| 179 | const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); | 244 | const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); |
| 180 | return makeGrokAdapterV2({ | 245 | return makeGrokAdapterV2({ |
| 181 | instanceId: input.instanceId, | 246 | instanceId: input.instanceId, |
| @@ -185,6 +250,7 @@ export const GrokAdapterV2Driver: ProviderAdapterDriver<GrokSettings, GrokAdapte · 3 unmodified lines | |||
| 185 | fileSystem, | 250 | fileSystem, |
| 186 | idAllocator, | 251 | idAllocator, |
| 187 | serverConfig, | 252 | serverConfig, |
| 253 | continuationRequests, | ||
| 188 | nativeLogging: (threadId) => | 254 | nativeLogging: (threadId) => |
| 189 | makeNativeLogger({ | 255 | makeNativeLogger({ |
| 190 | nativeEventLogger: providerEventLoggers.native, | 256 | nativeEventLogger: providerEventLoggers.native, |
| @@ -226,6 +292,7 @@ export const layer: Layer.Layer< · 35 unmodified lines | |||
| 226 | const idAllocator = yield* IdAllocatorV2; | 292 | const idAllocator = yield* IdAllocatorV2; |
| 227 | const providerEventLoggers = yield* ProviderEventLoggers; | 293 | const providerEventLoggers = yield* ProviderEventLoggers; |
| 228 | const serverConfig = yield* ServerConfig; | 294 | const serverConfig = yield* ServerConfig; |
| 295 | const continuationRequests = yield* ProviderContinuationRequests; | ||
| 229 | const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); | 296 | const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); |
| 230 | return makeGrokAdapterV2({ | 297 | return makeGrokAdapterV2({ |
| 231 | instanceId: GROK_DEFAULT_INSTANCE_ID, | 298 | instanceId: GROK_DEFAULT_INSTANCE_ID, |
| @@ -235,6 +302,7 @@ export const layer: Layer.Layer< · 3 unmodified lines | |||
| 235 | fileSystem, | 302 | fileSystem, |
| 236 | idAllocator, | 303 | idAllocator, |
| 237 | serverConfig, | 304 | serverConfig, |
| 305 | continuationRequests, | ||
| 238 | nativeLogging: (threadId) => | 306 | nativeLogging: (threadId) => |
| 239 | makeNativeLogger({ | 307 | makeNativeLogger({ |
| 240 | nativeEventLogger: providerEventLoggers.native, | 308 | nativeEventLogger: providerEventLoggers.native, |
| @@ -17,7 +17,11 @@ import * as Ref from "effect/Ref"; · 16 unmodified lines | |||
| 17 | 17 | ||
| 18 | import { CheckpointRollbackServiceV2 } from "./CheckpointRollbackService.ts"; | 18 | import { CheckpointRollbackServiceV2 } from "./CheckpointRollbackService.ts"; |
| 19 | import type { OrchestrationEffectV2 } from "./EffectOutbox.ts"; | 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 | import { RunFinalizationService } from "./RunFinalizationService.ts"; | 25 | import { RunFinalizationService } from "./RunFinalizationService.ts"; |
| 22 | import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; | 26 | import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; |
| 23 | import { ProviderTurnControlServiceV2 } from "./ProviderTurnControlService.ts"; | 27 | import { ProviderTurnControlServiceV2 } from "./ProviderTurnControlService.ts"; |
| @@ -133,6 +137,40 @@ function makeExecutorLayer(input: { · 109 unmodified lines | |||
| 133 | return executorLayer.pipe(Layer.provide(dependencies)); | 137 | return executorLayer.pipe(Layer.provide(dependencies)); |
| 134 | } | 138 | } |
| 135 | 139 | ||
| 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 | it.effect("detaches a handed-off session only after the old turn terminalizes", () => | 174 | it.effect("detaches a handed-off session only after the old turn terminalizes", () => |
| 137 | Effect.gen(function* () { | 175 | Effect.gen(function* () { |
| 138 | const now = yield* DateTime.now; | 176 | const now = yield* DateTime.now; |
| @@ -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 | export interface OrchestrationEffectExecutorV2Shape { | 52 | export interface OrchestrationEffectExecutorV2Shape { |
| 29 | readonly execute: ( | 53 | readonly execute: ( |
| 30 | effect: OrchestrationEffectV2, | 54 | effect: OrchestrationEffectV2, |
| @@ -348,14 +372,19 @@ export const layerWithOptions = ( · 317 unmodified lines | |||
| 348 | } | 372 | } |
| 349 | 373 | ||
| 350 | const error = Cause.pretty(exit.cause); | 374 | const error = Cause.pretty(exit.cause); |
| 375 | const nonRetryable = isNonRetryableProviderTurnControlFailure(effect.request.type, error); | ||
| 351 | yield* Effect.logWarning("Orchestration effect execution failed", { | 376 | yield* Effect.logWarning("Orchestration effect execution failed", { |
| 352 | effectId: effect.id, | 377 | effectId: effect.id, |
| 353 | effectType: effect.request.type, | 378 | effectType: effect.request.type, |
| 354 | attemptCount: effect.attemptCount, | 379 | attemptCount: effect.attemptCount, |
| 380 | nonRetryable, | ||
| 355 | error, | 381 | error, |
| 356 | }); | 382 | }); |
| 357 | const updated = | 383 | // Prefer succeed for terminal interrupt races so the outbox does not |
| 358 | effect.attemptCount >= maxAttempts | 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 | ? yield* outbox.fail({ effectId: effect.id, workerId, error }) | 388 | ? yield* outbox.fail({ effectId: effect.id, workerId, error }) |
| 360 | : yield* outbox.retry({ | 389 | : yield* outbox.retry({ |
| 361 | effectId: effect.id, | 390 | effectId: effect.id, |
| @@ -573,6 +573,7 @@ function activeLocalTurnItems( · 572 unmodified lines | |||
| 573 | item, | 573 | item, |
| 574 | runs: projection.runs, | 574 | runs: projection.runs, |
| 575 | attempts: projection.attempts, | 575 | attempts: projection.attempts, |
| 576 | items: projection.turnItems, | ||
| 576 | }), | 577 | }), |
| 577 | ) | 578 | ) |
| 578 | .map((item, position) => ({ | 579 | .map((item, position) => ({ |
| @@ -668,6 +669,7 @@ function visibleTurnItemsThroughRun(input: { · 89 unmodified lines | |||
| 668 | isOrchestrationV2SupersededInterrupt({ | 669 | isOrchestrationV2SupersededInterrupt({ |
| 669 | item, | 670 | item, |
| 670 | attempts: input.sourceProjection.attempts, | 671 | attempts: input.sourceProjection.attempts, |
| 672 | items: input.sourceProjection.turnItems, | ||
| 671 | }) | 673 | }) |
| 672 | ) { | 674 | ) { |
| 673 | return false; | 675 | return false; |
| @@ -407,6 +407,8 @@ export interface ProviderAdapterV2SteerInput { · 406 unmodified lines | |||
| 407 | export interface ProviderAdapterV2InterruptInput { | 407 | export interface ProviderAdapterV2InterruptInput { |
| 408 | readonly providerThread: OrchestrationV2ProviderThread; | 408 | readonly providerThread: OrchestrationV2ProviderThread; |
| 409 | readonly providerTurnId: ProviderTurnId; | 409 | readonly providerTurnId: ProviderTurnId; |
| 410 | /** When true, the next `startTurn` may respawn the provider runtime (Grok Stop recovery). */ | ||
| 411 | readonly requestRuntimeRestart?: boolean; | ||
| 410 | } | 412 | } |
| 411 | 413 | ||
| 412 | export interface ProviderAdapterV2RuntimeRequestResponseInput { | 414 | export interface ProviderAdapterV2RuntimeRequestResponseInput { |
| @@ -2,6 +2,7 @@ import { ProviderDriverKind, ProviderThreadId, ThreadId } from "@t3tools/contrac · 1 unmodified line | |||
| 2 | import * as Context from "effect/Context"; | 2 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | ||
| 5 | import * as Queue from "effect/Queue"; | 6 | import * as Queue from "effect/Queue"; |
| 6 | 7 | ||
| 7 | export interface ProviderContinuationRequest { | 8 | export interface ProviderContinuationRequest { |
| @@ -9,6 +10,9 @@ export interface ProviderContinuationRequest { · 1 unmodified line | |||
| 9 | readonly providerThreadId: ProviderThreadId; | 10 | readonly providerThreadId: ProviderThreadId; |
| 10 | readonly driver: ProviderDriverKind; | 11 | readonly driver: ProviderDriverKind; |
| 11 | readonly detail: string | null; | 12 | readonly detail: string | null; |
| 13 | readonly dispatchIfCurrent?: <A, E, R>( | ||
| 14 | effect: Effect.Effect<A, E, R>, | ||
| 15 | ) => Effect.Effect<Option.Option<A>, E, R>; | ||
| 12 | } | 16 | } |
| 13 | 17 | ||
| 14 | /** | 18 | /** |
| @@ -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 | threadId: request.threadId, | 32 | threadId: request.threadId, |
| 33 | providerThreadId: request.providerThreadId, | 33 | providerThreadId: request.providerThreadId, |
| 34 | }); | 34 | }); |
| 35 | // Still run the guard so adapters can clear sticky continuationRequested. | ||
| 36 | if (request.dispatchIfCurrent !== undefined) { | ||
| 37 | yield* request.dispatchIfCurrent(Effect.void); | ||
| 38 | } | ||
| 35 | return; | 39 | return; |
| 36 | } | 40 | } |
| 41 | // The ordinal is display metadata only: allocate.message appends a | ||
| 42 | // random UUID, so a stale projection read here cannot collide ids. | ||
| 37 | const messageId = yield* ids.allocate.message({ | 43 | const messageId = yield* ids.allocate.message({ |
| 38 | threadId: request.threadId, | 44 | threadId: request.threadId, |
| 39 | ordinal: projection.messages.length + 1, | 45 | ordinal: projection.messages.length + 1, |
| 40 | }); | 46 | }); |
| 41 | const commandId = CommandId.make(`provider-continuation:${messageId}`); | 47 | const commandId = CommandId.make(`provider-continuation:${messageId}`); |
| 42 | yield* threads.dispatch({ | 48 | const dispatch = threads.dispatch({ |
| 43 | type: "message.dispatch", | 49 | type: "message.dispatch", |
| 44 | commandId, | 50 | commandId, |
| 45 | threadId: request.threadId, | 51 | threadId: request.threadId, |
| @@ -50,6 +56,11 @@ export const workerLive = Layer.effectDiscard( · 4 unmodified lines | |||
| 50 | createdBy: "agent", | 56 | createdBy: "agent", |
| 51 | creationSource: "provider", | 57 | creationSource: "provider", |
| 52 | }); | 58 | }); |
| 59 | if (request.dispatchIfCurrent === undefined) { | ||
| 60 | yield* dispatch; | ||
| 61 | return; | ||
| 62 | } | ||
| 63 | yield* request.dispatchIfCurrent(dispatch); | ||
| 53 | }, | 64 | }, |
| 54 | ); | 65 | ); |
| 55 | 66 | ||
| @@ -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 | it.effect( | 1329 | it.effect( |
| 1237 | "ProviderSessionManagerV2 keeps active sessions alive until the provider turn terminates", | 1330 | "ProviderSessionManagerV2 keeps active sessions alive until the provider turn terminates", |
| 1238 | () => | 1331 | () => |
| @@ -605,31 +605,47 @@ export const layerWithOptions = ( · 604 unmodified lines | |||
| 605 | ) { | 605 | ) { |
| 606 | return; | 606 | return; |
| 607 | } | 607 | } |
| 608 | // Capture runtime identity before yielding: a replacement session | ||
| 609 | // can reuse the same providerSessionId while this fiber is parked. | ||
| 610 | const probedRuntime = entry.runtime; | ||
| 608 | const hasPendingWork = | 611 | const hasPendingWork = |
| 609 | entry.runtime.hasPendingBackgroundWork === undefined | 612 | probedRuntime.hasPendingBackgroundWork === undefined |
| 610 | ? false | 613 | ? false |
| 611 | : yield* entry.runtime.hasPendingBackgroundWork.pipe( | 614 | : yield* probedRuntime.hasPendingBackgroundWork.pipe( |
| 612 | Effect.catchCause(() => Effect.succeed(false)), | 615 | Effect.catchCause(() => Effect.succeed(false)), |
| 613 | ); | 616 | ); |
| 614 | if (hasPendingWork) { | 617 | if (hasPendingWork) { |
| 615 | const now = yield* Clock.currentTimeMillis; | 618 | const now = yield* Clock.currentTimeMillis; |
| 616 | const pinnedSinceMs = entry.pinnedSinceMs ?? now; | 619 | const pinnedSinceMs = entry.pinnedSinceMs ?? now; |
| 617 | if (now - pinnedSinceMs < maxIdlePinMs) { | 620 | if (now - pinnedSinceMs < maxIdlePinMs) { |
| 618 | yield* Effect.logInfo("orchestration-v2.driver-session.idle-release-deferred", { | 621 | const shouldContinuePin = yield* Ref.modify(sessions, (latest) => { |
| 619 | providerSessionId: input.providerSessionId, | ||
| 620 | pinnedForMs: now - pinnedSinceMs, | ||
| 621 | }); | ||
| 622 | yield* Ref.update(sessions, (latest) => { | ||
| 623 | const latestEntry = latest.get(key); | 622 | const latestEntry = latest.get(key); |
| 624 | if (latestEntry === undefined || latestEntry.busyCount > 0) { | 623 | if ( |
| 625 | return latest; | 624 | latestEntry === undefined || |
| 625 | latestEntry.busyCount > 0 || | ||
| 626 | latestEntry.idleGeneration !== input.generation || | ||
| 627 | latestEntry.runtime !== probedRuntime | ||
| 628 | ) { | ||
| 629 | return [false, latest] as const; | ||
| 626 | } | 630 | } |
| 627 | const updated = new Map(latest); | 631 | const updated = new Map(latest); |
| 628 | updated.set(key, { ...latestEntry, pinnedSinceMs }); | 632 | updated.set(key, { ...latestEntry, pinnedSinceMs }); |
| 629 | return updated; | 633 | return [true, updated] as const; |
| 630 | }); | 634 | }); |
| 631 | yield* scheduleIdleReleaseInternal(input.providerSessionId); | 635 | if (!shouldContinuePin) { |
| 632 | return; | 636 | // Generation or runtime advanced while we probed pending work; |
| 637 | // the current owner of the entry owns idle release. | ||
| 638 | return; | ||
| 639 | } | ||
| 640 | yield* Effect.logInfo("orchestration-v2.driver-session.idle-release-deferred", { | ||
| 641 | providerSessionId: input.providerSessionId, | ||
| 642 | pinnedForMs: now - pinnedSinceMs, | ||
| 643 | }); | ||
| 644 | // Re-check on this fiber after another idle window. Do not call | ||
| 645 | // scheduleIdleReleaseInternal: that cancels entry.idleFiber, which | ||
| 646 | // is this fiber, and can self-deadlock on Fiber.interrupt. | ||
| 647 | yield* Effect.sleep(Duration.millis(idleTimeoutMs)); | ||
| 648 | return yield* releaseIfStillIdle(input); | ||
| 633 | } | 649 | } |
| 634 | yield* Effect.logWarning("orchestration-v2.driver-session.idle-release-pin-expired", { | 650 | yield* Effect.logWarning("orchestration-v2.driver-session.idle-release-pin-expired", { |
| 635 | providerSessionId: input.providerSessionId, | 651 | providerSessionId: input.providerSessionId, |
| @@ -129,6 +129,28 @@ export const layer: Layer.Layer< · 128 unmodified lines | |||
| 129 | } | 129 | } |
| 130 | const session = yield* sessions.get(input.providerSessionId); | 130 | const session = yield* sessions.get(input.providerSessionId); |
| 131 | if (Option.isNone(session)) { | 131 | if (Option.isNone(session)) { |
| 132 | // Interrupt/restart against a already-released session must not fail | ||
| 133 | // the durable effect (and retry 5x). The turn may still look running | ||
| 134 | // in projection until recovery/finalization; there is no live adapter | ||
| 135 | // to interrupt. | ||
| 136 | if (input.operation === "interrupt" || input.operation === "restart") { | ||
| 137 | yield* Effect.logWarning( | ||
| 138 | "Provider interrupt/restart found no live session; treating as already stopped", | ||
| 139 | { | ||
| 140 | threadId: input.threadId, | ||
| 141 | operation: input.operation, | ||
| 142 | providerSessionId: input.providerSessionId, | ||
| 143 | providerTurnId: input.providerTurnId, | ||
| 144 | providerTurnStatus: providerTurn.status, | ||
| 145 | }, | ||
| 146 | ); | ||
| 147 | return { | ||
| 148 | projection, | ||
| 149 | providerThread: interruptProviderThread, | ||
| 150 | providerTurn, | ||
| 151 | session: Option.none(), | ||
| 152 | }; | ||
| 153 | } | ||
| 132 | return yield* new ProviderTurnControlError({ | 154 | return yield* new ProviderTurnControlError({ |
| 133 | threadId: input.threadId, | 155 | threadId: input.threadId, |
| 134 | operation: input.operation, | 156 | operation: input.operation, |
| @@ -147,6 +169,7 @@ export const layer: Layer.Layer< · 12 unmodified lines | |||
| 147 | yield* loaded.session.value.interruptTurn({ | 169 | yield* loaded.session.value.interruptTurn({ |
| 148 | providerThread: loaded.providerThread, | 170 | providerThread: loaded.providerThread, |
| 149 | providerTurnId: loaded.providerTurn.id, | 171 | providerTurnId: loaded.providerTurn.id, |
| 172 | requestRuntimeRestart: true, | ||
| 150 | }); | 173 | }); |
| 151 | }).pipe( | 174 | }).pipe( |
| 152 | Effect.mapError((cause) => | 175 | Effect.mapError((cause) => |
| @@ -163,13 +186,28 @@ export const layer: Layer.Layer< · 10 unmodified lines | |||
| 163 | interruptAndAwaitTerminal: (input) => | 186 | interruptAndAwaitTerminal: (input) => |
| 164 | Effect.gen(function* () { | 187 | Effect.gen(function* () { |
| 165 | const loaded = yield* load({ ...input, operation: "restart" }); | 188 | const loaded = yield* load({ ...input, operation: "restart" }); |
| 166 | if (Option.isSome(loaded.session)) { | 189 | if (Option.isNone(loaded.session)) { |
| 167 | yield* loaded.session.value.interruptTurn({ | 190 | // No live adapter: nothing can emit a terminal provider-turn update |
| 168 | providerThread: loaded.providerThread, | 191 | // from interrupt. Do not poll for projection terminalization or the |
| 169 | providerTurnId: loaded.providerTurn.id, | 192 | // restart effect stalls; let detach/start proceed. |
| 170 | }); | 193 | if (loaded.providerTurn.status === "running") { |
| 194 | yield* Effect.logWarning( | ||
| 195 | "Provider restart interrupt skipped; no live session for a still-projected running turn", | ||
| 196 | { | ||
| 197 | threadId: input.threadId, | ||
| 198 | providerSessionId: input.providerSessionId, | ||
| 199 | providerTurnId: input.providerTurnId, | ||
| 200 | }, | ||
| 201 | ); | ||
| 202 | } | ||
| 203 | return; | ||
| 171 | } | 204 | } |
| 172 | 205 | ||
| 206 | yield* loaded.session.value.interruptTurn({ | ||
| 207 | providerThread: loaded.providerThread, | ||
| 208 | providerTurnId: loaded.providerTurn.id, | ||
| 209 | }); | ||
| 210 | |||
| 173 | for (let remaining = 1_000; remaining > 0; remaining -= 1) { | 211 | for (let remaining = 1_000; remaining > 0; remaining -= 1) { |
| 174 | const projection = yield* projections.getThreadProjection(input.threadId); | 212 | const projection = yield* projections.getThreadProjection(input.threadId); |
| 175 | const providerTurn = projection.providerTurns.find( | 213 | const providerTurn = projection.providerTurns.find( |
| @@ -439,6 +439,23 @@ export const layer: Layer.Layer< · 438 unmodified lines | |||
| 439 | }), | 439 | }), |
| 440 | Effect.catchCause(() => Effect.succeed(false)), | 440 | Effect.catchCause(() => Effect.succeed(false)), |
| 441 | ), | 441 | ), |
| 442 | hasUnpairedRunInterruptRequest: () => | ||
| 443 | projectionStore.getThreadProjection(projection.thread.id).pipe( | ||
| 444 | Effect.map((current) => { | ||
| 445 | const requestId = idAllocator.derive.runSignalTurnItem({ | ||
| 446 | runId: run.id, | ||
| 447 | signal: "interrupt-request", | ||
| 448 | }); | ||
| 449 | const resultId = idAllocator.derive.runSignalTurnItem({ | ||
| 450 | runId: run.id, | ||
| 451 | signal: "interrupt-result", | ||
| 452 | }); | ||
| 453 | const hasRequest = current.turnItems.some((item) => item.id === requestId); | ||
| 454 | const hasResult = current.turnItems.some((item) => item.id === resultId); | ||
| 455 | return hasRequest && !hasResult; | ||
| 456 | }), | ||
| 457 | Effect.catchCause(() => Effect.succeed(false)), | ||
| 458 | ), | ||
| 442 | message: { | 459 | message: { |
| 443 | messageId: message.id, | 460 | messageId: message.id, |
| 444 | text: | 461 | text: |
| @@ -31,7 +31,7 @@ import * as Stream from "effect/Stream"; · 30 unmodified lines | |||
| 31 | import { ServerSettingsService } from "../serverSettings.ts"; | 31 | import { ServerSettingsService } from "../serverSettings.ts"; |
| 32 | import { CheckpointServiceV2 } from "./CheckpointService.ts"; | 32 | import { CheckpointServiceV2 } from "./CheckpointService.ts"; |
| 33 | import { EventSinkV2 } from "./EventSink.ts"; | 33 | import { EventSinkV2 } from "./EventSink.ts"; |
| 34 | import { layer as idAllocatorLayer } from "./IdAllocator.ts"; | 34 | import { IdAllocatorV2, layer as idAllocatorLayer } from "./IdAllocator.ts"; |
| 35 | import type { ProviderAdapterV2Event, ProviderAdapterV2SessionRuntime } from "./ProviderAdapter.ts"; | 35 | import type { ProviderAdapterV2Event, ProviderAdapterV2SessionRuntime } from "./ProviderAdapter.ts"; |
| 36 | import { ProviderEventIngestorV2 } from "./ProviderEventIngestor.ts"; | 36 | import { ProviderEventIngestorV2 } from "./ProviderEventIngestor.ts"; |
| 37 | import { | 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 | interface BackgroundScenarioIds { | 733 | interface BackgroundScenarioIds { |
| 546 | readonly threadId: ThreadId; | 734 | readonly threadId: ThreadId; |
| 547 | readonly childThreadId: ThreadId; | 735 | readonly childThreadId: ThreadId; |
| @@ -260,6 +260,7 @@ export interface RunExecutionServiceV2StartRootRunInput { · 259 unmodified lines | |||
| 260 | readonly relatedProviderThreadIds?: ReadonlyArray<ProviderThreadId>; | 260 | readonly relatedProviderThreadIds?: ReadonlyArray<ProviderThreadId>; |
| 261 | readonly shouldStartProviderTurn?: () => Effect.Effect<boolean, never>; | 261 | readonly shouldStartProviderTurn?: () => Effect.Effect<boolean, never>; |
| 262 | readonly shouldFinalizeRun?: () => Effect.Effect<boolean, never>; | 262 | readonly shouldFinalizeRun?: () => Effect.Effect<boolean, never>; |
| 263 | readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect<boolean, never>; | ||
| 263 | readonly message: ProviderAdapterV2TurnMessage; | 264 | readonly message: ProviderAdapterV2TurnMessage; |
| 264 | readonly modelSelection: ModelSelection; | 265 | readonly modelSelection: ModelSelection; |
| 265 | readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; | 266 | readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; |
| @@ -323,6 +324,7 @@ export const layer: Layer.Layer< · 57 unmodified lines | |||
| 323 | readonly providerThread: OrchestrationV2ProviderThread; | 324 | readonly providerThread: OrchestrationV2ProviderThread; |
| 324 | readonly attempt: OrchestrationV2RunAttempt; | 325 | readonly attempt: OrchestrationV2RunAttempt; |
| 325 | readonly shouldFinalizeRun?: () => Effect.Effect<boolean, never>; | 326 | readonly shouldFinalizeRun?: () => Effect.Effect<boolean, never>; |
| 327 | readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect<boolean, never>; | ||
| 326 | readonly terminal: ProviderTerminalEvent; | 328 | readonly terminal: ProviderTerminalEvent; |
| 327 | readonly failureItemPersisted: boolean; | 329 | readonly failureItemPersisted: boolean; |
| 328 | }) => | 330 | }) => |
| @@ -336,30 +338,37 @@ export const layer: Layer.Layer< · 7 unmodified lines | |||
| 336 | const shouldFinalizeRun = | 338 | const shouldFinalizeRun = |
| 337 | input.shouldFinalizeRun === undefined ? true : yield* input.shouldFinalizeRun(); | 339 | input.shouldFinalizeRun === undefined ? true : yield* input.shouldFinalizeRun(); |
| 338 | if (!shouldFinalizeRun) { | 340 | if (!shouldFinalizeRun) { |
| 339 | // A newer attempt already owns the run and the command that created | 341 | // Superseded attempt (steer / selection restart). Emit |
| 340 | // it terminalized this attempt as superseded. Preserve that domain | 342 | // run_interrupt_result only when hard Stop left an unpaired request |
| 341 | // status while retaining the provider's interruption artifact. | 343 | // for this run; plain steers and already-paired stops emit nothing. |
| 342 | if (input.terminal.status === "interrupted") { | 344 | if (input.terminal.status === "interrupted") { |
| 343 | yield* eventSink.write({ | 345 | const hasUnpairedRequest = |
| 344 | events: [ | 346 | input.hasUnpairedRunInterruptRequest === undefined |
| 345 | { | 347 | ? false |
| 346 | id: yield* idAllocator.allocate.event({ threadId: input.run.threadId }), | 348 | : yield* input.hasUnpairedRunInterruptRequest(); |
| 347 | type: "turn-item.updated" as const, | 349 | if (hasUnpairedRequest) { |
| 348 | threadId: input.run.threadId, | 350 | yield* eventSink.writeWithEffects({ |
| 349 | runId: input.run.id, | 351 | effects: [], |
| 350 | nodeId: input.rootNode.id, | 352 | events: [ |
| 351 | providerInstanceId: input.run.providerInstanceId, | 353 | { |
| 352 | occurredAt: completedAt, | 354 | id: yield* idAllocator.allocate.event({ threadId: input.run.threadId }), |
| 353 | payload: makeInterruptResultTurnItem({ | 355 | type: "turn-item.updated" as const, |
| 354 | idAllocator, | 356 | threadId: input.run.threadId, |
| 355 | run: input.run, | 357 | runId: input.run.id, |
| 356 | rootNode: input.rootNode, | 358 | nodeId: input.rootNode.id, |
| 357 | providerThread: input.providerThread, | 359 | providerInstanceId: input.run.providerInstanceId, |
| 358 | completedAt, | 360 | occurredAt: completedAt, |
| 359 | }), | 361 | payload: makeInterruptResultTurnItem({ |
| 360 | }, | 362 | idAllocator, |
| 361 | ], | 363 | run: input.run, |
| 362 | }); | 364 | rootNode: input.rootNode, |
| 365 | providerThread: input.providerThread, | ||
| 366 | completedAt, | ||
| 367 | }), | ||
| 368 | }, | ||
| 369 | ], | ||
| 370 | }); | ||
| 371 | } | ||
| 363 | } | 372 | } |
| 364 | return; | 373 | return; |
| 365 | } | 374 | } |
| @@ -594,6 +603,11 @@ export const layer: Layer.Layer< · 228 unmodified lines | |||
| 594 | ...(input.shouldFinalizeRun === undefined | 603 | ...(input.shouldFinalizeRun === undefined |
| 595 | ? {} | 604 | ? {} |
| 596 | : { shouldFinalizeRun: input.shouldFinalizeRun }), | 605 | : { shouldFinalizeRun: input.shouldFinalizeRun }), |
| 606 | ...(input.hasUnpairedRunInterruptRequest === undefined | ||
| 607 | ? {} | ||
| 608 | : { | ||
| 609 | hasUnpairedRunInterruptRequest: input.hasUnpairedRunInterruptRequest, | ||
| 610 | }), | ||
| 597 | terminal, | 611 | terminal, |
| 598 | failureItemPersisted: terminal.status === "failed", | 612 | failureItemPersisted: terminal.status === "failed", |
| 599 | }).pipe( | 613 | }).pipe( |
| @@ -776,6 +790,12 @@ export const layer: Layer.Layer< · 176 unmodified lines | |||
| 776 | ...(input.shouldFinalizeRun === undefined | 790 | ...(input.shouldFinalizeRun === undefined |
| 777 | ? {} | 791 | ? {} |
| 778 | : { shouldFinalizeRun: input.shouldFinalizeRun }), | 792 | : { shouldFinalizeRun: input.shouldFinalizeRun }), |
| 793 | ...(input.hasUnpairedRunInterruptRequest === undefined | ||
| 794 | ? {} | ||
| 795 | : { | ||
| 796 | hasUnpairedRunInterruptRequest: | ||
| 797 | input.hasUnpairedRunInterruptRequest, | ||
| 798 | }), | ||
| 779 | terminal: makeFailedTerminalEvent( | 799 | terminal: makeFailedTerminalEvent( |
| 780 | makeProviderFailure({ | 800 | makeProviderFailure({ |
| 781 | cause: Cause.squash(cause), | 801 | cause: Cause.squash(cause), |
| @@ -847,6 +867,12 @@ export const layer: Layer.Layer< · 65 unmodified lines | |||
| 847 | ...(input.shouldFinalizeRun === undefined | 867 | ...(input.shouldFinalizeRun === undefined |
| 848 | ? {} | 868 | ? {} |
| 849 | : { shouldFinalizeRun: input.shouldFinalizeRun }), | 869 | : { shouldFinalizeRun: input.shouldFinalizeRun }), |
| 870 | ...(input.hasUnpairedRunInterruptRequest === undefined | ||
| 871 | ? {} | ||
| 872 | : { | ||
| 873 | hasUnpairedRunInterruptRequest: | ||
| 874 | input.hasUnpairedRunInterruptRequest, | ||
| 875 | }), | ||
| 850 | terminal: makeFailedTerminalEvent( | 876 | terminal: makeFailedTerminalEvent( |
| 851 | makeProviderFailure({ | 877 | makeProviderFailure({ |
| 852 | cause: Cause.squash(cause), | 878 | cause: Cause.squash(cause), |
| @@ -490,7 +490,12 @@ it.live("restarts selection as a new attempt and retries after old-session clean · 489 unmodified lines | |||
| 490 | projection.providerTurns.map((turn) => turn.status), | 490 | projection.providerTurns.map((turn) => turn.status), |
| 491 | ["interrupted", "completed"], | 491 | ["interrupted", "completed"], |
| 492 | ); | 492 | ); |
| 493 | assert.isTrue(projection.turnItems.some((item) => item.type === "run_interrupt_result")); | 493 | assert.isFalse( |
| 494 | projection.turnItems.some( | ||
| 495 | (item) => item.type === "run_interrupt_request" || item.type === "run_interrupt_result", | ||
| 496 | ), | ||
| 497 | "selection restart supersede must not project hard-Stop interrupt items", | ||
| 498 | ); | ||
| 494 | assert.equal(projection.runs[0]?.modelSelection.model, replacementSelection.model); | 499 | assert.equal(projection.runs[0]?.modelSelection.model, replacementSelection.model); |
| 495 | assert.isTrue(captured.failedReplacementOpen); | 500 | assert.isTrue(captured.failedReplacementOpen); |
| 496 | // The old pooled process remains available to its other threads; this | 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 | const projection = projectionFor(result, transcript.scenario); | 25 | const projection = projectionFor(result, transcript.scenario); |
| 26 | assertSemanticProjectionIntegrity(projection); | 26 | assertSemanticProjectionIntegrity(projection); |
| 27 | assertTurnItemTypes(projection, ["user_message", "run_interrupt_result", "assistant_message"]); | 27 | assertTurnItemTypes(projection, ["user_message", "assistant_message"]); |
| 28 | assertRuntimeRequestCounts(projection, { total: 0 }); | 28 | assertRuntimeRequestCounts(projection, { total: 0 }); |
| 29 | assertUserMessagesInclude(projection, [ | 29 | assertUserMessagesInclude(projection, [ |
| 30 | MESSAGE_STEERING_INITIAL_PROMPT, | 30 | MESSAGE_STEERING_INITIAL_PROMPT, |
| @@ -47,9 +47,10 @@ export function assertCursorMessageSteeringOutput( · 16 unmodified lines | |||
| 47 | ); | 47 | ); |
| 48 | assert.equal(projection.runs[0]?.activeAttemptId, projection.attempts[1]?.id); | 48 | assert.equal(projection.runs[0]?.activeAttemptId, projection.attempts[1]?.id); |
| 49 | assert.equal(projection.runs[0]?.rootNodeId, projection.attempts[1]?.rootNodeId); | 49 | assert.equal(projection.runs[0]?.rootNodeId, projection.attempts[1]?.rootNodeId); |
| 50 | assert.notInclude( | 50 | assert.isFalse( |
| 51 | projection.visibleTurnItems.map((row) => row.item.type), | 51 | projection.turnItems.some( |
| 52 | "run_interrupt_result", | 52 | (item) => item.type === "run_interrupt_request" || item.type === "run_interrupt_result", |
| 53 | "restart steering must keep its internal interruption out of visible chat history", | 53 | ), |
| 54 | "steer supersede must not project run_interrupt_* items (those are hard Stop only)", | ||
| 54 | ); | 55 | ); |
| 55 | } | 56 | } |
| @@ -23,7 +23,7 @@ export function assertGrokMessageSteeringOutput( · 22 unmodified lines | |||
| 23 | 23 | ||
| 24 | const projection = projectionFor(result, transcript.scenario); | 24 | const projection = projectionFor(result, transcript.scenario); |
| 25 | assertSemanticProjectionIntegrity(projection); | 25 | assertSemanticProjectionIntegrity(projection); |
| 26 | assertTurnItemTypes(projection, ["user_message", "run_interrupt_result", "assistant_message"]); | 26 | assertTurnItemTypes(projection, ["user_message", "assistant_message"]); |
| 27 | assertRuntimeRequestCounts(projection, { total: 0 }); | 27 | assertRuntimeRequestCounts(projection, { total: 0 }); |
| 28 | assertUserMessagesInclude(projection, [ | 28 | assertUserMessagesInclude(projection, [ |
| 29 | MESSAGE_STEERING_INITIAL_PROMPT, | 29 | MESSAGE_STEERING_INITIAL_PROMPT, |
| @@ -46,9 +46,10 @@ export function assertGrokMessageSteeringOutput( · 16 unmodified lines | |||
| 46 | ); | 46 | ); |
| 47 | assert.equal(projection.runs[0]?.activeAttemptId, projection.attempts[1]?.id); | 47 | assert.equal(projection.runs[0]?.activeAttemptId, projection.attempts[1]?.id); |
| 48 | assert.equal(projection.runs[0]?.rootNodeId, projection.attempts[1]?.rootNodeId); | 48 | assert.equal(projection.runs[0]?.rootNodeId, projection.attempts[1]?.rootNodeId); |
| 49 | assert.notInclude( | 49 | assert.isFalse( |
| 50 | projection.visibleTurnItems.map((row) => row.item.type), | 50 | projection.turnItems.some( |
| 51 | "run_interrupt_result", | 51 | (item) => item.type === "run_interrupt_request" || item.type === "run_interrupt_result", |
| 52 | "restart steering must keep its internal interruption out of visible chat history", | 52 | ), |
| 53 | "steer supersede must not project run_interrupt_* items (those are hard Stop only)", | ||
| 53 | ); | 54 | ); |
| 54 | } | 55 | } |
| @@ -753,6 +753,40 @@ describe("AcpSessionRuntime", () => { · 752 unmodified lines | |||
| 753 | ), | 753 | ), |
| 754 | ); | 754 | ); |
| 755 | 755 | ||
| 756 | it.effect("completes ad-hoc loadSession after replay becomes idle while RPC stays pending", () => | ||
| 757 | Effect.gen(function* () { | ||
| 758 | const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; | ||
| 759 | yield* runtime.start(); | ||
| 760 | const loaded = yield* runtime.loadSession("mock-session-1").pipe(Effect.timeout("2 seconds")); | ||
| 761 | |||
| 762 | expect(loaded.sessionId).toBe("mock-session-1"); | ||
| 763 | expect(loaded.sessionSetupResult._meta).toMatchObject({ | ||
| 764 | t3SessionLoadReady: "replay_idle", | ||
| 765 | }); | ||
| 766 | }).pipe( | ||
| 767 | Effect.provide( | ||
| 768 | AcpSessionRuntime.layer({ | ||
| 769 | authMethodId: "test", | ||
| 770 | spawn: { | ||
| 771 | command: mockAgentCommand, | ||
| 772 | args: mockAgentArgs, | ||
| 773 | env: { | ||
| 774 | T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY: "1", | ||
| 775 | T3_ACP_LOAD_SESSION_DELAY_MS: "10000", | ||
| 776 | }, | ||
| 777 | }, | ||
| 778 | cwd: process.cwd(), | ||
| 779 | sessionLoadReplayIdleGap: "50 millis", | ||
| 780 | sessionLoadTimeout: "1 second", | ||
| 781 | clientInfo: { name: "t3-test", version: "0.0.0" }, | ||
| 782 | }), | ||
| 783 | ), | ||
| 784 | Effect.scoped, | ||
| 785 | Effect.provide(NodeServices.layer), | ||
| 786 | TestClock.withLive, | ||
| 787 | ), | ||
| 788 | ); | ||
| 789 | |||
| 756 | it.effect("rejects invalid config option values before sending session/set_config_option", () => { | 790 | it.effect("rejects invalid config option values before sending session/set_config_option", () => { |
| 757 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-")); | 791 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-")); |
| 758 | const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); | 792 | const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | parsePermissionRequest, | 9 | parsePermissionRequest, |
| 10 | parseSessionModeState, | 10 | parseSessionModeState, |
| 11 | parseSessionUpdateEvent, | 11 | parseSessionUpdateEvent, |
| 12 | sessionUpdateCountsAsLoadReplayActivity, | ||
| 12 | sessionUpdateIsReplay, | 13 | sessionUpdateIsReplay, |
| 13 | syntheticLoadSessionResponseFromInitialize, | 14 | syntheticLoadSessionResponseFromInitialize, |
| 14 | toolCallProgressLength, | 15 | toolCallProgressLength, |
| @@ -86,6 +87,98 @@ describe("AcpRuntimeModel", () => { · 71 unmodified lines | |||
| 86 | ).toBe(false); | 87 | ).toBe(false); |
| 87 | }); | 88 | }); |
| 88 | 89 | ||
| 90 | it("ignores Grok keepalive chunks when tracking session/load replay activity", () => { | ||
| 91 | expect( | ||
| 92 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 93 | sessionId: "session-1", | ||
| 94 | update: { | ||
| 95 | sessionUpdate: "agent_message_chunk", | ||
| 96 | content: { type: "text", text: "" }, | ||
| 97 | }, | ||
| 98 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 99 | ).toBe(false); | ||
| 100 | expect( | ||
| 101 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 102 | _meta: { isReplay: true }, | ||
| 103 | sessionId: "session-1", | ||
| 104 | update: { | ||
| 105 | sessionUpdate: "tool_call", | ||
| 106 | toolCallId: "replay-tool", | ||
| 107 | title: "Replay", | ||
| 108 | kind: "search", | ||
| 109 | status: "completed", | ||
| 110 | }, | ||
| 111 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 112 | ).toBe(true); | ||
| 113 | }); | ||
| 114 | |||
| 115 | it("ignores load-replay activity from other sessions while a load gate is active", () => { | ||
| 116 | expect( | ||
| 117 | sessionUpdateCountsAsLoadReplayActivity( | ||
| 118 | { | ||
| 119 | sessionId: "session-other", | ||
| 120 | update: { | ||
| 121 | sessionUpdate: "agent_message_chunk", | ||
| 122 | content: { type: "text", text: "unrelated" }, | ||
| 123 | }, | ||
| 124 | } satisfies EffectAcpSchema.SessionNotification, | ||
| 125 | "session-loading", | ||
| 126 | ), | ||
| 127 | ).toBe(false); | ||
| 128 | expect( | ||
| 129 | sessionUpdateCountsAsLoadReplayActivity( | ||
| 130 | { | ||
| 131 | sessionId: "session-loading", | ||
| 132 | update: { | ||
| 133 | sessionUpdate: "agent_message_chunk", | ||
| 134 | content: { type: "text", text: "replay" }, | ||
| 135 | }, | ||
| 136 | } satisfies EffectAcpSchema.SessionNotification, | ||
| 137 | "session-loading", | ||
| 138 | ), | ||
| 139 | ).toBe(true); | ||
| 140 | }); | ||
| 141 | |||
| 142 | it("counts mode/config/session/usage updates as load-replay activity", () => { | ||
| 143 | expect( | ||
| 144 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 145 | sessionId: "session-1", | ||
| 146 | update: { | ||
| 147 | sessionUpdate: "current_mode_update", | ||
| 148 | currentModeId: "code", | ||
| 149 | }, | ||
| 150 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 151 | ).toBe(true); | ||
| 152 | expect( | ||
| 153 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 154 | sessionId: "session-1", | ||
| 155 | update: { | ||
| 156 | sessionUpdate: "config_option_update", | ||
| 157 | configOptions: [], | ||
| 158 | }, | ||
| 159 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 160 | ).toBe(true); | ||
| 161 | expect( | ||
| 162 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 163 | sessionId: "session-1", | ||
| 164 | update: { | ||
| 165 | sessionUpdate: "session_info_update", | ||
| 166 | title: "Restored", | ||
| 167 | }, | ||
| 168 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 169 | ).toBe(true); | ||
| 170 | expect( | ||
| 171 | sessionUpdateCountsAsLoadReplayActivity({ | ||
| 172 | sessionId: "session-1", | ||
| 173 | update: { | ||
| 174 | sessionUpdate: "usage_update", | ||
| 175 | used: 1, | ||
| 176 | size: 10, | ||
| 177 | }, | ||
| 178 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 179 | ).toBe(true); | ||
| 180 | }); | ||
| 181 | |||
| 89 | it("builds a synthetic load response from initialize model state", () => { | 182 | it("builds a synthetic load response from initialize model state", () => { |
| 90 | const response = syntheticLoadSessionResponseFromInitialize({ | 183 | const response = syntheticLoadSessionResponseFromInitialize({ |
| 91 | protocolVersion: 1, | 184 | protocolVersion: 1, |
| @@ -700,8 +700,38 @@ export function sessionUpdateIsReplay(params: EffectAcpSchema.SessionNotificatio · 699 unmodified lines | |||
| 700 | return isRecord(meta) && meta.isReplay === true; | 700 | return isRecord(meta) && meta.isReplay === true; |
| 701 | } | 701 | } |
| 702 | 702 | ||
| 703 | /** Replay chunks and substantive updates during session/load; not Grok keepalives. */ | ||
| 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 | export interface SessionLoadGate { | 731 | export interface SessionLoadGate { |
| 704 | readonly active: boolean; | 732 | readonly active: boolean; |
| 733 | /** Only notifications for this session refresh load-replay idle activity. */ | ||
| 734 | readonly sessionId: string; | ||
| 705 | readonly lastActivityAtMillis: number | undefined; | 735 | readonly lastActivityAtMillis: number | undefined; |
| 706 | readonly idleGap: Duration.Duration; | 736 | readonly idleGap: Duration.Duration; |
| 707 | readonly initializeResult: EffectAcpSchema.InitializeResponse; | 737 | readonly initializeResult: EffectAcpSchema.InitializeResponse; |
| @@ -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 | import * as Cause from "effect/Cause"; | 6 | import * as Cause from "effect/Cause"; |
| 2 | import * as Clock from "effect/Clock"; | 7 | import * as Clock from "effect/Clock"; |
| 3 | import * as Context from "effect/Context"; | 8 | import * as Context from "effect/Context"; |
| @@ -5,11 +10,13 @@ import * as Crypto from "effect/Crypto"; · 1 unmodified line | |||
| 5 | import * as Deferred from "effect/Deferred"; | 10 | import * as Deferred from "effect/Deferred"; |
| 6 | import * as Duration from "effect/Duration"; | 11 | import * as Duration from "effect/Duration"; |
| 7 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Exit from "effect/Exit"; | ||
| 8 | import * as Fiber from "effect/Fiber"; | 14 | import * as Fiber from "effect/Fiber"; |
| 9 | import * as Layer from "effect/Layer"; | 15 | import * as Layer from "effect/Layer"; |
| 10 | import * as Option from "effect/Option"; | 16 | import * as Option from "effect/Option"; |
| 11 | import * as Queue from "effect/Queue"; | 17 | import * as Queue from "effect/Queue"; |
| 12 | import * as Ref from "effect/Ref"; | 18 | import * as Ref from "effect/Ref"; |
| 19 | import * as Schema from "effect/Schema"; | ||
| 13 | import * as Scope from "effect/Scope"; | 20 | import * as Scope from "effect/Scope"; |
| 14 | import * as Semaphore from "effect/Semaphore"; | 21 | import * as Semaphore from "effect/Semaphore"; |
| 15 | import * as Stream from "effect/Stream"; | 22 | import * as Stream from "effect/Stream"; |
| @@ -20,6 +27,7 @@ import * as EffectAcpErrors from "effect-acp/errors"; · 4 unmodified lines | |||
| 20 | import type * as EffectAcpSchema from "effect-acp/schema"; | 27 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 21 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | 28 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 22 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | 29 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 30 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 23 | 31 | ||
| 24 | import { | 32 | import { |
| 25 | collectSessionConfigOptionValues, | 33 | collectSessionConfigOptionValues, |
| @@ -28,6 +36,7 @@ import { · 2 unmodified lines | |||
| 28 | mergeToolCallState, | 36 | mergeToolCallState, |
| 29 | parseSessionModeState, | 37 | parseSessionModeState, |
| 30 | parseSessionUpdateEvent, | 38 | parseSessionUpdateEvent, |
| 39 | sessionUpdateCountsAsLoadReplayActivity, | ||
| 31 | sessionUpdateIsReplay, | 40 | sessionUpdateIsReplay, |
| 32 | waitForSessionLoadReplayIdle, | 41 | waitForSessionLoadReplayIdle, |
| 33 | type SessionLoadGate, | 42 | type SessionLoadGate, |
| @@ -63,6 +72,16 @@ export interface AcpSessionRuntimeOptions { · 29 unmodified lines | |||
| 63 | readonly resumeSessionId?: string; | 72 | readonly resumeSessionId?: string; |
| 64 | readonly sessionLoadTimeout?: Duration.Input; | 73 | readonly sessionLoadTimeout?: Duration.Input; |
| 65 | readonly sessionLoadReplayIdleGap?: Duration.Input; | 74 | readonly sessionLoadReplayIdleGap?: Duration.Input; |
| 75 | readonly interruptPromptOnCancel?: boolean; | ||
| 76 | readonly ownDetachedProcessGroup?: boolean; | ||
| 77 | readonly ownDescendantProcessGroups?: boolean; | ||
| 78 | readonly processGroupPlatform?: NodeJS.Platform; | ||
| 79 | readonly processGroupTerminationGrace?: Duration.Input; | ||
| 80 | readonly windowsProcessTreeTerminator?: ( | ||
| 81 | pid: number, | ||
| 82 | ) => Effect.Effect<void, AcpProcessGroupTerminationError>; | ||
| 83 | readonly posixProcessTreeController?: AcpPosixProcessTreeController; | ||
| 84 | readonly linuxCgroupController?: AcpLinuxCgroupController | null; | ||
| 66 | readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; | 85 | readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; |
| 67 | readonly clientInfo: { | 86 | readonly clientInfo: { |
| 68 | readonly name: string; | 87 | readonly name: string; |
| @@ -76,6 +95,10 @@ export interface AcpSessionRuntimeOptions { · 7 unmodified lines | |||
| 76 | readonly logOutgoing?: boolean; | 95 | readonly logOutgoing?: boolean; |
| 77 | readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>; | 96 | readonly logger?: (event: EffectAcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>; |
| 78 | }; | 97 | }; |
| 98 | readonly onIncomingRequest?: EffectAcpClient.AcpClientOptions["onIncomingRequest"]; | ||
| 99 | readonly onTermination?: (error: EffectAcpErrors.AcpError) => Effect.Effect<void>; | ||
| 100 | readonly onOutgoingResponseFailure?: EffectAcpClient.AcpClientOptions["onOutgoingResponseFailure"]; | ||
| 101 | readonly onOutgoingResponse?: EffectAcpClient.AcpClientOptions["onOutgoingResponse"]; | ||
| 79 | } | 102 | } |
| 80 | 103 | ||
| 81 | export interface AcpSessionRequestLogEvent { | 104 | export interface AcpSessionRequestLogEvent { |
| @@ -86,6 +109,925 @@ export interface AcpSessionRequestLogEvent { · 4 unmodified lines | |||
| 86 | readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>; | 109 | readonly cause?: Cause.Cause<EffectAcpErrors.AcpError>; |
| 87 | } | 110 | } |
| 88 | 111 | ||
| 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 | export function selectAcpAgentAuthMethod( | 1031 | export function selectAcpAgentAuthMethod( |
| 90 | authMethods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined, | 1032 | authMethods: ReadonlyArray<EffectAcpSchema.AuthMethod> | undefined, |
| 91 | preferredMethodId?: string, | 1033 | preferredMethodId?: string, |
| @@ -237,6 +1179,13 @@ export class AcpSessionRuntime extends Context.Service< · 145 unmodified lines | |||
| 237 | * @see https://agentclientprotocol.com/protocol/schema#session/cancel | 1179 | * @see https://agentclientprotocol.com/protocol/schema#session/cancel |
| 238 | */ | 1180 | */ |
| 239 | readonly cancel: Effect.Effect<void, EffectAcpErrors.AcpError>; | 1181 | readonly cancel: Effect.Effect<void, EffectAcpErrors.AcpError>; |
| 1182 | readonly processContainment: | ||
| 1183 | | "cgroup-v2" | ||
| 1184 | | "process-ledger-reduced-guarantee" | ||
| 1185 | | "process-group" | ||
| 1186 | | "none"; | ||
| 1187 | /** Terminates the opt-in detached ACP process group and all inherited work. */ | ||
| 1188 | readonly terminateProcessGroup?: Effect.Effect<void, AcpProcessGroupTerminationError>; | ||
| 240 | /** | 1189 | /** |
| 241 | * Selects the active mode through the negotiated `mode` configuration option. | 1190 | * Selects the active mode through the negotiated `mode` configuration option. |
| 242 | * This is a no-op when the requested mode is already active. | 1191 | * This is a no-op when the requested mode is already active. |
| @@ -341,6 +1290,7 @@ export const make = ( · 98 unmodified lines | |||
| 341 | const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); | 1290 | const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); |
| 342 | const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" }); | 1291 | const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" }); |
| 343 | const promptSerializationSemaphore = yield* Semaphore.make(1); | 1292 | const promptSerializationSemaphore = yield* Semaphore.make(1); |
| 1293 | const sessionLoadSemaphore = yield* Semaphore.make(1); | ||
| 344 | const activePromptFiberRef = yield* Ref.make< | 1294 | const activePromptFiberRef = yield* Ref.make< |
| 345 | Option.Option<Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>> | 1295 | Option.Option<Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>> |
| 346 | >(Option.none()); | 1296 | >(Option.none()); |
| @@ -382,12 +1332,70 @@ export const make = ( · 35 unmodified lines | |||
| 382 | options.spawn.args, | 1332 | options.spawn.args, |
| 383 | options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}, | 1333 | options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}, |
| 384 | ); | 1334 | ); |
| 1335 | const linuxCgroupLease = | ||
| 1336 | options.ownDescendantProcessGroups === true && options.processGroupPlatform === "linux" | ||
| 1337 | ? yield* Effect.sync(() => { | ||
| 1338 | try { | ||
| 1339 | if (options.linuxCgroupController === null) return undefined; | ||
| 1340 | return (options.linuxCgroupController ?? makeLinuxCgroupController()).create(); | ||
| 1341 | } catch { | ||
| 1342 | return undefined; | ||
| 1343 | } | ||
| 1344 | }) | ||
| 1345 | : undefined; | ||
| 1346 | // This is lifecycle containment for inherited provider work. It is not a | ||
| 1347 | // security boundary against a provider that can modify its delegated cgroup. | ||
| 1348 | if (linuxCgroupLease !== undefined) { | ||
| 1349 | yield* Scope.addFinalizer( | ||
| 1350 | runtimeScope, | ||
| 1351 | Effect.uninterruptible(terminateLinuxCgroupLease(linuxCgroupLease)).pipe( | ||
| 1352 | Effect.ignoreCause({ log: true }), | ||
| 1353 | ), | ||
| 1354 | ); | ||
| 1355 | } | ||
| 1356 | const containedTargetCommand = | ||
| 1357 | linuxCgroupLease === undefined || NodePath.isAbsolute(spawnCommand.command) | ||
| 1358 | ? spawnCommand.command | ||
| 1359 | : yield* Effect.gen(function* () { | ||
| 1360 | const resolved = resolveLinuxCgroupTargetCommand( | ||
| 1361 | spawnCommand.command, | ||
| 1362 | options.spawn.cwd ?? options.cwd, | ||
| 1363 | { ...process.env, ...options.spawn.env }, | ||
| 1364 | ); | ||
| 1365 | if (resolved !== undefined) return resolved; | ||
| 1366 | return yield* new EffectAcpErrors.AcpSpawnError({ | ||
| 1367 | command: options.spawn.command, | ||
| 1368 | cause: new Error("Contained ACP command was not found on PATH"), | ||
| 1369 | }); | ||
| 1370 | }); | ||
| 1371 | const containedSpawnCommand = | ||
| 1372 | linuxCgroupLease === undefined | ||
| 1373 | ? spawnCommand | ||
| 1374 | : { | ||
| 1375 | ...wrapCommandForLinuxCgroup( | ||
| 1376 | linuxCgroupLease, | ||
| 1377 | containedTargetCommand, | ||
| 1378 | spawnCommand.args, | ||
| 1379 | ), | ||
| 1380 | shell: false, | ||
| 1381 | }; | ||
| 1382 | const spawnEnvironment = | ||
| 1383 | linuxCgroupLease === undefined | ||
| 1384 | ? options.spawn.env | ||
| 1385 | : { | ||
| 1386 | ...options.spawn.env, | ||
| 1387 | ELECTRON_RUN_AS_NODE: "1", | ||
| 1388 | T3_ACP_CGROUP_WRAPPER: "1", | ||
| 1389 | }; | ||
| 385 | const child = yield* spawner | 1390 | const child = yield* spawner |
| 386 | .spawn( | 1391 | .spawn( |
| 387 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { | 1392 | ChildProcess.make(containedSpawnCommand.command, containedSpawnCommand.args, { |
| 388 | ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), | 1393 | ...(options.spawn.cwd ? { cwd: options.spawn.cwd } : {}), |
| 389 | ...(options.spawn.env ? { env: options.spawn.env, extendEnv: true } : {}), | 1394 | ...(spawnEnvironment ? { env: spawnEnvironment, extendEnv: true } : {}), |
| 390 | shell: spawnCommand.shell, | 1395 | ...(options.ownDetachedProcessGroup === undefined |
| 1396 | ? {} | ||
| 1397 | : { detached: options.ownDetachedProcessGroup }), | ||
| 1398 | shell: containedSpawnCommand.shell, | ||
| 391 | }), | 1399 | }), |
| 392 | ) | 1400 | ) |
| 393 | .pipe( | 1401 | .pipe( |
| @@ -401,6 +1409,155 @@ export const make = ( · 7 unmodified lines | |||
| 401 | ), | 1409 | ), |
| 402 | ); | 1410 | ); |
| 403 | 1411 | ||
| 1412 | const posixOwnershipLedger = new Map<string, AcpOwnedPosixProcess>(); | ||
| 1413 | const posixOwnershipFrontier = new Map<number, AcpOwnedPosixProcess>(); | ||
| 1414 | const posixOwnershipChildQueues = new Map<number, Array<number>>(); | ||
| 1415 | const posixOwnershipRoot: AcpPosixOwnershipRoot = { value: undefined }; | ||
| 1416 | const posixController = | ||
| 1417 | options.ownDescendantProcessGroups === true | ||
| 1418 | ? yield* Effect.try({ | ||
| 1419 | try: () => { | ||
| 1420 | if (options.posixProcessTreeController !== undefined) { | ||
| 1421 | return options.posixProcessTreeController; | ||
| 1422 | } | ||
| 1423 | if (options.processGroupPlatform === undefined) { | ||
| 1424 | throw new AcpProcessGroupTerminationError({ | ||
| 1425 | detail: "POSIX ACP descendant ownership requires a supported host platform", | ||
| 1426 | }); | ||
| 1427 | } | ||
| 1428 | return makePosixProcessTreeController(options.processGroupPlatform); | ||
| 1429 | }, | ||
| 1430 | catch: (cause) => | ||
| 1431 | new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), | ||
| 1432 | }) | ||
| 1433 | : undefined; | ||
| 1434 | if (posixController !== undefined) { | ||
| 1435 | const observeOwnership = () => | ||
| 1436 | observePosixOwnershipLedger({ | ||
| 1437 | childQueues: posixOwnershipChildQueues, | ||
| 1438 | controller: posixController, | ||
| 1439 | frontier: posixOwnershipFrontier, | ||
| 1440 | ledger: posixOwnershipLedger, | ||
| 1441 | root: posixOwnershipRoot, | ||
| 1442 | rootPid: Number(child.pid), | ||
| 1443 | }); | ||
| 1444 | yield* Effect.try({ | ||
| 1445 | try: observeOwnership, | ||
| 1446 | catch: (cause) => | ||
| 1447 | new EffectAcpErrors.AcpSpawnError({ command: options.spawn.command, cause }), | ||
| 1448 | }); | ||
| 1449 | yield* observePosixOwnershipLedgerContinuously({ | ||
| 1450 | childQueues: posixOwnershipChildQueues, | ||
| 1451 | controller: posixController, | ||
| 1452 | frontier: posixOwnershipFrontier, | ||
| 1453 | ledger: posixOwnershipLedger, | ||
| 1454 | root: posixOwnershipRoot, | ||
| 1455 | rootPid: Number(child.pid), | ||
| 1456 | }).pipe(Effect.forkIn(runtimeScope)); | ||
| 1457 | } | ||
| 1458 | |||
| 1459 | const signalOwnedProcessGroup = (signal: NodeJS.Signals) => | ||
| 1460 | Effect.try({ | ||
| 1461 | try: () => { | ||
| 1462 | process.kill(-Number(child.pid), signal); | ||
| 1463 | return true; | ||
| 1464 | }, | ||
| 1465 | catch: (cause) => | ||
| 1466 | new AcpProcessGroupTerminationError({ | ||
| 1467 | cause, | ||
| 1468 | detail: `Failed to signal ACP process group ${child.pid} with ${signal}`, | ||
| 1469 | }), | ||
| 1470 | }).pipe( | ||
| 1471 | Effect.catch((error) => { | ||
| 1472 | const cause = error.cause as NodeJS.ErrnoException | undefined; | ||
| 1473 | return cause?.code === "ESRCH" ? Effect.succeed(false) : Effect.fail(error); | ||
| 1474 | }), | ||
| 1475 | ); | ||
| 1476 | const terminateWindowsProcessTree = | ||
| 1477 | options.windowsProcessTreeTerminator ?? | ||
| 1478 | ((pid: number) => terminateWindowsProcessTreeWithTaskkill(spawner, pid)); | ||
| 1479 | const terminatePosixProcessTree = (grace: Duration.Input, platformOverride?: NodeJS.Platform) => | ||
| 1480 | Effect.gen(function* () { | ||
| 1481 | const platform = platformOverride ?? options.processGroupPlatform; | ||
| 1482 | if (platform === undefined || platform === "win32") { | ||
| 1483 | return yield* new AcpProcessGroupTerminationError({ | ||
| 1484 | detail: "POSIX ACP descendant ownership requires a supported host platform", | ||
| 1485 | }); | ||
| 1486 | } | ||
| 1487 | const controller = yield* Effect.try({ | ||
| 1488 | try: () => posixController ?? makePosixProcessTreeController(platform), | ||
| 1489 | catch: (cause) => | ||
| 1490 | isAcpProcessGroupTerminationError(cause) | ||
| 1491 | ? cause | ||
| 1492 | : new AcpProcessGroupTerminationError({ | ||
| 1493 | cause, | ||
| 1494 | detail: `Failed to initialize ACP descendant ownership on ${platform}`, | ||
| 1495 | }), | ||
| 1496 | }); | ||
| 1497 | yield* terminatePosixOwnedProcessTree({ | ||
| 1498 | controller, | ||
| 1499 | grace, | ||
| 1500 | ledger: posixOwnershipLedger, | ||
| 1501 | root: posixOwnershipRoot, | ||
| 1502 | rootPid: Number(child.pid), | ||
| 1503 | }); | ||
| 1504 | }); | ||
| 1505 | const terminateOwnedProcessGroupImpl = Effect.gen(function* () { | ||
| 1506 | if (options.ownDetachedProcessGroup !== true) return; | ||
| 1507 | if (options.processGroupPlatform === undefined) { | ||
| 1508 | return yield* new AcpProcessGroupTerminationError({ | ||
| 1509 | detail: "Detached ACP process-group ownership requires a host platform", | ||
| 1510 | }); | ||
| 1511 | } | ||
| 1512 | if (options.processGroupPlatform === "win32") { | ||
| 1513 | return yield* terminateWindowsProcessTree(Number(child.pid)); | ||
| 1514 | } | ||
| 1515 | if (linuxCgroupLease !== undefined) { | ||
| 1516 | if (posixController !== undefined) { | ||
| 1517 | yield* Effect.sync(() => | ||
| 1518 | signalLinuxCgroupRootTerm({ | ||
| 1519 | controller: posixController, | ||
| 1520 | lease: linuxCgroupLease, | ||
| 1521 | root: posixOwnershipRoot, | ||
| 1522 | }), | ||
| 1523 | ); | ||
| 1524 | } | ||
| 1525 | const grace = options.processGroupTerminationGrace ?? "1 second"; | ||
| 1526 | if (grace !== 0) yield* Effect.sleep(grace); | ||
| 1527 | return yield* terminateLinuxCgroupLease(linuxCgroupLease); | ||
| 1528 | } | ||
| 1529 | if (options.ownDescendantProcessGroups === true) { | ||
| 1530 | return yield* terminatePosixProcessTree(options.processGroupTerminationGrace ?? "1 second"); | ||
| 1531 | } | ||
| 1532 | const groupExisted = yield* signalOwnedProcessGroup("SIGTERM"); | ||
| 1533 | if (!groupExisted) return; | ||
| 1534 | const grace = options.processGroupTerminationGrace ?? "1 second"; | ||
| 1535 | if (grace !== 0) { | ||
| 1536 | yield* Effect.sleep(grace); | ||
| 1537 | } | ||
| 1538 | yield* signalOwnedProcessGroup("SIGKILL"); | ||
| 1539 | }).pipe(withWallClock); | ||
| 1540 | const terminateProcessGroup = yield* Effect.cached( | ||
| 1541 | Effect.uninterruptible(terminateOwnedProcessGroupImpl), | ||
| 1542 | ); | ||
| 1543 | if (options.ownDetachedProcessGroup === true) { | ||
| 1544 | const hostPlatform = yield* HostProcessPlatform; | ||
| 1545 | const forceTerminateOwnedProcessGroup = | ||
| 1546 | hostPlatform === "win32" | ||
| 1547 | ? terminateWindowsProcessTreeWithTaskkill(spawner, Number(child.pid)) | ||
| 1548 | : linuxCgroupLease !== undefined | ||
| 1549 | ? terminateLinuxCgroupLease(linuxCgroupLease) | ||
| 1550 | : options.ownDescendantProcessGroups === true | ||
| 1551 | ? terminatePosixProcessTree(0, hostPlatform) | ||
| 1552 | : signalOwnedProcessGroup("SIGKILL").pipe(Effect.asVoid); | ||
| 1553 | yield* Scope.addFinalizer( | ||
| 1554 | runtimeScope, | ||
| 1555 | Effect.uninterruptible(forceTerminateOwnedProcessGroup).pipe( | ||
| 1556 | Effect.ignoreCause({ log: true }), | ||
| 1557 | ), | ||
| 1558 | ); | ||
| 1559 | } | ||
| 1560 | |||
| 404 | const acpContext = yield* Layer.build( | 1561 | const acpContext = yield* Layer.build( |
| 405 | EffectAcpClient.layerChildProcess(child, { | 1562 | EffectAcpClient.layerChildProcess(child, { |
| 406 | ...(options.protocolLogging?.logIncoming !== undefined | 1563 | ...(options.protocolLogging?.logIncoming !== undefined |
| @@ -410,6 +1567,17 @@ export const make = ( · 3 unmodified lines | |||
| 410 | ? { logOutgoing: options.protocolLogging.logOutgoing } | 1567 | ? { logOutgoing: options.protocolLogging.logOutgoing } |
| 411 | : {}), | 1568 | : {}), |
| 412 | ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}), | 1569 | ...(options.protocolLogging?.logger ? { logger: options.protocolLogging.logger } : {}), |
| 1570 | ...(options.onIncomingRequest ? { onIncomingRequest: options.onIncomingRequest } : {}), | ||
| 1571 | onTermination: (error) => | ||
| 1572 | (options.onTermination?.(error) ?? Effect.void).pipe( | ||
| 1573 | Effect.ensuring( | ||
| 1574 | Scope.close(runtimeScope, Exit.fail(error)).pipe(Effect.forkDetach, Effect.asVoid), | ||
| 1575 | ), | ||
| 1576 | ), | ||
| 1577 | ...(options.onOutgoingResponseFailure | ||
| 1578 | ? { onOutgoingResponseFailure: options.onOutgoingResponseFailure } | ||
| 1579 | : {}), | ||
| 1580 | ...(options.onOutgoingResponse ? { onOutgoingResponse: options.onOutgoingResponse } : {}), | ||
| 413 | }), | 1581 | }), |
| 414 | ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); | 1582 | ).pipe(Effect.provideService(Scope.Scope, runtimeScope)); |
| 415 | 1583 | ||
| @@ -419,14 +1587,16 @@ export const make = ( · 3 unmodified lines | |||
| 419 | Effect.gen(function* () { | 1587 | Effect.gen(function* () { |
| 420 | const gate = yield* Ref.get(sessionLoadGateRef); | 1588 | const gate = yield* Ref.get(sessionLoadGateRef); |
| 421 | if (Option.isSome(gate) && gate.value.active) { | 1589 | if (Option.isSome(gate) && gate.value.active) { |
| 422 | const lastActivityAtMillis = yield* Clock.currentTimeMillis; | 1590 | if (sessionUpdateCountsAsLoadReplayActivity(notification, gate.value.sessionId)) { |
| 423 | yield* Ref.set( | 1591 | const lastActivityAtMillis = yield* Clock.currentTimeMillis; |
| 424 | sessionLoadGateRef, | 1592 | yield* Ref.set( |
| 425 | Option.some({ | 1593 | sessionLoadGateRef, |
| 426 | ...gate.value, | 1594 | Option.some({ |
| 427 | lastActivityAtMillis, | 1595 | ...gate.value, |
| 428 | }), | 1596 | lastActivityAtMillis, |
| 429 | ); | 1597 | }), |
| 1598 | ); | ||
| 1599 | } | ||
| 430 | return; | 1600 | return; |
| 431 | } | 1601 | } |
| 432 | if (sessionUpdateIsReplay(notification)) { | 1602 | if (sessionUpdateIsReplay(notification)) { |
| @@ -550,20 +1720,121 @@ export const make = ( · 117 unmodified lines | |||
| 550 | ): Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError> => | 1720 | ): Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError> => |
| 551 | Effect.gen(function* () { | 1721 | Effect.gen(function* () { |
| 552 | const current = yield* getStartedState; | 1722 | const current = yield* getStartedState; |
| 1723 | const meta = sessionSetupResult._meta; | ||
| 1724 | const syntheticReplayIdle = | ||
| 1725 | meta !== null && | ||
| 1726 | typeof meta === "object" && | ||
| 1727 | !Array.isArray(meta) && | ||
| 1728 | (meta as { readonly t3SessionLoadReady?: unknown }).t3SessionLoadReady === "replay_idle"; | ||
| 1729 | const extractedModelConfigId = extractModelConfigId(sessionSetupResult); | ||
| 1730 | const nextModelConfigId = | ||
| 1731 | extractedModelConfigId ?? (syntheticReplayIdle ? current.modelConfigId : undefined); | ||
| 1732 | const nextModeState = parseSessionModeState(sessionSetupResult); | ||
| 1733 | if (nextModeState !== undefined) { | ||
| 1734 | yield* Ref.set(modeStateRef, nextModeState); | ||
| 1735 | } else if (!syntheticReplayIdle) { | ||
| 1736 | yield* Ref.set(modeStateRef, undefined); | ||
| 1737 | } | ||
| 1738 | // Synthetic replay-idle load responses only carry initialize model/mode | ||
| 1739 | // meta; preserve live configOptions so setConfigOption still validates. | ||
| 1740 | if ( | ||
| 1741 | sessionSetupResult.configOptions !== undefined && | ||
| 1742 | sessionSetupResult.configOptions !== null | ||
| 1743 | ) { | ||
| 1744 | yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); | ||
| 1745 | } else if (!syntheticReplayIdle) { | ||
| 1746 | yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); | ||
| 1747 | } | ||
| 553 | const nextState = { | 1748 | const nextState = { |
| 554 | sessionId, | 1749 | sessionId, |
| 555 | initializeResult: current.initializeResult, | 1750 | initializeResult: current.initializeResult, |
| 556 | sessionSetupResult, | 1751 | sessionSetupResult, |
| 557 | modelConfigId: extractModelConfigId(sessionSetupResult), | 1752 | modelConfigId: nextModelConfigId, |
| 558 | } satisfies AcpStartedState; | 1753 | } satisfies AcpStartedState; |
| 559 | yield* Ref.set(modeStateRef, parseSessionModeState(sessionSetupResult)); | ||
| 560 | yield* Ref.set(configOptionsRef, sessionConfigOptionsFromSetup(sessionSetupResult)); | ||
| 561 | yield* Ref.set(toolCallsRef, new Map()); | 1754 | yield* Ref.set(toolCallsRef, new Map()); |
| 562 | yield* Ref.set(assistantSegmentRef, { nextSegmentIndex: 0 }); | 1755 | yield* Ref.set(assistantSegmentRef, { nextSegmentIndex: 0 }); |
| 563 | yield* Ref.set(startStateRef, { _tag: "Started", result: nextState }); | 1756 | yield* Ref.set(startStateRef, { _tag: "Started", result: nextState }); |
| 564 | return nextState; | 1757 | return nextState; |
| 565 | }); | 1758 | }); |
| 566 | 1759 | ||
| 1760 | const runLoadSessionWithReplayIdle = ( | ||
| 1761 | loadPayload: EffectAcpSchema.LoadSessionRequest, | ||
| 1762 | initializeResult: EffectAcpSchema.InitializeResponse, | ||
| 1763 | ): Effect.Effect<EffectAcpSchema.LoadSessionResponse, EffectAcpErrors.AcpError> => | ||
| 1764 | sessionLoadSemaphore.withPermits(1)( | ||
| 1765 | Effect.gen(function* () { | ||
| 1766 | const sessionLoadTimeout = Duration.fromInputUnsafe( | ||
| 1767 | options.sessionLoadTimeout ?? defaultSessionLoadTimeout, | ||
| 1768 | ); | ||
| 1769 | const sessionLoadReplayIdleGap = Duration.fromInputUnsafe( | ||
| 1770 | options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap, | ||
| 1771 | ); | ||
| 1772 | |||
| 1773 | yield* Ref.set( | ||
| 1774 | sessionLoadGateRef, | ||
| 1775 | Option.some({ | ||
| 1776 | active: true, | ||
| 1777 | sessionId: loadPayload.sessionId, | ||
| 1778 | lastActivityAtMillis: undefined, | ||
| 1779 | idleGap: sessionLoadReplayIdleGap, | ||
| 1780 | initializeResult, | ||
| 1781 | }), | ||
| 1782 | ); | ||
| 1783 | |||
| 1784 | return yield* Effect.gen(function* () { | ||
| 1785 | yield* logRequest({ | ||
| 1786 | method: "session/load", | ||
| 1787 | payload: loadPayload, | ||
| 1788 | status: "started", | ||
| 1789 | }); | ||
| 1790 | |||
| 1791 | const idleFiber = yield* waitForSessionLoadReplayIdle({ | ||
| 1792 | gateRef: sessionLoadGateRef, | ||
| 1793 | }).pipe(Effect.forkIn(runtimeScope)); | ||
| 1794 | const loaded = yield* Effect.raceFirst( | ||
| 1795 | acp.agent.loadSession(loadPayload), | ||
| 1796 | Fiber.join(idleFiber), | ||
| 1797 | ).pipe( | ||
| 1798 | Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), | ||
| 1799 | Effect.timeoutOption(sessionLoadTimeout), | ||
| 1800 | Effect.flatMap((result) => | ||
| 1801 | Option.match(result, { | ||
| 1802 | onNone: () => | ||
| 1803 | Effect.fail( | ||
| 1804 | new EffectAcpErrors.AcpTransportError({ | ||
| 1805 | operation: "call-rpc", | ||
| 1806 | method: "session/load", | ||
| 1807 | detail: | ||
| 1808 | "session/load timed out waiting for RPC response or replay idle gap", | ||
| 1809 | cause: undefined, | ||
| 1810 | }), | ||
| 1811 | ), | ||
| 1812 | onSome: Effect.succeed, | ||
| 1813 | }), | ||
| 1814 | ), | ||
| 1815 | Effect.tap((result) => | ||
| 1816 | logRequest({ | ||
| 1817 | method: "session/load", | ||
| 1818 | payload: loadPayload, | ||
| 1819 | status: "succeeded", | ||
| 1820 | result, | ||
| 1821 | }), | ||
| 1822 | ), | ||
| 1823 | Effect.onError((cause) => | ||
| 1824 | logRequest({ | ||
| 1825 | method: "session/load", | ||
| 1826 | payload: loadPayload, | ||
| 1827 | status: "failed", | ||
| 1828 | cause, | ||
| 1829 | }), | ||
| 1830 | ), | ||
| 1831 | ); | ||
| 1832 | |||
| 1833 | return loaded; | ||
| 1834 | }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none()))); | ||
| 1835 | }), | ||
| 1836 | ); | ||
| 1837 | |||
| 567 | const setConfigOption = ( | 1838 | const setConfigOption = ( |
| 568 | configId: string, | 1839 | configId: string, |
| 569 | value: string | boolean, | 1840 | value: string | boolean, |
| @@ -669,27 +1940,9 @@ export const make = ( · 99 unmodified lines | |||
| 669 | cwd: options.cwd, | 1940 | cwd: options.cwd, |
| 670 | mcpServers: options.mcpServers ?? [], | 1941 | mcpServers: options.mcpServers ?? [], |
| 671 | } satisfies EffectAcpSchema.LoadSessionRequest; | 1942 | } satisfies EffectAcpSchema.LoadSessionRequest; |
| 672 | const resumed = yield* runLoggedRequest( | 1943 | |
| 673 | "session/load", | 1944 | sessionId = options.resumeSessionId; |
| 674 | loadPayload, | 1945 | sessionSetupResult = yield* runLoadSessionWithReplayIdle(loadPayload, initializeResult); |
| 675 | acp.agent.loadSession(loadPayload), | ||
| 676 | ).pipe(Effect.exit); | ||
| 677 | if (Exit.isSuccess(resumed)) { | ||
| 678 | sessionId = options.resumeSessionId; | ||
| 679 | sessionSetupResult = resumed.value; | ||
| 680 | } else { | ||
| 681 | const createPayload = { | ||
| 682 | cwd: options.cwd, | ||
| 683 | mcpServers: options.mcpServers ?? [], | ||
| 684 | } satisfies EffectAcpSchema.NewSessionRequest; | ||
| 685 | const created = yield* runLoggedRequest( | ||
| 686 | "session/new", | ||
| 687 | createPayload, | ||
| 688 | acp.agent.createSession(createPayload), | ||
| 689 | ); | ||
| 690 | sessionId = created.sessionId; | ||
| 691 | sessionSetupResult = created; | ||
| 692 | } | ||
| 693 | } else { | 1946 | } else { |
| 694 | const createPayload = { | 1947 | const createPayload = { |
| 695 | cwd: options.cwd, | 1948 | cwd: options.cwd, |
| @@ -760,6 +2013,14 @@ export const make = ( · 64 unmodified lines | |||
| 760 | }); | 2013 | }); |
| 761 | 2014 | ||
| 762 | return { | 2015 | return { |
| 2016 | processContainment: | ||
| 2017 | linuxCgroupLease !== undefined | ||
| 2018 | ? "cgroup-v2" | ||
| 2019 | : options.ownDescendantProcessGroups === true | ||
| 2020 | ? "process-ledger-reduced-guarantee" | ||
| 2021 | : options.ownDetachedProcessGroup === true | ||
| 2022 | ? "process-group" | ||
| 2023 | : "none", | ||
| 763 | handleRequestPermission: acp.handleRequestPermission, | 2024 | handleRequestPermission: acp.handleRequestPermission, |
| 764 | handleElicitation: acp.handleElicitation, | 2025 | handleElicitation: acp.handleElicitation, |
| 765 | handleReadTextFile: acp.handleReadTextFile, | 2026 | handleReadTextFile: acp.handleReadTextFile, |
| @@ -782,17 +2043,13 @@ export const make = ( · 16 unmodified lines | |||
| 782 | getConfigOptions: Ref.get(configOptionsRef), | 2043 | getConfigOptions: Ref.get(configOptionsRef), |
| 783 | loadSession: (sessionId, activationOptions) => | 2044 | loadSession: (sessionId, activationOptions) => |
| 784 | start.pipe( | 2045 | start.pipe( |
| 785 | Effect.flatMap(() => { | 2046 | Effect.flatMap((started) => { |
| 786 | const requestPayload = { | 2047 | const requestPayload = { |
| 787 | sessionId, | 2048 | sessionId, |
| 788 | cwd: options.cwd, | 2049 | cwd: options.cwd, |
| 789 | mcpServers: activationOptions?.mcpServers ?? options.mcpServers ?? [], | 2050 | mcpServers: activationOptions?.mcpServers ?? options.mcpServers ?? [], |
| 790 | } satisfies EffectAcpSchema.LoadSessionRequest; | 2051 | } satisfies EffectAcpSchema.LoadSessionRequest; |
| 791 | return runLoggedRequest( | 2052 | return runLoadSessionWithReplayIdle(requestPayload, started.initializeResult); |
| 792 | "session/load", | ||
| 793 | requestPayload, | ||
| 794 | acp.agent.loadSession(requestPayload), | ||
| 795 | ); | ||
| 796 | }), | 2053 | }), |
| 797 | Effect.flatMap((response) => adoptSession(sessionId, response)), | 2054 | Effect.flatMap((response) => adoptSession(sessionId, response)), |
| 798 | ), | 2055 | ), |
| @@ -914,6 +2171,7 @@ export const make = ( · 115 unmodified lines | |||
| 914 | }), | 2171 | }), |
| 915 | ), | 2172 | ), |
| 916 | ), | 2173 | ), |
| 2174 | ...(options.ownDetachedProcessGroup === true ? { terminateProcessGroup } : {}), | ||
| 917 | setMode: (modeId) => | 2175 | setMode: (modeId) => |
| 918 | Ref.get(modeStateRef).pipe( | 2176 | Ref.get(modeStateRef).pipe( |
| 919 | Effect.flatMap((modeState) => { | 2177 | Effect.flatMap((modeState) => { |
| @@ -5,11 +5,33 @@ import * as EffectAcpErrors from "effect-acp/errors"; · 4 unmodified lines | |||
| 5 | import { | 5 | import { |
| 6 | applyGrokAcpModelSelection, | 6 | applyGrokAcpModelSelection, |
| 7 | buildGrokAcpSpawnInput, | 7 | buildGrokAcpSpawnInput, |
| 8 | grokAcpSpawnArgs, | 8 | grokAcpRuntimeProcessOwnership, |
| 9 | isValidGrokReasoningEffortToken, | ||
| 10 | resolveGrokAcpBaseModelId, | 9 | resolveGrokAcpBaseModelId, |
| 11 | } from "./GrokAcpSupport.ts"; | 10 | } from "./GrokAcpSupport.ts"; |
| 12 | 11 | ||
| 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 | describe("resolveGrokAcpBaseModelId", () => { | 35 | describe("resolveGrokAcpBaseModelId", () => { |
| 14 | it("normalizes empty and custom Grok model ids", () => { | 36 | it("normalizes empty and custom Grok model ids", () => { |
| 15 | expect(resolveGrokAcpBaseModelId(undefined)).toBe("grok-build"); | 37 | expect(resolveGrokAcpBaseModelId(undefined)).toBe("grok-build"); |
| @@ -18,35 +40,6 @@ describe("resolveGrokAcpBaseModelId", () => { · 2 unmodified lines | |||
| 18 | }); | 40 | }); |
| 19 | }); | 41 | }); |
| 20 | 42 | ||
| 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 | describe("buildGrokAcpSpawnInput", () => { | 43 | describe("buildGrokAcpSpawnInput", () => { |
| 51 | it("passes the T3 Code referrer through Grok OAuth env", () => { | 44 | it("passes the T3 Code referrer through Grok OAuth env", () => { |
| 52 | const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", { | 45 | const spawn = buildGrokAcpSpawnInput({ binaryPath: "/usr/local/bin/grok" }, "/tmp/project", { |
| @@ -64,38 +57,15 @@ describe("buildGrokAcpSpawnInput", () => { · 11 unmodified lines | |||
| 64 | }, | 57 | }, |
| 65 | }); | 58 | }); |
| 66 | }); | 59 | }); |
| 67 | |||
| 68 | it("puts Supervised on the Grok argv so config always-approve cannot win", () => { | ||
| 69 | const spawn = buildGrokAcpSpawnInput( | ||
| 70 | { binaryPath: "/usr/local/bin/grok" }, | ||
| 71 | "/tmp/project", | ||
| 72 | undefined, | ||
| 73 | "approval-required", | ||
| 74 | ); | ||
| 75 | expect(spawn.args).toEqual(["--permission-mode", "default", "agent", "stdio"]); | ||
| 76 | }); | ||
| 77 | }); | ||
| 78 | |||
| 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 | describe("applyGrokAcpModelSelection", () => { | 62 | describe("applyGrokAcpModelSelection", () => { |
| 90 | const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { | 63 | const makeRecordingRuntime = (failure?: EffectAcpErrors.AcpError) => { |
| 91 | const modelCalls: Array<{ | 64 | const modelCalls: Array<string> = []; |
| 92 | modelId: string; | ||
| 93 | meta?: { readonly [key: string]: unknown } | null; | ||
| 94 | }> = []; | ||
| 95 | const runtime = { | 65 | const runtime = { |
| 96 | setSessionModel: (modelId: string, meta?: { readonly [key: string]: unknown } | null) => | 66 | setSessionModel: (modelId: string) => |
| 97 | Effect.gen(function* () { | 67 | Effect.gen(function* () { |
| 98 | modelCalls.push(meta === undefined ? { modelId } : { modelId, meta }); | 68 | modelCalls.push(modelId); |
| 99 | if (failure) return yield* failure; | 69 | if (failure) return yield* failure; |
| 100 | return {}; | 70 | return {}; |
| 101 | }), | 71 | }), |
| @@ -112,58 +82,11 @@ describe("applyGrokAcpModelSelection", () => { · 10 unmodified lines | |||
| 112 | requestedModelId: "grok-mock-alt", | 82 | requestedModelId: "grok-mock-alt", |
| 113 | mapError: (cause) => cause.message, | 83 | mapError: (cause) => cause.message, |
| 114 | }); | 84 | }); |
| 115 | expect(modelCalls).toEqual([{ modelId: "grok-mock-alt" }]); | 85 | expect(modelCalls).toEqual(["grok-mock-alt"]); |
| 116 | expect(result).toBe("grok-mock-alt"); | 86 | expect(result).toBe("grok-mock-alt"); |
| 117 | }), | 87 | }), |
| 118 | ); | 88 | ); |
| 119 | 89 | ||
| 120 | it.effect("applies reasoning effort through session/set_model metadata", () => | ||
| 121 | Effect.gen(function* () { | ||
| 122 | const { runtime, modelCalls } = makeRecordingRuntime(); | ||
| 123 | const result = yield* applyGrokAcpModelSelection({ | ||
| 124 | runtime, | ||
| 125 | currentModelId: "grok-4.6", | ||
| 126 | currentReasoningEffort: "high", | ||
| 127 | requestedModelId: "grok-4.6", | ||
| 128 | requestedReasoningEffort: "xhigh", | ||
| 129 | mapError: (cause) => cause.message, | ||
| 130 | }); | ||
| 131 | expect(modelCalls).toEqual([{ modelId: "grok-4.6", meta: { reasoningEffort: "xhigh" } }]); | ||
| 132 | expect(result).toBe("grok-4.6"); | ||
| 133 | }), | ||
| 134 | ); | ||
| 135 | |||
| 136 | it.effect("does not clear reasoning when same-model selection omits effort", () => | ||
| 137 | Effect.gen(function* () { | ||
| 138 | const { runtime, modelCalls } = makeRecordingRuntime(); | ||
| 139 | const result = yield* applyGrokAcpModelSelection({ | ||
| 140 | runtime, | ||
| 141 | currentModelId: "grok-4.6", | ||
| 142 | currentReasoningEffort: "high", | ||
| 143 | requestedModelId: "grok-4.6", | ||
| 144 | requestedReasoningEffort: undefined, | ||
| 145 | mapError: (cause) => cause.message, | ||
| 146 | }); | ||
| 147 | expect(modelCalls).toEqual([]); | ||
| 148 | expect(result).toBe("grok-4.6"); | ||
| 149 | }), | ||
| 150 | ); | ||
| 151 | |||
| 152 | it.effect("drops malformed effort metadata instead of sending it", () => | ||
| 153 | Effect.gen(function* () { | ||
| 154 | const { runtime, modelCalls } = makeRecordingRuntime(); | ||
| 155 | yield* applyGrokAcpModelSelection({ | ||
| 156 | runtime, | ||
| 157 | currentModelId: "grok-4.6", | ||
| 158 | currentReasoningEffort: "high", | ||
| 159 | requestedModelId: "grok-4.6", | ||
| 160 | requestedReasoningEffort: "not a token", | ||
| 161 | mapError: (cause) => cause.message, | ||
| 162 | }); | ||
| 163 | expect(modelCalls).toEqual([{ modelId: "grok-4.6" }]); | ||
| 164 | }), | ||
| 165 | ); | ||
| 166 | |||
| 167 | it.effect("skips set_model when requested matches current", () => | 90 | it.effect("skips set_model when requested matches current", () => |
| 168 | Effect.gen(function* () { | 91 | Effect.gen(function* () { |
| 169 | const { runtime, modelCalls } = makeRecordingRuntime(); | 92 | const { runtime, modelCalls } = makeRecordingRuntime(); |
| @@ -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 | import * as Crypto from "effect/Crypto"; | 3 | import * as Crypto from "effect/Crypto"; |
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| @@ -6,6 +7,7 @@ import * as Scope from "effect/Scope"; · 1 unmodified line | |||
| 6 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | 7 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 7 | import * as EffectAcpErrors from "effect-acp/errors"; | 8 | import * as EffectAcpErrors from "effect-acp/errors"; |
| 8 | import type * as EffectAcpSchema from "effect-acp/schema"; | 9 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 10 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 9 | import { normalizeModelSlug } from "@t3tools/shared/model"; | 11 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 10 | 12 | ||
| 11 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 13 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| @@ -24,33 +26,16 @@ interface GrokAcpRuntimeInput extends Omit<AcpSessionRuntimeOptions, "authMethod · 12 unmodified lines | |||
| 24 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; | 26 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; |
| 25 | readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined; | 27 | readonly grokSettings: GrokAcpRuntimeGrokSettings | null | undefined; |
| 26 | readonly environment?: NodeJS.ProcessEnv; | 28 | readonly environment?: NodeJS.ProcessEnv; |
| 27 | readonly runtimeMode?: RuntimeMode; | ||
| 28 | } | ||
| 29 | |||
| 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 | export function buildGrokAcpSpawnInput( | 31 | export function buildGrokAcpSpawnInput( |
| 46 | grokSettings: GrokAcpRuntimeGrokSettings | null | undefined, | 32 | grokSettings: GrokAcpRuntimeGrokSettings | null | undefined, |
| 47 | cwd: string, | 33 | cwd: string, |
| 48 | environment?: NodeJS.ProcessEnv, | 34 | environment?: NodeJS.ProcessEnv, |
| 49 | runtimeMode?: RuntimeMode, | ||
| 50 | ): AcpSessionRuntime.AcpSpawnInput { | 35 | ): AcpSessionRuntime.AcpSpawnInput { |
| 51 | return { | 36 | return { |
| 52 | command: grokSettings?.binaryPath || "grok", | 37 | command: grokSettings?.binaryPath || "grok", |
| 53 | args: [...grokAcpSpawnArgs(runtimeMode)], | 38 | args: ["agent", "stdio"], |
| 54 | cwd, | 39 | cwd, |
| 55 | env: { | 40 | env: { |
| 56 | ...environment, | 41 | ...environment, |
| @@ -65,6 +50,21 @@ function resolveGrokAuthMethodId(environment: NodeJS.ProcessEnv | undefined): st · 8 unmodified lines | |||
| 65 | : GROK_AUTH_METHOD_CACHED_TOKEN; | 50 | : GROK_AUTH_METHOD_CACHED_TOKEN; |
| 66 | } | 51 | } |
| 67 | 52 | ||
| 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 | export const makeGrokAcpRuntime = ( | 68 | export const makeGrokAcpRuntime = ( |
| 69 | input: GrokAcpRuntimeInput, | 69 | input: GrokAcpRuntimeInput, |
| 70 | ): Effect.Effect< | 70 | ): Effect.Effect< |
| @@ -73,16 +73,15 @@ export const makeGrokAcpRuntime = ( · 2 unmodified lines | |||
| 73 | Crypto.Crypto | Scope.Scope | 73 | Crypto.Crypto | Scope.Scope |
| 74 | > => | 74 | > => |
| 75 | Effect.gen(function* () { | 75 | Effect.gen(function* () { |
| 76 | const processGroupPlatform = yield* HostProcessPlatform.pipe( | ||
| 77 | Effect.provide(NodeServices.layer), | ||
| 78 | ); | ||
| 76 | const acpContext = yield* Layer.build( | 79 | const acpContext = yield* Layer.build( |
| 77 | AcpSessionRuntime.layer({ | 80 | AcpSessionRuntime.layer({ |
| 78 | ...input, | 81 | ...input, |
| 79 | spawn: buildGrokAcpSpawnInput( | 82 | spawn: buildGrokAcpSpawnInput(input.grokSettings, input.cwd, input.environment), |
| 80 | input.grokSettings, | ||
| 81 | input.cwd, | ||
| 82 | input.environment, | ||
| 83 | input.runtimeMode, | ||
| 84 | ), | ||
| 85 | authMethodId: resolveGrokAuthMethodId(input.environment), | 83 | authMethodId: resolveGrokAuthMethodId(input.environment), |
| 84 | ...grokAcpRuntimeProcessOwnership(processGroupPlatform), | ||
| 86 | }).pipe( | 85 | }).pipe( |
| 87 | Layer.provide( | 86 | Layer.provide( |
| 88 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), | 87 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), |
| @@ -107,17 +106,6 @@ export function resolveGrokAcpBaseModelId(model: string | null | undefined): str · 18 unmodified lines | |||
| 107 | return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? GROK_DEFAULT_MODEL_SLUG; | 106 | return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? GROK_DEFAULT_MODEL_SLUG; |
| 108 | } | 107 | } |
| 109 | 108 | ||
| 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 | export function currentGrokModelIdFromSessionSetup( | 109 | export function currentGrokModelIdFromSessionSetup( |
| 122 | sessionSetupResult: | 110 | sessionSetupResult: |
| 123 | | EffectAcpSchema.LoadSessionResponse | 111 | | EffectAcpSchema.LoadSessionResponse |
| @@ -127,59 +115,18 @@ export function currentGrokModelIdFromSessionSetup( · 3 unmodified lines | |||
| 127 | return sessionSetupResult.models?.currentModelId?.trim() || undefined; | 115 | return sessionSetupResult.models?.currentModelId?.trim() || undefined; |
| 128 | } | 116 | } |
| 129 | 117 | ||
| 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 | export function applyGrokAcpModelSelection<E>(input: { | 118 | export function applyGrokAcpModelSelection<E>(input: { |
| 154 | readonly runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "setSessionModel">; | 119 | readonly runtime: Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "setSessionModel">; |
| 155 | readonly currentModelId: string | undefined; | 120 | readonly currentModelId: string | undefined; |
| 156 | readonly currentReasoningEffort?: string | undefined; | ||
| 157 | readonly requestedModelId: string | undefined; | 121 | readonly requestedModelId: string | undefined; |
| 158 | readonly requestedReasoningEffort?: string | undefined; | ||
| 159 | readonly mapError: (cause: EffectAcpErrors.AcpError) => E; | 122 | readonly mapError: (cause: EffectAcpErrors.AcpError) => E; |
| 160 | }): Effect.Effect<string | undefined, E> { | 123 | }): Effect.Effect<string | undefined, E> { |
| 161 | // The product slug is never sent over the wire; it keeps the session's current model. | 124 | const shouldSwitchModel = |
| 162 | const requestedModelId = | 125 | input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; |
| 163 | input.requestedModelId === GROK_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId; | 126 | if (!shouldSwitchModel) { |
| 164 | const modelChanged = requestedModelId !== undefined && requestedModelId !== input.currentModelId; | ||
| 165 | const reasoningProvided = input.requestedReasoningEffort !== undefined; | ||
| 166 | const reasoningEffort = reasoningProvided | ||
| 167 | ? normalizeGrokReasoningEffort(input.requestedReasoningEffort) | ||
| 168 | : undefined; | ||
| 169 | const reasoningEffortChanged = | ||
| 170 | reasoningProvided && reasoningEffort !== input.currentReasoningEffort; | ||
| 171 | const targetModelId = requestedModelId ?? input.currentModelId; | ||
| 172 | if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) { | ||
| 173 | return Effect.succeed(input.currentModelId); | 127 | return Effect.succeed(input.currentModelId); |
| 174 | } | 128 | } |
| 175 | const reasoningMeta = | ||
| 176 | reasoningProvided && reasoningEffort !== undefined ? { reasoningEffort } : undefined; | ||
| 177 | // When reasoning was explicitly provided but invalid (normalize => undefined), we deliberately | ||
| 178 | // send no meta so the invalid value is dropped rather than forwarded. When reasoning was not | ||
| 179 | // provided at all, we also send no meta, but we only reach this call when the model itself | ||
| 180 | // changed - an omitted reasoning preference must not be treated as an explicit clear of the | ||
| 181 | // CLI-advertised default (e.g. Extra High) on same-model reselections. | ||
| 182 | return input.runtime | 129 | return input.runtime |
| 183 | .setSessionModel(targetModelId, reasoningMeta) | 130 | .setSessionModel(input.requestedModelId) |
| 184 | .pipe(Effect.mapError(input.mapError), Effect.as(targetModelId)); | 131 | .pipe(Effect.mapError(input.mapError), Effect.as(input.requestedModelId)); |
| 185 | } | 132 | } |
| @@ -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 | import { it } from "@effect/vitest"; | 1 | import { it } from "@effect/vitest"; |
| 2 | import * as Deferred from "effect/Deferred"; | ||
| 7 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 8 | import * as Schema from "effect/Schema"; | 5 | import * as Schema from "effect/Schema"; |
| 9 | import { describe, expect } from "vite-plus/test"; | 6 | import { describe, expect } from "vite-plus/test"; |
| 10 | 7 | ||
| 11 | import { | 8 | import { |
| 12 | extractXAiAskUserQuestions, | 9 | extractXAiAcpBackgroundToolMutation, |
| 10 | extractXAiAcpSubagentEndNotice, | ||
| 13 | extractXAiAcpSubagentUpdate, | 11 | extractXAiAcpSubagentUpdate, |
| 12 | extractXAiAskUserQuestions, | ||
| 13 | extractXAiBackgroundTaskCompletion, | ||
| 14 | extractXAiKilledBackgroundTasks, | ||
| 15 | extractXAiMonitorTaskId, | ||
| 16 | isGenericAcpToolTitle, | ||
| 17 | isXAiMonitorTool, | ||
| 18 | isXAiPersistentMonitor, | ||
| 14 | makeXAiAskUserQuestionCancelledResponse, | 19 | makeXAiAskUserQuestionCancelledResponse, |
| 15 | makeXAiAskUserQuestionResponse, | 20 | makeXAiAskUserQuestionResponse, |
| 16 | makeXAiPromptCompletionRuntime, | 21 | makeXAiPromptCompletionRuntime, |
| 22 | normalizeXAiAcpToolCallState, | ||
| 23 | resolveXAiAcpToolTitle, | ||
| 24 | xAiBackgroundTaskLifecycleMutation, | ||
| 25 | xAiPromptCompleteFromSessionUpdate, | ||
| 17 | XAiAskUserQuestionRequest, | 26 | XAiAskUserQuestionRequest, |
| 18 | } from "./XAiAcpExtension.ts"; | 27 | } from "./XAiAcpExtension.ts"; |
| 19 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 28 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| 20 | 29 | ||
| 21 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | 30 | const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); |
| 22 | const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); | ||
| 23 | 31 | ||
| 24 | const makePromptCompletionRuntime = (env: NodeJS.ProcessEnv) => | 32 | describe("xAiPromptCompleteFromSessionUpdate", () => { |
| 25 | Effect.gen(function* () { | 33 | it("maps live turn_completed snake_case payloads", () => { |
| 26 | const runtime = yield* AcpSessionRuntime.make({ | 34 | expect( |
| 27 | spawn: { | 35 | xAiPromptCompleteFromSessionUpdate({ |
| 28 | command: process.execPath, | 36 | sessionId: "019f4428-4bf1-7e52-b7c6-c29b506543b1", |
| 29 | args: [mockAgentPath], | 37 | update: { |
| 30 | env, | 38 | sessionUpdate: "turn_completed", |
| 31 | }, | 39 | prompt_id: "t3-xai-prompt-1", |
| 32 | cwd: process.cwd(), | 40 | stop_reason: "end_turn", |
| 33 | clientInfo: { name: "t3-test", version: "0.0.0" }, | 41 | }, |
| 34 | authMethodId: "test", | 42 | }), |
| 43 | ).toEqual({ | ||
| 44 | sessionId: "019f4428-4bf1-7e52-b7c6-c29b506543b1", | ||
| 45 | promptId: "t3-xai-prompt-1", | ||
| 46 | stopReason: "end_turn", | ||
| 35 | }); | 47 | }); |
| 36 | return yield* makeXAiPromptCompletionRuntime(runtime); | ||
| 37 | }); | 48 | }); |
| 38 | 49 | ||
| 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 | describe("XAiAcpExtension", () => { | 76 | describe("XAiAcpExtension", () => { |
| 42 | it("recognizes Grok Task starts as native subagents", () => { | 77 | it("recognizes Grok Task starts as native subagents", () => { |
| @@ -62,6 +97,7 @@ describe("XAiAcpExtension", () => { · 19 unmodified lines | |||
| 62 | status: "running", | 97 | status: "running", |
| 63 | childSessionId: null, | 98 | childSessionId: null, |
| 64 | result: null, | 99 | result: null, |
| 100 | suppressNormalTool: true, | ||
| 65 | }); | 101 | }); |
| 66 | }); | 102 | }); |
| 67 | 103 | ||
| @@ -95,6 +131,792 @@ describe("XAiAcpExtension", () => { · 27 unmodified lines | |||
| 95 | status: "completed", | 131 | status: "completed", |
| 96 | childSessionId: "019f0220-e192-7c41-9e9d-b406bc3459c8", | 132 | childSessionId: "019f0220-e192-7c41-9e9d-b406bc3459c8", |
| 97 | result: "Server audit complete.", | 133 | result: "Server audit complete.", |
| 134 | suppressNormalTool: true, | ||
| 135 | }); | ||
| 136 | }); | ||
| 137 | |||
| 138 | it("keeps async spawn_subagent ACKs running and binds subagent_id", () => { | ||
| 139 | expect( | ||
| 140 | extractXAiAcpSubagentUpdate({ | ||
| 141 | toolCallId: "call-spawn-1", | ||
| 142 | title: "spawn_subagent", | ||
| 143 | status: "completed", | ||
| 144 | data: { | ||
| 145 | rawInput: { | ||
| 146 | description: "Demo subagent wait and ls", | ||
| 147 | prompt: "Wait then ls.", | ||
| 148 | subagent_type: "general-purpose", | ||
| 149 | }, | ||
| 150 | rawOutput: { | ||
| 151 | type: "Text", | ||
| 152 | text: [ | ||
| 153 | "Subagent started in background.", | ||
| 154 | "subagent_id: 019f44a6-4820-7402-925d-bc862ee711dd", | ||
| 155 | "type: general-purpose", | ||
| 156 | "description: Demo subagent wait and ls", | ||
| 157 | "", | ||
| 158 | 'Use get_command_or_subagent_output with task_ids=["019f44a6-4820-7402-925d-bc862ee711dd"] and timeout_ms to wait for results.', | ||
| 159 | ].join("\n"), | ||
| 160 | }, | ||
| 161 | }, | ||
| 162 | }), | ||
| 163 | ).toEqual({ | ||
| 164 | nativeTaskId: "call-spawn-1", | ||
| 165 | prompt: "Wait then ls.", | ||
| 166 | title: "Demo subagent wait and ls", | ||
| 167 | model: null, | ||
| 168 | status: "running", | ||
| 169 | childSessionId: "019f44a6-4820-7402-925d-bc862ee711dd", | ||
| 170 | result: null, | ||
| 171 | suppressNormalTool: true, | ||
| 172 | }); | ||
| 173 | }); | ||
| 174 | |||
| 175 | it("hydrates subagent completion from get_command_or_subagent_output", () => { | ||
| 176 | expect( | ||
| 177 | extractXAiAcpSubagentUpdate({ | ||
| 178 | toolCallId: "call-get-1", | ||
| 179 | title: "get_command_or_subagent_output", | ||
| 180 | status: "completed", | ||
| 181 | data: { | ||
| 182 | rawInput: { | ||
| 183 | task_ids: ["019f44a6-4820-7402-925d-bc862ee711dd"], | ||
| 184 | timeout_ms: 30000, | ||
| 185 | }, | ||
| 186 | rawOutput: { | ||
| 187 | type: "Text", | ||
| 188 | text: [ | ||
| 189 | "=== Task 019f44a6-4820-7402-925d-bc862ee711dd ===", | ||
| 190 | "Status: completed", | ||
| 191 | "Exit Code: 0", | ||
| 192 | "", | ||
| 193 | "=== Output ===", | ||
| 194 | "SUBAGENT_MARKER: 35 entries", | ||
| 195 | ].join("\n"), | ||
| 196 | }, | ||
| 197 | }, | ||
| 198 | }), | ||
| 199 | ).toEqual({ | ||
| 200 | nativeTaskId: "call-get-1", | ||
| 201 | prompt: "", | ||
| 202 | title: null, | ||
| 203 | model: null, | ||
| 204 | status: "completed", | ||
| 205 | childSessionId: "019f44a6-4820-7402-925d-bc862ee711dd", | ||
| 206 | result: "SUBAGENT_MARKER: 35 entries", | ||
| 207 | suppressNormalTool: true, | ||
| 208 | }); | ||
| 209 | }); | ||
| 210 | |||
| 211 | it("attributes multi-task get_output envelopes to the result task_id", () => { | ||
| 212 | expect( | ||
| 213 | extractXAiAcpSubagentUpdate({ | ||
| 214 | toolCallId: "call-get-multi", | ||
| 215 | title: "get_command_or_subagent_output", | ||
| 216 | status: "completed", | ||
| 217 | data: { | ||
| 218 | rawInput: { | ||
| 219 | task_ids: [ | ||
| 220 | "019f44a6-4820-7402-925d-bc862ee711dd", | ||
| 221 | "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | ||
| 222 | ], | ||
| 223 | timeout_ms: 30000, | ||
| 224 | }, | ||
| 225 | rawOutput: { | ||
| 226 | type: "TaskOutput", | ||
| 227 | Result: { | ||
| 228 | task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | ||
| 229 | status: "completed", | ||
| 230 | exit_code: 0, | ||
| 231 | output: "SECOND_SUB_DONE\n", | ||
| 232 | }, | ||
| 233 | }, | ||
| 234 | }, | ||
| 235 | }), | ||
| 236 | ).toMatchObject({ | ||
| 237 | childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | ||
| 238 | status: "completed", | ||
| 239 | result: "SECOND_SUB_DONE", | ||
| 240 | }); | ||
| 241 | }); | ||
| 242 | |||
| 243 | it("hydrates structured ACP TaskOutput tool envelopes", () => { | ||
| 244 | expect( | ||
| 245 | extractXAiAcpSubagentUpdate({ | ||
| 246 | toolCallId: "call-get-2", | ||
| 247 | title: "[subagent:general-purpose] Sleep then return SUBAGENT_DONE (019f44b0)", | ||
| 248 | status: "completed", | ||
| 249 | data: { | ||
| 250 | rawInput: { | ||
| 251 | variant: "TaskOutput", | ||
| 252 | task_ids: ["019f44b0-1b73-7f32-bb1b-1ff696f536e3"], | ||
| 253 | timeout_ms: 20000, | ||
| 254 | }, | ||
| 255 | rawOutput: { | ||
| 256 | type: "TaskOutput", | ||
| 257 | Result: { | ||
| 258 | task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | ||
| 259 | status: "completed", | ||
| 260 | exit_code: 0, | ||
| 261 | output: | ||
| 262 | "SUBAGENT_DONE\n\n<subagent_meta>id=019f44b0-1b73-7f32-bb1b-1ff696f536e3</subagent_meta>\n", | ||
| 263 | }, | ||
| 264 | }, | ||
| 265 | }, | ||
| 266 | }), | ||
| 267 | ).toEqual({ | ||
| 268 | nativeTaskId: "call-get-2", | ||
| 269 | prompt: "", | ||
| 270 | title: null, | ||
| 271 | model: null, | ||
| 272 | status: "completed", | ||
| 273 | childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | ||
| 274 | result: "SUBAGENT_DONE", | ||
| 275 | suppressNormalTool: true, | ||
| 276 | }); | ||
| 277 | }); | ||
| 278 | |||
| 279 | it("does not leak raw machine tags when get_output is only meta/result blocks", () => { | ||
| 280 | const metaOnlyToolCall = { | ||
| 281 | toolCallId: "call-get-meta-only", | ||
| 282 | title: "get_command_or_subagent_output", | ||
| 283 | status: "completed" as const, | ||
| 284 | data: { | ||
| 285 | rawInput: { | ||
| 286 | variant: "TaskOutput", | ||
| 287 | task_ids: ["019f44b0-1b73-7f32-bb1b-1ff696f536e3"], | ||
| 288 | timeout_ms: 20000, | ||
| 289 | }, | ||
| 290 | rawOutput: { | ||
| 291 | type: "TaskOutput", | ||
| 292 | Result: { | ||
| 293 | task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | ||
| 294 | status: "completed", | ||
| 295 | exit_code: 0, | ||
| 296 | output: | ||
| 297 | "<subagent_meta>id=019f44b0-1b73-7f32-bb1b-1ff696f536e3</subagent_meta>\n<subagent_result>done</subagent_result>\n", | ||
| 298 | }, | ||
| 299 | }, | ||
| 300 | }, | ||
| 301 | }; | ||
| 302 | expect(extractXAiAcpSubagentUpdate(metaOnlyToolCall)).toEqual({ | ||
| 303 | nativeTaskId: "call-get-meta-only", | ||
| 304 | prompt: "", | ||
| 305 | title: null, | ||
| 306 | model: null, | ||
| 307 | status: "completed", | ||
| 308 | childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | ||
| 309 | result: null, | ||
| 310 | suppressNormalTool: true, | ||
| 311 | }); | ||
| 312 | expect(extractXAiBackgroundTaskCompletion(metaOnlyToolCall)).toEqual([ | ||
| 313 | { | ||
| 314 | taskId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | ||
| 315 | status: "completed", | ||
| 316 | appendOutput: "", | ||
| 317 | }, | ||
| 318 | ]); | ||
| 319 | }); | ||
| 320 | |||
| 321 | it("keeps monitor start ACKs running and extracts task ids", () => { | ||
| 322 | const toolCall = { | ||
| 323 | toolCallId: "call-mon-1", | ||
| 324 | title: "monitor", | ||
| 325 | status: "completed" as const, | ||
| 326 | data: { | ||
| 327 | rawInput: { | ||
| 328 | command: "echo mon_line_1", | ||
| 329 | description: "Demo monitor", | ||
| 330 | }, | ||
| 331 | rawOutput: { | ||
| 332 | type: "Text", | ||
| 333 | text: "Monitor started (task 019f44a5-87d1-7640-8e35-6a4667ffc873, timeout 36000000ms).\nYou will be notified on each event.", | ||
| 334 | }, | ||
| 335 | }, | ||
| 336 | }; | ||
| 337 | expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress"); | ||
| 338 | expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873"); | ||
| 339 | }); | ||
| 340 | |||
| 341 | it("completes Monitor variant tools from structured Bash exit codes", () => { | ||
| 342 | const toolCall = { | ||
| 343 | toolCallId: "call-mon-2", | ||
| 344 | title: "Tool", | ||
| 345 | status: "inProgress" as const, | ||
| 346 | data: { | ||
| 347 | rawInput: { | ||
| 348 | variant: "Monitor", | ||
| 349 | command: "echo MON_DONE", | ||
| 350 | description: "Stream mon lines", | ||
| 351 | }, | ||
| 352 | rawOutput: { | ||
| 353 | type: "Bash", | ||
| 354 | output: Array.from(new TextEncoder().encode("mon_line_1\nMON_DONE\n")), | ||
| 355 | output_for_prompt: "mon_line_1\nMON_DONE\n", | ||
| 356 | exit_code: 0, | ||
| 357 | }, | ||
| 358 | }, | ||
| 359 | }; | ||
| 360 | expect(isXAiMonitorTool(toolCall)).toBe(true); | ||
| 361 | expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("completed"); | ||
| 362 | }); | ||
| 363 | |||
| 364 | it("detects Monitor start ACKs from structured rawOutput when title is generic", () => { | ||
| 365 | const toolCall = { | ||
| 366 | toolCallId: "call-mon-generic-title", | ||
| 367 | title: "Tool", | ||
| 368 | status: "completed" as const, | ||
| 369 | data: { | ||
| 370 | rawInput: { | ||
| 371 | command: "echo mon_line", | ||
| 372 | description: "Demo monitor without variant", | ||
| 373 | }, | ||
| 374 | rawOutput: { | ||
| 375 | type: "Monitor", | ||
| 376 | taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", | ||
| 377 | timeoutMs: 36000000, | ||
| 378 | persistent: false, | ||
| 379 | }, | ||
| 380 | }, | ||
| 381 | }; | ||
| 382 | expect(isXAiMonitorTool(toolCall)).toBe(true); | ||
| 383 | expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress"); | ||
| 384 | expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873"); | ||
| 385 | }); | ||
| 386 | |||
| 387 | it("completes wake re-reports of finished commands despite generic titles", () => { | ||
| 388 | // Post-settle wake replay: the finished monitor is re-reported with empty | ||
| 389 | // rawInput and a generic title, so monitor detection cannot match; the | ||
| 390 | // structured Bash result with exit_code must still terminalize it. | ||
| 391 | const toolCall = { | ||
| 392 | toolCallId: "call-wake-1", | ||
| 393 | title: "Tool", | ||
| 394 | status: "inProgress" as const, | ||
| 395 | data: { | ||
| 396 | rawInput: {}, | ||
| 397 | rawOutput: { | ||
| 398 | type: "Bash", | ||
| 399 | output: Array.from(new TextEncoder().encode("STREAM_1\nSTREAM_DONE\n")), | ||
| 400 | output_for_prompt: "STREAM_1\nSTREAM_DONE\n", | ||
| 401 | exit_code: 0, | ||
| 402 | command: "for i in 1 2; do echo STREAM_$i; done; echo STREAM_DONE", | ||
| 403 | }, | ||
| 404 | }, | ||
| 405 | }; | ||
| 406 | expect(isXAiMonitorTool(toolCall)).toBe(false); | ||
| 407 | const normalized = normalizeXAiAcpToolCallState(toolCall); | ||
| 408 | expect(normalized.status).toBe("completed"); | ||
| 409 | expect(normalized.title).toBe("for i in 1 2; do echo STREAM_$i; done; echo STREAM_DONE"); | ||
| 410 | expect( | ||
| 411 | normalizeXAiAcpToolCallState({ | ||
| 412 | ...toolCall, | ||
| 413 | data: { | ||
| 414 | ...toolCall.data, | ||
| 415 | rawOutput: { ...toolCall.data.rawOutput, exit_code: 2 }, | ||
| 416 | }, | ||
| 417 | }).status, | ||
| 418 | ).toBe("failed"); | ||
| 419 | }); | ||
| 420 | |||
| 421 | it("keeps empty successful Bash updates running until native output or turn settlement", () => { | ||
| 422 | const normalized = normalizeXAiAcpToolCallState({ | ||
| 423 | toolCallId: "call-bash-permission-ack", | ||
| 424 | title: "Ran command", | ||
| 425 | status: "completed", | ||
| 426 | data: { | ||
| 427 | rawInput: { command: "sleep 40" }, | ||
| 428 | rawOutput: { type: "Bash", exit_code: 0 }, | ||
| 429 | }, | ||
| 430 | }); | ||
| 431 | expect(normalized.status).toBe("inProgress"); | ||
| 432 | }); | ||
| 433 | |||
| 434 | it("keeps structured Monitor start ACKs running and extracts taskId", () => { | ||
| 435 | const toolCall = { | ||
| 436 | toolCallId: "call-mon-3", | ||
| 437 | title: "Start monitor: Wait 30s then list directory", | ||
| 438 | status: "completed" as const, | ||
| 439 | data: { | ||
| 440 | rawInput: { | ||
| 441 | variant: "Monitor", | ||
| 442 | command: "sleep 30 && ls", | ||
| 443 | description: "Wait 30s then list directory", | ||
| 444 | }, | ||
| 445 | rawOutput: { | ||
| 446 | type: "Monitor", | ||
| 447 | taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", | ||
| 448 | timeoutMs: 36000000, | ||
| 449 | persistent: false, | ||
| 450 | }, | ||
| 451 | }, | ||
| 452 | }; | ||
| 453 | expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress"); | ||
| 454 | expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44b8-8e98-7c80-a40e-df1e26a5f9e3"); | ||
| 455 | }); | ||
| 456 | |||
| 457 | it("replaces generic ACP titles with description / Monitor labels", () => { | ||
| 458 | expect(isGenericAcpToolTitle("Tool")).toBe(true); | ||
| 459 | expect(isGenericAcpToolTitle("Read package.json")).toBe(false); | ||
| 460 | expect( | ||
| 461 | resolveXAiAcpToolTitle({ | ||
| 462 | toolCallId: "call-mon-title", | ||
| 463 | title: "Tool", | ||
| 464 | status: "completed", | ||
| 465 | data: { | ||
| 466 | rawInput: { | ||
| 467 | variant: "Monitor", | ||
| 468 | command: "sleep 30 && ls", | ||
| 469 | description: "Wait 30s then list directory", | ||
| 470 | }, | ||
| 471 | }, | ||
| 472 | }), | ||
| 473 | ).toBe("Monitor: Wait 30s then list directory"); | ||
| 474 | const normalized = normalizeXAiAcpToolCallState({ | ||
| 475 | toolCallId: "call-mon-title", | ||
| 476 | title: "Tool", | ||
| 477 | status: "completed", | ||
| 478 | data: { | ||
| 479 | rawInput: { | ||
| 480 | variant: "Monitor", | ||
| 481 | command: "sleep 30 && ls", | ||
| 482 | description: "Wait 30s then list directory", | ||
| 483 | }, | ||
| 484 | rawOutput: { | ||
| 485 | type: "Monitor", | ||
| 486 | taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", | ||
| 487 | timeoutMs: 36000000, | ||
| 488 | persistent: false, | ||
| 489 | }, | ||
| 490 | }, | ||
| 491 | }); | ||
| 492 | expect(normalized.title).toBe("Monitor: Wait 30s then list directory"); | ||
| 493 | expect(normalized.status).toBe("inProgress"); | ||
| 494 | // Non-generic titles from the CLI are preserved. | ||
| 495 | expect( | ||
| 496 | resolveXAiAcpToolTitle({ | ||
| 497 | toolCallId: "call-read", | ||
| 498 | title: "Read package.json", | ||
| 499 | status: "inProgress", | ||
| 500 | data: { rawInput: { path: "package.json" } }, | ||
| 501 | }), | ||
| 502 | ).toBe("Read package.json"); | ||
| 503 | }); | ||
| 504 | |||
| 505 | it("hydrates monitor completion from TaskOutput get_command envelopes", () => { | ||
| 506 | expect( | ||
| 507 | extractXAiBackgroundTaskCompletion({ | ||
| 508 | toolCallId: "call-get-mon", | ||
| 509 | title: "Wait 30s then list directory", | ||
| 510 | status: "completed", | ||
| 511 | data: { | ||
| 512 | rawInput: { | ||
| 513 | variant: "TaskOutput", | ||
| 514 | task_ids: ["019f44b8-8e98-7c80-a40e-df1e26a5f9e3"], | ||
| 515 | }, | ||
| 516 | rawOutput: { | ||
| 517 | type: "TaskOutput", | ||
| 518 | Result: { | ||
| 519 | task_id: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", | ||
| 520 | command: "[monitor] Wait 30s then list directory", | ||
| 521 | status: "completed", | ||
| 522 | exit_code: 0, | ||
| 523 | output: "agents\nAGENTS.md\nnotes\n", | ||
| 524 | }, | ||
| 525 | }, | ||
| 526 | }, | ||
| 527 | }), | ||
| 528 | ).toEqual([ | ||
| 529 | { | ||
| 530 | taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", | ||
| 531 | status: "completed", | ||
| 532 | appendOutput: "agents\nAGENTS.md\nnotes", | ||
| 533 | }, | ||
| 534 | ]); | ||
| 535 | }); | ||
| 536 | |||
| 537 | it("hydrates monitor completion from a standalone TaskOutput frame without rawInput", () => { | ||
| 538 | // Post-settle shape observed live (2026-07-12): the final | ||
| 539 | // get_command_or_subagent_output update carries only rawOutput; the frame | ||
| 540 | // is parsed in isolation (no in-turn merge), and this completion is the | ||
| 541 | // ONLY end signal when the agent consumed the output itself (no "Monitor | ||
| 542 | // ended" reminder follows). | ||
| 543 | expect( | ||
| 544 | extractXAiBackgroundTaskCompletion({ | ||
| 545 | toolCallId: "call-c29e64dd-ce5d-4eac-a6ca-c20513fefac3-1", | ||
| 546 | title: "[monitor] Stream numbered markers every 3s (019f54a0)", | ||
| 547 | status: "completed", | ||
| 548 | data: { | ||
| 549 | rawOutput: { | ||
| 550 | type: "TaskOutput", | ||
| 551 | Result: { | ||
| 552 | task_id: "019f54a0-06a8-77f2-8214-e24937cad564", | ||
| 553 | command: "[monitor] Stream numbered markers every 3s", | ||
| 554 | status: "completed", | ||
| 555 | exit_code: 0, | ||
| 556 | output: "STREAM_1\nSTREAM_DONE\n", | ||
| 557 | }, | ||
| 558 | }, | ||
| 559 | }, | ||
| 560 | }), | ||
| 561 | ).toMatchObject([ | ||
| 562 | { | ||
| 563 | taskId: "019f54a0-06a8-77f2-8214-e24937cad564", | ||
| 564 | status: "completed", | ||
| 565 | }, | ||
| 566 | ]); | ||
| 567 | }); | ||
| 568 | |||
| 569 | it("hydrates every requested monitor when TaskOutput has no result task_id", () => { | ||
| 570 | expect( | ||
| 571 | extractXAiBackgroundTaskCompletion({ | ||
| 572 | toolCallId: "call-get-multi-mon", | ||
| 573 | title: "get_command_or_subagent_output", | ||
| 574 | status: "completed", | ||
| 575 | data: { | ||
| 576 | rawInput: { | ||
| 577 | variant: "TaskOutput", | ||
| 578 | task_ids: [ | ||
| 579 | "019f44a6-4820-7402-925d-bc862ee711dd", | ||
| 580 | "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | ||
| 581 | ], | ||
| 582 | }, | ||
| 583 | rawOutput: { | ||
| 584 | type: "TaskOutput", | ||
| 585 | Result: { | ||
| 586 | status: "completed", | ||
| 587 | exit_code: 0, | ||
| 588 | output: "BOTH_DONE\n", | ||
| 589 | }, | ||
| 590 | }, | ||
| 591 | }, | ||
| 592 | }), | ||
| 593 | ).toEqual([ | ||
| 594 | { | ||
| 595 | taskId: "019f44a6-4820-7402-925d-bc862ee711dd", | ||
| 596 | status: "completed", | ||
| 597 | appendOutput: "BOTH_DONE", | ||
| 598 | }, | ||
| 599 | { | ||
| 600 | taskId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", | ||
| 601 | status: "completed", | ||
| 602 | appendOutput: "BOTH_DONE", | ||
| 603 | }, | ||
| 604 | ]); | ||
| 605 | }); | ||
| 606 | |||
| 607 | it("treats Exit Code: -1 text envelopes as failed", () => { | ||
| 608 | expect( | ||
| 609 | extractXAiBackgroundTaskCompletion({ | ||
| 610 | toolCallId: "call-get-neg", | ||
| 611 | title: "get_command_or_subagent_output", | ||
| 612 | status: "completed", | ||
| 613 | data: { | ||
| 614 | rawInput: { | ||
| 615 | variant: "TaskOutput", | ||
| 616 | task_ids: ["019f44b8-8e98-7c80-a40e-df1e26a5f9e3"], | ||
| 617 | }, | ||
| 618 | rawOutput: { | ||
| 619 | type: "Text", | ||
| 620 | text: ["=== Task 019f44b8-8e98-7c80-a40e-df1e26a5f9e3 ===", "Exit Code: -1"].join("\n"), | ||
| 621 | }, | ||
| 622 | }, | ||
| 623 | }), | ||
| 624 | ).toMatchObject([ | ||
| 625 | { | ||
| 626 | taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", | ||
| 627 | status: "failed", | ||
| 628 | }, | ||
| 629 | ]); | ||
| 630 | }); | ||
| 631 | |||
| 632 | it("tombstones tasks the model kills via kill_command_or_subagent", () => { | ||
| 633 | expect( | ||
| 634 | extractXAiKilledBackgroundTasks({ | ||
| 635 | toolCallId: "call-kill-1", | ||
| 636 | title: "kill_command_or_subagent", | ||
| 637 | status: "completed", | ||
| 638 | data: { | ||
| 639 | rawInput: { | ||
| 640 | variant: "Kill", | ||
| 641 | task_ids: ["call-bg-1", "call-bg-2", "call-bg-1"], | ||
| 642 | }, | ||
| 643 | }, | ||
| 644 | }), | ||
| 645 | ).toEqual([ | ||
| 646 | { taskId: "call-bg-1", status: "completed", appendOutput: "" }, | ||
| 647 | { taskId: "call-bg-2", status: "completed", appendOutput: "" }, | ||
| 648 | ]); | ||
| 649 | // Singular task_id shape, matched by variant when the title is generic. | ||
| 650 | expect( | ||
| 651 | extractXAiKilledBackgroundTasks({ | ||
| 652 | toolCallId: "call-kill-2", | ||
| 653 | title: "Tool", | ||
| 654 | status: "failed", | ||
| 655 | data: { rawInput: { variant: "kill", task_id: "call-bg-3" } }, | ||
| 656 | }), | ||
| 657 | ).toEqual([{ taskId: "call-bg-3", status: "completed", appendOutput: "" }]); | ||
| 658 | // In-flight kill calls and unrelated tools contribute nothing. | ||
| 659 | expect( | ||
| 660 | extractXAiKilledBackgroundTasks({ | ||
| 661 | toolCallId: "call-kill-3", | ||
| 662 | title: "kill_command_or_subagent", | ||
| 663 | status: "inProgress", | ||
| 664 | data: { rawInput: { task_ids: ["call-bg-4"] } }, | ||
| 665 | }), | ||
| 666 | ).toEqual([]); | ||
| 667 | expect( | ||
| 668 | extractXAiKilledBackgroundTasks({ | ||
| 669 | toolCallId: "call-get", | ||
| 670 | title: "get_command_or_subagent_output", | ||
| 671 | status: "completed", | ||
| 672 | data: { rawInput: { variant: "TaskOutput", task_ids: ["call-bg-5"] } }, | ||
| 673 | }), | ||
| 674 | ).toEqual([]); | ||
| 675 | }); | ||
| 676 | |||
| 677 | it("maps task lifecycle notifications to background mutations", () => { | ||
| 678 | // task_backgrounded carries the cancelled call's tool_call_id as task_id. | ||
| 679 | expect( | ||
| 680 | xAiBackgroundTaskLifecycleMutation( | ||
| 681 | { | ||
| 682 | sessionId: "session-1", | ||
| 683 | update: { | ||
| 684 | sessionUpdate: "task_backgrounded", | ||
| 685 | tool_call_id: "call-bg-1", | ||
| 686 | task_id: "call-bg-1", | ||
| 687 | }, | ||
| 688 | }, | ||
| 689 | "running", | ||
| 690 | ), | ||
| 691 | ).toEqual({ sessionId: "session-1", taskId: "call-bg-1", status: "running" }); | ||
| 692 | // task_completed nests the id inside task_snapshot. | ||
| 693 | expect( | ||
| 694 | xAiBackgroundTaskLifecycleMutation( | ||
| 695 | { | ||
| 696 | sessionId: "session-1", | ||
| 697 | update: { | ||
| 698 | sessionUpdate: "task_completed", | ||
| 699 | task_snapshot: { task_id: "call-bg-1" }, | ||
| 700 | }, | ||
| 701 | }, | ||
| 702 | "completed", | ||
| 703 | ), | ||
| 704 | ).toEqual({ sessionId: "session-1", taskId: "call-bg-1", status: "completed" }); | ||
| 705 | expect( | ||
| 706 | xAiBackgroundTaskLifecycleMutation( | ||
| 707 | { sessionId: "session-1", update: { sessionUpdate: "task_completed" } }, | ||
| 708 | "completed", | ||
| 709 | ), | ||
| 710 | ).toBeNull(); | ||
| 711 | }); | ||
| 712 | |||
| 713 | it("detects persistent Monitor start ACKs", () => { | ||
| 714 | expect( | ||
| 715 | isXAiPersistentMonitor({ | ||
| 716 | toolCallId: "call-mon-persist", | ||
| 717 | title: "Monitor", | ||
| 718 | status: "completed", | ||
| 719 | data: { | ||
| 720 | rawInput: { variant: "Monitor" }, | ||
| 721 | rawOutput: { | ||
| 722 | type: "Monitor", | ||
| 723 | taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", | ||
| 724 | timeoutMs: 36000000, | ||
| 725 | persistent: true, | ||
| 726 | }, | ||
| 727 | }, | ||
| 728 | }), | ||
| 729 | ).toBe(true); | ||
| 730 | expect( | ||
| 731 | isXAiPersistentMonitor({ | ||
| 732 | toolCallId: "call-mon-ephemeral", | ||
| 733 | title: "Monitor", | ||
| 734 | status: "completed", | ||
| 735 | data: { | ||
| 736 | rawInput: { variant: "Monitor" }, | ||
| 737 | rawOutput: { | ||
| 738 | type: "Monitor", | ||
| 739 | taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", | ||
| 740 | timeoutMs: 36000000, | ||
| 741 | persistent: false, | ||
| 742 | }, | ||
| 743 | }, | ||
| 744 | }), | ||
| 745 | ).toBe(false); | ||
| 746 | }); | ||
| 747 | |||
| 748 | it("keeps a standalone TaskOutput completion terminal through normalize", () => { | ||
| 749 | // Must remain offer evidence post-settle: normalize must not demote it to | ||
| 750 | // inProgress the way monitor start ACKs are. | ||
| 751 | expect( | ||
| 752 | normalizeXAiAcpToolCallState({ | ||
| 753 | toolCallId: "call-c29e64dd-ce5d-4eac-a6ca-c20513fefac3-1", | ||
| 754 | title: "[monitor] Stream numbered markers every 3s (019f54a0)", | ||
| 755 | status: "completed", | ||
| 756 | data: { | ||
| 757 | rawOutput: { | ||
| 758 | type: "TaskOutput", | ||
| 759 | Result: { | ||
| 760 | task_id: "019f54a0-06a8-77f2-8214-e24937cad564", | ||
| 761 | status: "completed", | ||
| 762 | exit_code: 0, | ||
| 763 | output: "STREAM_DONE\n", | ||
| 764 | }, | ||
| 765 | }, | ||
| 766 | }, | ||
| 767 | }).status, | ||
| 768 | ).toBe("completed"); | ||
| 769 | }); | ||
| 770 | |||
| 771 | it("parses monitor event lines and end reminders", () => { | ||
| 772 | expect( | ||
| 773 | extractXAiAcpBackgroundToolMutation( | ||
| 774 | '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">\n[Demo] mon_line_1\n</monitor-event>', | ||
| 775 | ), | ||
| 776 | ).toEqual([ | ||
| 777 | { | ||
| 778 | taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", | ||
| 779 | status: "running", | ||
| 780 | appendOutput: "[Demo] mon_line_1\n", | ||
| 781 | }, | ||
| 782 | ]); | ||
| 783 | expect( | ||
| 784 | extractXAiAcpBackgroundToolMutation( | ||
| 785 | [ | ||
| 786 | "<system-reminder>", | ||
| 787 | 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].', | ||
| 788 | "Description: Demo monitor", | ||
| 789 | "</system-reminder>", | ||
| 790 | ].join("\n"), | ||
| 791 | ), | ||
| 792 | ).toMatchObject([ | ||
| 793 | { | ||
| 794 | taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", | ||
| 795 | status: "completed", | ||
| 796 | }, | ||
| 797 | ]); | ||
| 798 | // Description/output may contain "error" without meaning the monitor failed. | ||
| 799 | expect( | ||
| 800 | extractXAiAcpBackgroundToolMutation( | ||
| 801 | [ | ||
| 802 | "<system-reminder>", | ||
| 803 | 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].', | ||
| 804 | "Description: watch error logs for failed deploys", | ||
| 805 | "error: nothing found", | ||
| 806 | "</system-reminder>", | ||
| 807 | ].join("\n"), | ||
| 808 | ), | ||
| 809 | ).toMatchObject([ | ||
| 810 | { | ||
| 811 | taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", | ||
| 812 | status: "completed", | ||
| 813 | }, | ||
| 814 | ]); | ||
| 815 | }); | ||
| 816 | |||
| 817 | it("parses every monitor-event and trailing end notice in one chunk", () => { | ||
| 818 | const mutations = extractXAiAcpBackgroundToolMutation( | ||
| 819 | [ | ||
| 820 | '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">', | ||
| 821 | "[Demo] mon_line_1", | ||
| 822 | "</monitor-event>", | ||
| 823 | '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">', | ||
| 824 | "[Demo] mon_line_2", | ||
| 825 | "</monitor-event>", | ||
| 826 | 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].', | ||
| 827 | ].join("\n"), | ||
| 828 | ); | ||
| 829 | expect(mutations).toHaveLength(3); | ||
| 830 | expect(mutations[0]).toEqual({ | ||
| 831 | taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", | ||
| 832 | status: "running", | ||
| 833 | appendOutput: "[Demo] mon_line_1\n", | ||
| 834 | }); | ||
| 835 | expect(mutations[1]).toEqual({ | ||
| 836 | taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", | ||
| 837 | status: "running", | ||
| 838 | appendOutput: "[Demo] mon_line_2\n", | ||
| 839 | }); | ||
| 840 | expect(mutations[2]).toMatchObject({ | ||
| 841 | taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", | ||
| 842 | status: "completed", | ||
| 843 | }); | ||
| 844 | }); | ||
| 845 | |||
| 846 | it("parses batched monitor event blocks as running mutations", () => { | ||
| 847 | expect( | ||
| 848 | extractXAiAcpBackgroundToolMutation( | ||
| 849 | [ | ||
| 850 | "3 monitor events from 1 monitor (use get_command_or_subagent_output to identify each monitor):", | ||
| 851 | "", | ||
| 852 | '<monitor description="Stream STREAM_i every 3s" task_id="019f545d-4d39-7001-b1c8-c7744c448ec1">', | ||
| 853 | "[1] STREAM_7", | ||
| 854 | "[2] STREAM_8", | ||
| 855 | "[3] STREAM_9", | ||
| 856 | "</monitor>", | ||
| 857 | ].join("\n"), | ||
| 858 | ), | ||
| 859 | ).toEqual([ | ||
| 860 | { | ||
| 861 | taskId: "019f545d-4d39-7001-b1c8-c7744c448ec1", | ||
| 862 | status: "running", | ||
| 863 | appendOutput: "[1] STREAM_7\n[2] STREAM_8\n[3] STREAM_9\n", | ||
| 864 | }, | ||
| 865 | ]); | ||
| 866 | }); | ||
| 867 | |||
| 868 | it("parses the subagent completed reminder as an end notice", () => { | ||
| 869 | expect( | ||
| 870 | extractXAiAcpSubagentEndNotice( | ||
| 871 | [ | ||
| 872 | "<system-reminder>", | ||
| 873 | 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") completed successfully.', | ||
| 874 | "Duration: 28.8s | Tool calls: 1 | Turns: 1", | ||
| 875 | 'Use get_task_output("019f5470-bf92-7a90-afb3-5a6cea5b34a3") to see the full output.', | ||
| 876 | "</system-reminder>", | ||
| 877 | ].join("\n"), | ||
| 878 | ), | ||
| 879 | ).toEqual({ | ||
| 880 | childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3", | ||
| 881 | status: "completed", | ||
| 882 | }); | ||
| 883 | }); | ||
| 884 | |||
| 885 | it("parses a failed subagent reminder as a failed end notice", () => { | ||
| 886 | expect( | ||
| 887 | extractXAiAcpSubagentEndNotice( | ||
| 888 | 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") failed.', | ||
| 889 | ), | ||
| 890 | ).toEqual({ | ||
| 891 | childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3", | ||
| 892 | status: "failed", | ||
| 893 | }); | ||
| 894 | }); | ||
| 895 | |||
| 896 | it("ignores prompt text that merely mentions subagents", () => { | ||
| 897 | expect( | ||
| 898 | extractXAiAcpSubagentEndNotice("Live-test post-settle subagent completion."), | ||
| 899 | ).toBeUndefined(); | ||
| 900 | expect( | ||
| 901 | extractXAiAcpSubagentEndNotice( | ||
| 902 | 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") is still running.', | ||
| 903 | ), | ||
| 904 | ).toBeUndefined(); | ||
| 905 | }); | ||
| 906 | |||
| 907 | it("does not treat nested parentheses in the title as the outcome verb", () => { | ||
| 908 | expect( | ||
| 909 | extractXAiAcpSubagentEndNotice( | ||
| 910 | 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Check (backend) failed tests") is still running.', | ||
| 911 | ), | ||
| 912 | ).toBeUndefined(); | ||
| 913 | expect( | ||
| 914 | extractXAiAcpSubagentEndNotice( | ||
| 915 | 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Check (backend) failed tests") completed successfully.', | ||
| 916 | ), | ||
| 917 | ).toEqual({ | ||
| 918 | childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3", | ||
| 919 | status: "completed", | ||
| 98 | }); | 920 | }); |
| 99 | }); | 921 | }); |
| 100 | 922 | ||
| @@ -335,58 +1157,265 @@ describe("XAiAcpExtension", () => { · 234 unmodified lines | |||
| 335 | }); | 1157 | }); |
| 336 | }); | 1158 | }); |
| 337 | 1159 | ||
| 338 | it.effect("resolves a hung standard prompt from xAI prompt completion", () => | 1160 | it.effect("settles a hung prompt from a root-session prompt_complete notification", () => |
| 339 | Effect.gen(function* () { | 1161 | Effect.gen(function* () { |
| 340 | const runtime = yield* makePromptCompletionRuntime({ | 1162 | const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); |
| 341 | T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", | 1163 | const hungPrompt = yield* Deferred.make<never>(); |
| 342 | }); | 1164 | const baseRuntime = { |
| 343 | yield* runtime.start(); | 1165 | start: () => |
| 1166 | Effect.succeed({ | ||
| 1167 | sessionId: "root-session", | ||
| 1168 | initializeResult: {}, | ||
| 1169 | sessionSetupResult: {}, | ||
| 1170 | modelConfigId: undefined, | ||
| 1171 | }), | ||
| 1172 | prompt: () => Deferred.await(hungPrompt), | ||
| 1173 | cancel: Effect.void, | ||
| 1174 | handleExtNotification: ( | ||
| 1175 | method: string, | ||
| 1176 | _schema: unknown, | ||
| 1177 | handler: (notification: unknown) => Effect.Effect<void>, | ||
| 1178 | ) => { | ||
| 1179 | handlers.set(method, handler); | ||
| 1180 | return Effect.void; | ||
| 1181 | }, | ||
| 1182 | handleExtRequest: () => Effect.void, | ||
| 1183 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 344 | 1184 | ||
| 345 | const promptResult = yield* runtime.prompt({ | 1185 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); |
| 346 | prompt: [{ type: "text", text: "hi" }], | 1186 | const promptFiber = yield* runtime |
| 1187 | .prompt({ prompt: [{ type: "text", text: "hi" }] }) | ||
| 1188 | .pipe(Effect.forkChild); | ||
| 1189 | yield* Effect.yieldNow; | ||
| 1190 | const promptCompleteHandler = handlers.get("_x.ai/session/prompt_complete"); | ||
| 1191 | expect(promptCompleteHandler).toBeDefined(); | ||
| 1192 | yield* promptCompleteHandler!({ | ||
| 1193 | sessionId: "root-session", | ||
| 1194 | stopReason: "end_turn", | ||
| 347 | }); | 1195 | }); |
| 348 | const promptId = promptResult._meta?.promptId; | 1196 | const response = yield* Fiber.join(promptFiber); |
| 1197 | expect(response.stopReason).toBe("end_turn"); | ||
| 1198 | }), | ||
| 1199 | ); | ||
| 349 | 1200 | ||
| 1201 | it.effect("settles a hung prompt from _x.ai/session/update turn_completed", () => | ||
| 1202 | Effect.gen(function* () { | ||
| 1203 | const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); | ||
| 1204 | let capturedMeta: Record<string, unknown> | null | undefined; | ||
| 1205 | const hungPrompt = yield* Deferred.make<never>(); | ||
| 1206 | const baseRuntime = { | ||
| 1207 | start: () => | ||
| 1208 | Effect.succeed({ | ||
| 1209 | sessionId: "root-session", | ||
| 1210 | initializeResult: {}, | ||
| 1211 | sessionSetupResult: {}, | ||
| 1212 | modelConfigId: undefined, | ||
| 1213 | }), | ||
| 1214 | prompt: (payload: { readonly _meta?: Record<string, unknown> | null }) => { | ||
| 1215 | capturedMeta = payload._meta ?? null; | ||
| 1216 | return Deferred.await(hungPrompt); | ||
| 1217 | }, | ||
| 1218 | cancel: Effect.void, | ||
| 1219 | handleExtNotification: ( | ||
| 1220 | method: string, | ||
| 1221 | _schema: unknown, | ||
| 1222 | handler: (notification: unknown) => Effect.Effect<void>, | ||
| 1223 | ) => { | ||
| 1224 | handlers.set(method, handler); | ||
| 1225 | return Effect.void; | ||
| 1226 | }, | ||
| 1227 | handleExtRequest: () => Effect.void, | ||
| 1228 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 1229 | |||
| 1230 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); | ||
| 1231 | const promptFiber = yield* runtime | ||
| 1232 | .prompt({ prompt: [{ type: "text", text: "hi" }] }) | ||
| 1233 | .pipe(Effect.forkChild); | ||
| 1234 | yield* Effect.yieldNow; | ||
| 1235 | const promptId = capturedMeta?.promptId; | ||
| 350 | expect(typeof promptId).toBe("string"); | 1236 | expect(typeof promptId).toBe("string"); |
| 351 | expect(promptResult).toMatchObject({ | 1237 | const sessionUpdateHandler = handlers.get("_x.ai/session/update"); |
| 352 | stopReason: "end_turn", | 1238 | expect(sessionUpdateHandler).toBeDefined(); |
| 353 | _meta: { | 1239 | yield* sessionUpdateHandler!({ |
| 354 | sessionId: "mock-session-1", | 1240 | sessionId: "root-session", |
| 355 | promptId, | 1241 | update: { |
| 356 | requestId: promptId, | 1242 | sessionUpdate: "turn_completed", |
| 1243 | prompt_id: promptId, | ||
| 1244 | stop_reason: "end_turn", | ||
| 357 | }, | 1245 | }, |
| 358 | }); | 1246 | }); |
| 359 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 1247 | const response = yield* Fiber.join(promptFiber); |
| 1248 | expect(response.stopReason).toBe("end_turn"); | ||
| 1249 | }), | ||
| 360 | ); | 1250 | ); |
| 361 | 1251 | ||
| 362 | it.effect("ignores stale xAI completion from an already settled prompt", () => | 1252 | it.effect("ignores turn_completed for non-pending prompt ids and task completions", () => |
| 363 | Effect.gen(function* () { | 1253 | Effect.gen(function* () { |
| 364 | const runtime = yield* makePromptCompletionRuntime({ | 1254 | const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); |
| 365 | T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG: "1", | 1255 | const hungPrompt = yield* Deferred.make<never>(); |
| 366 | }); | 1256 | const baseRuntime = { |
| 367 | yield* runtime.start(); | 1257 | start: () => |
| 1258 | Effect.succeed({ | ||
| 1259 | sessionId: "root-session", | ||
| 1260 | initializeResult: {}, | ||
| 1261 | sessionSetupResult: {}, | ||
| 1262 | modelConfigId: undefined, | ||
| 1263 | }), | ||
| 1264 | prompt: () => Deferred.await(hungPrompt), | ||
| 1265 | cancel: Effect.void, | ||
| 1266 | handleExtNotification: ( | ||
| 1267 | method: string, | ||
| 1268 | _schema: unknown, | ||
| 1269 | handler: (notification: unknown) => Effect.Effect<void>, | ||
| 1270 | ) => { | ||
| 1271 | handlers.set(method, handler); | ||
| 1272 | return Effect.void; | ||
| 1273 | }, | ||
| 1274 | handleExtRequest: () => Effect.void, | ||
| 1275 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 368 | 1276 | ||
| 369 | const firstPromptResult = yield* runtime.prompt({ | 1277 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); |
| 370 | prompt: [{ type: "text", text: "first" }], | 1278 | const promptFiber = yield* runtime |
| 1279 | .prompt({ prompt: [{ type: "text", text: "hi" }] }) | ||
| 1280 | .pipe(Effect.forkChild); | ||
| 1281 | yield* Effect.yieldNow; | ||
| 1282 | const sessionUpdateHandler = handlers.get("_x.ai/session/update"); | ||
| 1283 | expect(sessionUpdateHandler).toBeDefined(); | ||
| 1284 | yield* sessionUpdateHandler!({ | ||
| 1285 | sessionId: "root-session", | ||
| 1286 | update: { | ||
| 1287 | sessionUpdate: "turn_completed", | ||
| 1288 | prompt_id: "task-completed-call-abc", | ||
| 1289 | stop_reason: "end_turn", | ||
| 1290 | }, | ||
| 371 | }); | 1291 | }); |
| 372 | expect(firstPromptResult).toMatchObject({ | 1292 | yield* sessionUpdateHandler!({ |
| 373 | stopReason: "end_turn", | 1293 | sessionId: "root-session", |
| 374 | _meta: { promptId: "mock-stale-xai-prompt-1" }, | 1294 | update: { |
| 1295 | sessionUpdate: "turn_completed", | ||
| 1296 | prompt_id: "some-other-cli-turn", | ||
| 1297 | stop_reason: "end_turn", | ||
| 1298 | }, | ||
| 375 | }); | 1299 | }); |
| 1300 | yield* Effect.yieldNow; | ||
| 1301 | expect(promptFiber.pollUnsafe()).toBeUndefined(); | ||
| 1302 | yield* Fiber.interrupt(promptFiber); | ||
| 1303 | }), | ||
| 1304 | ); | ||
| 1305 | |||
| 1306 | it.effect("ignores prompt_complete notifications for foreign session ids", () => | ||
| 1307 | Effect.gen(function* () { | ||
| 1308 | const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); | ||
| 1309 | const hungPrompt = yield* Deferred.make<never>(); | ||
| 1310 | const baseRuntime = { | ||
| 1311 | start: () => | ||
| 1312 | Effect.succeed({ | ||
| 1313 | sessionId: "root-session", | ||
| 1314 | initializeResult: {}, | ||
| 1315 | sessionSetupResult: {}, | ||
| 1316 | modelConfigId: undefined, | ||
| 1317 | }), | ||
| 1318 | prompt: () => Deferred.await(hungPrompt), | ||
| 1319 | cancel: Effect.void, | ||
| 1320 | handleExtNotification: ( | ||
| 1321 | method: string, | ||
| 1322 | _schema: unknown, | ||
| 1323 | handler: (notification: unknown) => Effect.Effect<void>, | ||
| 1324 | ) => { | ||
| 1325 | handlers.set(method, handler); | ||
| 1326 | return Effect.void; | ||
| 1327 | }, | ||
| 1328 | handleExtRequest: () => Effect.void, | ||
| 1329 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 376 | 1330 | ||
| 377 | const secondPromptResult = yield* runtime.prompt({ | 1331 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); |
| 378 | prompt: [{ type: "text", text: "second" }], | 1332 | const promptFiber = yield* runtime |
| 1333 | .prompt({ prompt: [{ type: "text", text: "hi" }] }) | ||
| 1334 | .pipe(Effect.forkChild); | ||
| 1335 | yield* Effect.yieldNow; | ||
| 1336 | const promptCompleteHandler = handlers.get("_x.ai/session/prompt_complete"); | ||
| 1337 | expect(promptCompleteHandler).toBeDefined(); | ||
| 1338 | yield* promptCompleteHandler!({ | ||
| 1339 | sessionId: "child-session", | ||
| 379 | }); | 1340 | }); |
| 380 | const secondPromptId = secondPromptResult._meta?.promptId; | 1341 | yield* Effect.yieldNow; |
| 381 | expect(typeof secondPromptId).toBe("string"); | 1342 | expect(promptFiber.pollUnsafe()).toBeUndefined(); |
| 382 | expect(secondPromptId).not.toBe("mock-stale-xai-prompt-1"); | 1343 | yield* Fiber.interrupt(promptFiber); |
| 383 | expect(secondPromptResult).toMatchObject({ | 1344 | }), |
| 384 | stopReason: "end_turn", | 1345 | ); |
| 385 | _meta: { | 1346 | |
| 386 | promptId: secondPromptId, | 1347 | it.effect( |
| 387 | requestId: secondPromptId, | 1348 | "ignores promptId-less prompt_complete when multiple prompts are pending on the same session", |
| 1349 | () => | ||
| 1350 | Effect.gen(function* () { | ||
| 1351 | const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); | ||
| 1352 | const hungPrompt = yield* Deferred.make<never>(); | ||
| 1353 | const baseRuntime = { | ||
| 1354 | start: () => | ||
| 1355 | Effect.succeed({ | ||
| 1356 | sessionId: "root-session", | ||
| 1357 | initializeResult: {}, | ||
| 1358 | sessionSetupResult: {}, | ||
| 1359 | modelConfigId: undefined, | ||
| 1360 | }), | ||
| 1361 | prompt: () => Deferred.await(hungPrompt), | ||
| 1362 | cancel: Effect.void, | ||
| 1363 | handleExtNotification: ( | ||
| 1364 | method: string, | ||
| 1365 | _schema: unknown, | ||
| 1366 | handler: (notification: unknown) => Effect.Effect<void>, | ||
| 1367 | ) => { | ||
| 1368 | handlers.set(method, handler); | ||
| 1369 | return Effect.void; | ||
| 1370 | }, | ||
| 1371 | handleExtRequest: () => Effect.void, | ||
| 1372 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 1373 | |||
| 1374 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); | ||
| 1375 | const firstPromptFiber = yield* runtime | ||
| 1376 | .prompt({ prompt: [{ type: "text", text: "first" }] }) | ||
| 1377 | .pipe(Effect.forkChild); | ||
| 1378 | yield* Effect.yieldNow; | ||
| 1379 | const secondPromptFiber = yield* runtime | ||
| 1380 | .prompt({ prompt: [{ type: "text", text: "second" }] }) | ||
| 1381 | .pipe(Effect.forkChild); | ||
| 1382 | yield* Effect.yieldNow; | ||
| 1383 | const promptCompleteHandler = handlers.get("_x.ai/session/prompt_complete"); | ||
| 1384 | expect(promptCompleteHandler).toBeDefined(); | ||
| 1385 | yield* promptCompleteHandler!({ | ||
| 1386 | sessionId: "root-session", | ||
| 1387 | stopReason: "end_turn", | ||
| 1388 | }); | ||
| 1389 | yield* Effect.yieldNow; | ||
| 1390 | expect(firstPromptFiber.pollUnsafe()).toBeUndefined(); | ||
| 1391 | expect(secondPromptFiber.pollUnsafe()).toBeUndefined(); | ||
| 1392 | yield* Fiber.interrupt(firstPromptFiber); | ||
| 1393 | yield* Fiber.interrupt(secondPromptFiber); | ||
| 1394 | }), | ||
| 1395 | ); | ||
| 1396 | |||
| 1397 | it.effect("injects promptId and requestId into prompt _meta", () => | ||
| 1398 | Effect.gen(function* () { | ||
| 1399 | let capturedMeta: Record<string, unknown> | null | undefined; | ||
| 1400 | const baseRuntime = { | ||
| 1401 | start: () => Effect.succeed({ sessionId: "session-1" }), | ||
| 1402 | prompt: (payload: { readonly _meta?: Record<string, unknown> | null }) => { | ||
| 1403 | capturedMeta = payload._meta ?? null; | ||
| 1404 | return Effect.succeed({ stopReason: "end_turn" as const }); | ||
| 388 | }, | 1405 | }, |
| 1406 | cancel: Effect.void, | ||
| 1407 | handleExtNotification: () => Effect.void, | ||
| 1408 | handleExtRequest: () => Effect.void, | ||
| 1409 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 1410 | |||
| 1411 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); | ||
| 1412 | yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }] }); | ||
| 1413 | |||
| 1414 | expect(typeof capturedMeta?.promptId).toBe("string"); | ||
| 1415 | expect(capturedMeta).toMatchObject({ | ||
| 1416 | promptId: capturedMeta?.promptId, | ||
| 1417 | requestId: capturedMeta?.promptId, | ||
| 389 | }); | 1418 | }); |
| 390 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 1419 | }), |
| 391 | ); | 1420 | ); |
| 392 | }); | 1421 | }); |
| @@ -1,16 +1,19 @@ | |||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | |||
| 3 | import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; | 1 | import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; |
| 2 | import * as Cause from "effect/Cause"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | 3 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 6 | import * as Ref from "effect/Ref"; | 6 | import * as Ref from "effect/Ref"; |
| 7 | import * as Schema from "effect/Schema"; | 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 | import type * as EffectAcpSchema from "effect-acp/schema"; | 9 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 10 | 10 | ||
| 11 | import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 11 | import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| 12 | import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; | 12 | import type { AcpToolCallState } from "./AcpRuntimeModel.ts"; |
| 13 | 13 | ||
| 14 | const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; | ||
| 15 | const completedXAiPromptIdLimit = 128; | ||
| 16 | |||
| 14 | const XAiPromptCompleteNotification = Schema.Struct({ | 17 | const XAiPromptCompleteNotification = Schema.Struct({ |
| 15 | sessionId: Schema.String, | 18 | sessionId: Schema.String, |
| 16 | promptId: Schema.optional(Schema.String), | 19 | promptId: Schema.optional(Schema.String), |
| @@ -20,19 +23,60 @@ const XAiPromptCompleteNotification = Schema.Struct({ · 3 unmodified lines | |||
| 20 | 23 | ||
| 21 | type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type; | 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 | interface PendingXAiPromptCompletion { | 74 | interface PendingXAiPromptCompletion { |
| 24 | readonly sessionId: string; | 75 | readonly sessionId: string; |
| 25 | readonly promptId: string; | 76 | readonly promptId: string; |
| 26 | readonly deferred: Deferred.Deferred< | 77 | readonly deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse>; |
| 27 | EffectAcpSchema.PromptResponse, | ||
| 28 | EffectAcpErrors.AcpRequestError | ||
| 29 | >; | ||
| 30 | } | 78 | } |
| 31 | 79 | ||
| 32 | const completedXAiPromptIdLimit = 128; | ||
| 33 | const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; | ||
| 34 | const xAiRateLimitedErrorCode = -32003; | ||
| 35 | |||
| 36 | export interface XAiAcpSubagentUpdate { | 80 | export interface XAiAcpSubagentUpdate { |
| 37 | readonly nativeTaskId: string; | 81 | readonly nativeTaskId: string; |
| 38 | readonly prompt: string; | 82 | readonly prompt: string; |
| @@ -41,8 +85,26 @@ export interface XAiAcpSubagentUpdate { · 2 unmodified lines | |||
| 41 | readonly status: "running" | "completed" | "failed"; | 85 | readonly status: "running" | "completed" | "failed"; |
| 42 | readonly childSessionId: string | null; | 86 | readonly childSessionId: string | null; |
| 43 | readonly result: string | null; | 87 | readonly result: string | null; |
| 88 | /** | ||
| 89 | * When false, the ACP adapter also projects a normal tool turn item for this | ||
| 90 | * tool call (used for get_command_or_subagent_output hydration). Default true. | ||
| 91 | */ | ||
| 92 | readonly suppressNormalTool?: boolean; | ||
| 93 | } | ||
| 94 | |||
| 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 | function unknownRecord(value: unknown): Record<string, unknown> | undefined { | 108 | function unknownRecord(value: unknown): Record<string, unknown> | undefined { |
| 47 | return typeof value === "object" && value !== null && !Array.isArray(value) | 109 | return typeof value === "object" && value !== null && !Array.isArray(value) |
| 48 | ? (value as Record<string, unknown>) | 110 | ? (value as Record<string, unknown>) |
| @@ -53,10 +115,42 @@ function nonEmptyString(value: unknown): string | undefined { · 4 unmodified lines | |||
| 53 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; | 115 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; |
| 54 | } | 116 | } |
| 55 | 117 | ||
| 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 | const rawOutput = unknownRecord(toolCall.data.rawOutput); | 135 | const rawOutput = unknownRecord(toolCall.data.rawOutput); |
| 58 | const rawOutputText = nonEmptyString(rawOutput?.text); | 136 | if (rawOutput !== undefined) { |
| 59 | if (rawOutputText !== undefined) return rawOutputText; | 137 | const direct = |
| 138 | nonEmptyString(rawOutput.text) ?? | ||
| 139 | nonEmptyString(rawOutput.output_for_prompt) ?? | ||
| 140 | decodeByteText(rawOutput.output); | ||
| 141 | if (direct !== undefined) return direct; | ||
| 142 | const result = unknownRecord(rawOutput.Result) ?? unknownRecord(rawOutput.result); | ||
| 143 | if (result !== undefined) { | ||
| 144 | const nested = | ||
| 145 | nonEmptyString(result.output) ?? | ||
| 146 | nonEmptyString(result.text) ?? | ||
| 147 | decodeByteText(result.output); | ||
| 148 | if (nested !== undefined) return nested; | ||
| 149 | } | ||
| 150 | } | ||
| 151 | if (typeof toolCall.data.rawOutput === "string") { | ||
| 152 | return nonEmptyString(toolCall.data.rawOutput); | ||
| 153 | } | ||
| 60 | const content = toolCall.data.content; | 154 | const content = toolCall.data.content; |
| 61 | if (!Array.isArray(content)) return undefined; | 155 | if (!Array.isArray(content)) return undefined; |
| 62 | const text = content | 156 | const text = content |
| @@ -70,41 +164,631 @@ function xAiTaskOutputText(toolCall: AcpToolCallState): string | undefined { · 7 unmodified lines | |||
| 70 | return text.length > 0 ? text : undefined; | 164 | return text.length > 0 ? text : undefined; |
| 71 | } | 165 | } |
| 72 | 166 | ||
| 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 | 721 | * Recognizes Grok Task / spawn_subagent envelopes and get_command hydration |
| 75 | * ACP adapter about xAI tool names or result formatting. | 722 | * without teaching the generic ACP adapter about xAI tool names. |
| 723 | * | ||
| 724 | * Current Grok CLI returns spawn_subagent immediately with a background ACK | ||
| 725 | * (`subagent_id: ...`). That must stay `running` until child work finishes or | ||
| 726 | * get_command_or_subagent_output reports a terminal status. | ||
| 76 | */ | 727 | */ |
| 77 | export function extractXAiAcpSubagentUpdate( | 728 | export function extractXAiAcpSubagentUpdate( |
| 78 | toolCall: AcpToolCallState, | 729 | toolCall: AcpToolCallState, |
| 79 | ): XAiAcpSubagentUpdate | undefined { | 730 | ): XAiAcpSubagentUpdate | undefined { |
| 80 | const rawInput = unknownRecord(toolCall.data.rawInput); | 731 | const rawInput = unknownRecord(toolCall.data.rawInput); |
| 81 | const isTask = | 732 | const output = xAiToolOutputText(toolCall); |
| 82 | toolCall.title === "Task" || | 733 | |
| 83 | rawInput?.variant === "CursorTask" || | 734 | if (isXAiGetSubagentOutputTool(toolCall, rawInput)) { |
| 84 | nonEmptyString(rawInput?.subagent_type) !== undefined; | 735 | const taskIds = taskIdsFromGetOutputTool(toolCall, rawInput); |
| 85 | if (!isTask || rawInput === undefined) return undefined; | 736 | // Prefer the completed task identity from the result/header over the first |
| 86 | 737 | // requested task_ids entry (one call can poll multiple task_ids). | |
| 87 | const output = xAiTaskOutputText(toolCall); | 738 | const childSessionId = |
| 88 | const childSessionId = | 739 | resultTaskIdFromGetOutputTool(toolCall, output) ?? |
| 89 | output?.match(/(?:^|\n)Agent ID:\s*([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\b/i)?.[1] ?? | 740 | taskIds[0] ?? |
| 90 | null; | 741 | extractXAiChildSessionId(output); |
| 91 | const result = | 742 | if (childSessionId === null) return undefined; |
| 92 | output?.replace(/(?:^|\n)Agent ID:\s*[0-9a-f-]+[^\n]*(?:\n|$)/gi, "\n").trim() || null; | 743 | // Prefer the durable subagent row as the completion surface (Claude/Codex |
| 93 | const status = | 744 | // style). Suppress the noisy TaskOutput tool card; monitor hydration is |
| 94 | toolCall.status === "failed" | 745 | // handled separately via extractBackgroundTaskCompletion. |
| 95 | ? "failed" | 746 | return { |
| 96 | : toolCall.status === "completed" | 747 | nativeTaskId: toolCall.toolCallId, |
| 97 | ? "completed" | 748 | prompt: "", |
| 98 | : "running"; | 749 | title: null, |
| 750 | model: null, | ||
| 751 | status: statusFromGetOutputTool(toolCall, output), | ||
| 752 | childSessionId, | ||
| 753 | result: resultFromGetOutputTool(toolCall, output), | ||
| 754 | suppressNormalTool: true, | ||
| 755 | }; | ||
| 756 | } | ||
| 757 | |||
| 758 | if (!isXAiSpawnOrTaskTool(toolCall, rawInput)) return undefined; | ||
| 759 | |||
| 760 | const childSessionId = extractXAiChildSessionId(output); | ||
| 761 | const asyncSpawnAck = isXAiAsyncSpawnAck(output); | ||
| 762 | const legacyResult = | ||
| 763 | output | ||
| 764 | ?.replace(new RegExp(`(?:^|\\n)\\s*Agent ID:\\s*${XAI_UUID_RE}[^\\n]*(?:\\n|$)`, "gi"), "\n") | ||
| 765 | .replace( | ||
| 766 | new RegExp(`(?:^|\\n)\\s*subagent_id:\\s*${XAI_UUID_RE}[^\\n]*(?:\\n|$)`, "gi"), | ||
| 767 | "\n", | ||
| 768 | ) | ||
| 769 | .trim() || null; | ||
| 770 | |||
| 771 | let status: "running" | "completed" | "failed"; | ||
| 772 | if (toolCall.status === "failed") { | ||
| 773 | status = "failed"; | ||
| 774 | } else if (asyncSpawnAck) { | ||
| 775 | // Spawn RPC completed, but the child is still running. | ||
| 776 | status = "running"; | ||
| 777 | } else if (toolCall.status === "completed") { | ||
| 778 | status = "completed"; | ||
| 779 | } else { | ||
| 780 | status = "running"; | ||
| 781 | } | ||
| 99 | 782 | ||
| 100 | return { | 783 | return { |
| 101 | nativeTaskId: toolCall.toolCallId, | 784 | nativeTaskId: toolCall.toolCallId, |
| 102 | prompt: nonEmptyString(rawInput.prompt) ?? "", | 785 | prompt: nonEmptyString(rawInput?.prompt) ?? "", |
| 103 | title: nonEmptyString(rawInput.description) ?? null, | 786 | title: nonEmptyString(rawInput?.description) ?? null, |
| 104 | model: nonEmptyString(rawInput.model) ?? null, | 787 | model: nonEmptyString(rawInput?.model) ?? null, |
| 105 | status, | 788 | status, |
| 106 | childSessionId, | 789 | childSessionId, |
| 107 | result, | 790 | result: asyncSpawnAck ? null : legacyResult, |
| 791 | suppressNormalTool: true, | ||
| 108 | }; | 792 | }; |
| 109 | } | 793 | } |
| 110 | 794 | ||
| @@ -290,301 +974,57 @@ export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCan · 179 unmodified lines | |||
| 290 | return { outcome: "cancelled" }; | 974 | return { outcome: "cancelled" }; |
| 291 | } | 975 | } |
| 292 | 976 | ||
| 293 | // --------------------------------------------------------------------------- | 977 | function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined { |
| 294 | // x.ai/exit_plan_mode — plan approval gate (mirrors Grok Build TUI plan window) | 978 | const meta = response._meta; |
| 295 | // --------------------------------------------------------------------------- | 979 | if (meta === null || typeof meta !== "object") { |
| 296 | 980 | return undefined; | |
| 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; | ||
| 336 | } | 981 | } |
| 337 | return XAI_EMPTY_PLAN_MARKDOWN; | 982 | const promptId = meta.promptId ?? meta.requestId; |
| 338 | } | 983 | return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined; |
| 339 | |||
| 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(".."); | ||
| 366 | } | 984 | } |
| 367 | 985 | ||
| 368 | function addGrokSessionPrefix( | 986 | function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.StopReason { |
| 369 | prefixes: Set<string>, | 987 | switch (value) { |
| 370 | homeOrRoot: string, | 988 | case "cancelled": |
| 371 | nestedGrokDir: boolean, | 989 | case "end_turn": |
| 372 | ): void { | 990 | case "max_tokens": |
| 373 | const root = normalizeFsPath(homeOrRoot); | 991 | case "max_turn_requests": |
| 374 | if (!root) { | 992 | case "refusal": |
| 375 | return; | 993 | return value; |
| 994 | default: | ||
| 995 | return "end_turn"; | ||
| 376 | } | 996 | } |
| 377 | prefixes.add(nestedGrokDir ? `${root}/.grok/sessions/` : `${root}/sessions/`); | ||
| 378 | } | ||
| 379 | |||
| 380 | /** Injected host bits so these helpers stay off `process.platform` / `process.env`. */ | ||
| 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 = | 999 | function promptResponseFromXAi( |
| 401 | /^(?:\/home\/[^/]+|\/Users\/[^/]+|[a-zA-Z]:\/Users\/[^/]+)\/\.grok\/sessions\/(?:[^/]+\/)+plan\.md$/; | 1000 | notification: XAiPromptCompleteNotification, |
| 402 | const CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH = new RegExp( | 1001 | ): EffectAcpSchema.PromptResponse { |
| 403 | CANONICAL_HOME_GROK_SESSION_PATH.source, | 1002 | const stopReason = normalizeXAiStopReason(notification.stopReason); |
| 404 | "i", | 1003 | const meta: Record<string, unknown> = { |
| 405 | ); | 1004 | sessionId: notification.sessionId, |
| 406 | 1005 | }; | |
| 407 | /** | 1006 | if (notification.stopReason === undefined) { |
| 408 | * True when a path is Grok's session plan file under a Grok home | 1007 | meta[xAiStopReasonMissingMetaKey] = true; |
| 409 | * (`~/.grok/sessions/.../plan.md`, `$HOME/.grok/sessions/...`, or `$GROK_HOME/sessions/...`). | ||
| 410 | * Deliberately does not match workspace files named `plan.md` (e.g. docs/plan.md | ||
| 411 | * or a repo-local `.grok/sessions/.../plan.md`). | ||
| 412 | */ | ||
| 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; | ||
| 429 | } | 1008 | } |
| 430 | for (const prefix of grokPlanSessionPrefixes(host.environment)) { | 1009 | if (notification.promptId !== undefined) { |
| 431 | const needle = win32 ? prefix.toLowerCase() : prefix; | 1010 | meta.promptId = notification.promptId; |
| 432 | if (!haystack.startsWith(needle)) { | 1011 | meta.requestId = notification.promptId; |
| 433 | continue; | ||
| 434 | } | ||
| 435 | const rest = haystack.slice(needle.length); | ||
| 436 | // Session layout: <home>/.grok/sessions/<encoded-cwd>/<session-id>/plan.md | ||
| 437 | if (rest !== "plan.md" && rest.endsWith("plan.md")) { | ||
| 438 | return true; | ||
| 439 | } | ||
| 440 | } | 1012 | } |
| 441 | return ( | 1013 | if (notification.agentResult !== undefined) { |
| 442 | win32 ? CASE_INSENSITIVE_CANONICAL_HOME_GROK_SESSION_PATH : CANONICAL_HOME_GROK_SESSION_PATH | 1014 | meta.agentResult = notification.agentResult; |
| 443 | ).test(haystack); | ||
| 444 | } | ||
| 445 | |||
| 446 | /** | ||
| 447 | * Extract plan markdown from a Grok write/edit tool call targeting plan.md. | ||
| 448 | * Used so T3 can show the plan while plan mode is still active (before exit). | ||
| 449 | */ | ||
| 450 | export function extractGrokPlanMarkdownFromToolCallData( | ||
| 451 | data: Record<string, unknown> | undefined, | ||
| 452 | host: GrokPlanPathHost, | ||
| 453 | ): string | undefined { | ||
| 454 | if (!data) { | ||
| 455 | return undefined; | ||
| 456 | } | 1015 | } |
| 457 | 1016 | return { | |
| 458 | let sawPlanWrite = false; | 1017 | stopReason, |
| 459 | const takePlanText = ( | 1018 | _meta: meta, |
| 460 | value: string | undefined, | ||
| 461 | filePath: string | undefined, | ||
| 462 | ): string | undefined => { | ||
| 463 | if (!isGrokPlanMarkdownPath(filePath, host) || value === undefined) { | ||
| 464 | return undefined; | ||
| 465 | } | ||
| 466 | sawPlanWrite = true; | ||
| 467 | const trimmed = value.trim(); | ||
| 468 | return trimmed.length > 0 ? trimmed : undefined; | ||
| 469 | }; | 1019 | }; |
| 470 | |||
| 471 | const rawInput = data.rawInput; | ||
| 472 | if (isRecord(rawInput)) { | ||
| 473 | const filePath = | ||
| 474 | (typeof rawInput.file_path === "string" ? rawInput.file_path : undefined) ?? | ||
| 475 | (typeof rawInput.path === "string" ? rawInput.path : undefined); | ||
| 476 | const content = typeof rawInput.content === "string" ? rawInput.content : undefined; | ||
| 477 | const fromRaw = takePlanText(content, filePath); | ||
| 478 | if (fromRaw !== undefined) { | ||
| 479 | return fromRaw; | ||
| 480 | } | ||
| 481 | } | ||
| 482 | |||
| 483 | const content = data.content; | ||
| 484 | if (Array.isArray(content)) { | ||
| 485 | for (const block of content) { | ||
| 486 | if (!isRecord(block) || block.type !== "diff") { | ||
| 487 | continue; | ||
| 488 | } | ||
| 489 | const path = typeof block.path === "string" ? block.path : undefined; | ||
| 490 | const newText = typeof block.newText === "string" ? block.newText : undefined; | ||
| 491 | const fromDiff = takePlanText(newText, path); | ||
| 492 | if (fromDiff !== undefined) { | ||
| 493 | return fromDiff; | ||
| 494 | } | ||
| 495 | } | ||
| 496 | } | ||
| 497 | |||
| 498 | return sawPlanWrite ? "" : undefined; | ||
| 499 | } | ||
| 500 | |||
| 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 | const registerXAiPromptCompletionFallback = ( | 1022 | const registerXAiPromptCompletionFallback = ( |
| 583 | pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>, | 1023 | pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>, |
| 584 | sessionId: string, | 1024 | sessionId: string, |
| 585 | promptId: string, | 1025 | promptId: string, |
| 586 | ) => | 1026 | ) => |
| 587 | Deferred.make<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>().pipe( | 1027 | Deferred.make<EffectAcpSchema.PromptResponse>().pipe( |
| 588 | Effect.tap((deferred) => | 1028 | Effect.tap((deferred) => |
| 589 | Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]), | 1029 | Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]), |
| 590 | ), | 1030 | ), |
| @@ -593,7 +1033,7 @@ const registerXAiPromptCompletionFallback = ( · 2 unmodified lines | |||
| 593 | 1033 | ||
| 594 | const unregisterXAiPromptCompletionFallback = ( | 1034 | const unregisterXAiPromptCompletionFallback = ( |
| 595 | pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>, | 1035 | pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>, |
| 596 | deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpRequestError>, | 1036 | deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse>, |
| 597 | ) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred)); | 1037 | ) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred)); |
| 598 | 1038 | ||
| 599 | const abortPendingPromptCompletions = ( | 1039 | const abortPendingPromptCompletions = ( |
| @@ -655,7 +1095,15 @@ const resolveXAiPromptCompletionFallback = ({ · 55 unmodified lines | |||
| 655 | entry.sessionId === notification.sessionId && | 1095 | entry.sessionId === notification.sessionId && |
| 656 | entry.promptId === notification.promptId, | 1096 | entry.promptId === notification.promptId, |
| 657 | ) | 1097 | ) |
| 658 | : pending.findIndex((entry) => entry.sessionId === notification.sessionId); | 1098 | : (() => { |
| 1099 | const sessionPendingIndexes = pending.flatMap((entry, entryIndex) => | ||
| 1100 | entry.sessionId === notification.sessionId ? [entryIndex] : [], | ||
| 1101 | ); | ||
| 1102 | if (sessionPendingIndexes.length !== 1) { | ||
| 1103 | return -1; | ||
| 1104 | } | ||
| 1105 | return sessionPendingIndexes[0] ?? -1; | ||
| 1106 | })(); | ||
| 659 | if (index < 0) { | 1107 | if (index < 0) { |
| 660 | return [Effect.void, pending] as const; | 1108 | return [Effect.void, pending] as const; |
| 661 | } | 1109 | } |
| @@ -664,54 +1112,22 @@ const resolveXAiPromptCompletionFallback = ({ · 2 unmodified lines | |||
| 664 | return [Effect.void, pending] as const; | 1112 | return [Effect.void, pending] as const; |
| 665 | } | 1113 | } |
| 666 | return [ | 1114 | return [ |
| 667 | settleXAiPromptCompletion(entry.deferred, notification), | 1115 | Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid), |
| 668 | [...pending.slice(0, index), ...pending.slice(index + 1)], | 1116 | [...pending.slice(0, index), ...pending.slice(index + 1)], |
| 669 | ] as const; | 1117 | ] as const; |
| 670 | }).pipe(Effect.flatten); | 1118 | }).pipe(Effect.flatten); |
| 671 | }), | 1119 | }), |
| 672 | ); | 1120 | ); |
| 673 | 1121 | ||
| 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 | const rememberCompletedXAiPromptId = ( | 1122 | const rememberCompletedXAiPromptId = ( |
| 710 | completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>, | 1123 | completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>, |
| 711 | response: EffectAcpSchema.PromptResponse, | 1124 | response: EffectAcpSchema.PromptResponse, |
| 712 | fallbackPromptId: string, | 1125 | fallbackPromptId: string, |
| 713 | ) => { | 1126 | ) => { |
| 714 | const promptId = promptIdFromResponse(response) ?? fallbackPromptId; | 1127 | const promptId = promptIdFromResponse(response) ?? fallbackPromptId; |
| 1128 | if (promptId.length === 0) { | ||
| 1129 | return Effect.void; | ||
| 1130 | } | ||
| 715 | return Ref.update(completedPromptIdsRef, (completedPromptIds) => { | 1131 | return Ref.update(completedPromptIdsRef, (completedPromptIds) => { |
| 716 | if (completedPromptIds.includes(promptId)) { | 1132 | if (completedPromptIds.includes(promptId)) { |
| 717 | return completedPromptIds; | 1133 | return completedPromptIds; |
| @@ -720,14 +1136,111 @@ const rememberCompletedXAiPromptId = ( · 2 unmodified lines | |||
| 720 | }); | 1136 | }); |
| 721 | }; | 1137 | }; |
| 722 | 1138 | ||
| 723 | function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined { | 1139 | /** |
| 724 | const meta = response._meta; | 1140 | * Grok-specific ACP runtime wrapper. Races `session/prompt` against root-matched |
| 725 | if (meta === null || typeof meta !== "object") { | 1141 | * terminal notifications: |
| 726 | return undefined; | 1142 | * - `_x.ai/session/update` + `turn_completed` + matching `prompt_id` (current CLI) |
| 727 | } | 1143 | * - `_x.ai/session/prompt_complete` (legacy / alternate builds) |
| 728 | const promptId = meta.promptId ?? meta.requestId; | 1144 | * |
| 729 | return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined; | 1145 | * Pending entries are keyed by root sessionId + T3-injected promptId, so |
| 730 | } | 1146 | * foreign/child sessions and `task-completed-*` ids do not settle the root turn. |
| 1147 | */ | ||
| 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 | export function promptResponseHasMissingXAiStopReason( | 1245 | export function promptResponseHasMissingXAiStopReason( |
| 733 | response: EffectAcpSchema.PromptResponse, | 1246 | response: EffectAcpSchema.PromptResponse, |
| @@ -735,39 +1248,3 @@ export function promptResponseHasMissingXAiStopReason( · 1 unmodified line | |||
| 735 | const meta = response._meta; | 1248 | const meta = response._meta; |
| 736 | return meta !== null && typeof meta === "object" && meta[xAiStopReasonMissingMetaKey] === true; | 1249 | return meta !== null && typeof meta === "object" && meta[xAiStopReasonMissingMetaKey] === true; |
| 737 | } | 1250 | } |
| 738 | |||
| 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 | import { describe, expect, it } from "vite-plus/test"; | 12 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 13 | ||
| 4 | import type { Thread } from "../types"; | 14 | import type { Thread } from "../types"; |
| @@ -11,27 +21,17 @@ import { · 6 unmodified lines | |||
| 11 | buildLoadingThreadFromShell, | 21 | buildLoadingThreadFromShell, |
| 12 | buildThreadTurnInterruptInput, | 22 | buildThreadTurnInterruptInput, |
| 13 | createLocalDispatchSnapshot, | 23 | createLocalDispatchSnapshot, |
| 24 | deriveCommittedServerUserMessageIds, | ||
| 14 | deriveComposerSendState, | 25 | deriveComposerSendState, |
| 15 | dismissBranchMismatchForSession, | 26 | dismissBranchMismatchForSession, |
| 16 | ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, | ||
| 17 | getStartedThreadModelChangeBlockReason, | 27 | getStartedThreadModelChangeBlockReason, |
| 18 | isVideoPreviewRequestCurrent, | ||
| 19 | hasEnvironmentReconnectWarningGraceElapsed, | ||
| 20 | hasServerAcknowledgedLocalDispatch, | 28 | hasServerAcknowledgedLocalDispatch, |
| 21 | isBranchMismatchDismissedForSession, | 29 | isBranchMismatchDismissedForSession, |
| 22 | reconcileMountedTerminalThreadIds, | 30 | reconcileMountedTerminalThreadIds, |
| 23 | reconcileRetainedMountedThreadIds, | 31 | reconcileRetainedMountedThreadIds, |
| 24 | resolveBackgroundDraftWorkspaceOptions, | ||
| 25 | resolveDraftPromotionNavigationTarget, | ||
| 26 | resolveProactiveTurnDiffAction, | ||
| 27 | resolveThreadMetadataUpdateForNextTurn, | 32 | resolveThreadMetadataUpdateForNextTurn, |
| 28 | resolveSendEnvMode, | 33 | resolveSendEnvMode, |
| 29 | resolveDraftHeroState, | ||
| 30 | scheduleEnvironmentReconnectWarning, | ||
| 31 | startNewThreadForProject, | 34 | startNewThreadForProject, |
| 32 | codexArtifactTemplatePromptToAppend, | ||
| 33 | shouldDockDraftHeroForSubmission, | ||
| 34 | shouldReleaseTimelineAnchorForToolActivity, | ||
| 35 | shouldShowBranchMismatchBanner, | 35 | shouldShowBranchMismatchBanner, |
| 36 | shouldShowPlanFollowUpPrompt, | 36 | shouldShowPlanFollowUpPrompt, |
| 37 | shouldWriteThreadErrorToCurrentServerThread, | 37 | shouldWriteThreadErrorToCurrentServerThread, |
| @@ -259,184 +259,6 @@ const helloWorldTemplate: CodexArtifactTemplate = { · 221 unmodified lines | |||
| 259 | skillName: "artifact-template-hello-world", | 259 | skillName: "artifact-template-hello-world", |
| 260 | }; | 260 | }; |
| 261 | 261 | ||
| 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 | function makeThread(overrides: Partial<Thread> = {}): Thread { | 262 | function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 441 | return makeThreadFixture({ | 263 | return makeThreadFixture({ |
| 442 | id: threadId, | 264 | id: threadId, |
| @@ -800,23 +622,6 @@ describe("resolveSendEnvMode", () => { · 357 unmodified lines | |||
| 800 | }); | 622 | }); |
| 801 | }); | 623 | }); |
| 802 | 624 | ||
| 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 | describe("branchMismatchKey", () => { | 625 | describe("branchMismatchKey", () => { |
| 821 | it("builds a key from thread id and both branches", () => { | 626 | it("builds a key from thread id and both branches", () => { |
| 822 | expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( | 627 | expect(branchMismatchKey("thread-1", { threadBranch: "feat/a", currentBranch: "feat/b" })).toBe( |
| @@ -1042,29 +847,6 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 219 unmodified lines | |||
| 1042 | ).toBe(false); | 847 | ).toBe(false); |
| 1043 | }); | 848 | }); |
| 1044 | 849 | ||
| 1045 | it("keeps a follow-up active while its provider session is starting", () => { | ||
| 1046 | const localDispatch = createLocalDispatchSnapshot( | ||
| 1047 | makeThread({ latestTurn: completedTurn, session: readySession }), | ||
| 1048 | ); | ||
| 1049 | |||
| 1050 | expect( | ||
| 1051 | hasServerAcknowledgedLocalDispatch({ | ||
| 1052 | localDispatch, | ||
| 1053 | phase: "connecting", | ||
| 1054 | latestTurn: completedTurn, | ||
| 1055 | latestUserMessageId: MessageId.make("message-followup"), | ||
| 1056 | session: { | ||
| 1057 | ...readySession, | ||
| 1058 | status: "starting", | ||
| 1059 | updatedAt: "2026-03-29T00:01:00.000Z", | ||
| 1060 | }, | ||
| 1061 | hasPendingApproval: false, | ||
| 1062 | hasPendingUserInput: false, | ||
| 1063 | threadError: null, | ||
| 1064 | }), | ||
| 1065 | ).toBe(false); | ||
| 1066 | }); | ||
| 1067 | |||
| 1068 | it("acknowledges a settled newer turn", () => { | 850 | it("acknowledges a settled newer turn", () => { |
| 1069 | const localDispatch = createLocalDispatchSnapshot( | 851 | const localDispatch = createLocalDispatchSnapshot( |
| 1070 | makeThread({ latestRun: completedTurn, runtime: readySession }), | 852 | makeThread({ latestRun: completedTurn, runtime: readySession }), |
| @@ -1195,3 +977,103 @@ describe("hasServerAcknowledgedLocalDispatch", () => { · 124 unmodified lines | |||
| 1195 | expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); | 977 | expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); |
| 1196 | }); | 978 | }); |
| 1197 | }); | 979 | }); |
| 980 | |||
| 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 | isProviderDriverKind, | 6 | isProviderDriverKind, |
| 7 | ProjectId, | 7 | ProjectId, |
| 8 | type ModelSelection, | 8 | type ModelSelection, |
| 9 | type ProviderInteractionMode, | 9 | type OrchestrationV2ProjectedTurnItem, |
| 10 | type ProviderDriverKind, | 10 | type ProviderDriverKind, |
| 11 | type ServerProvider, | 11 | type ServerProvider, |
| 12 | type ScopedProjectRef, | 12 | type ScopedProjectRef, |
| @@ -560,6 +560,22 @@ export function createLocalDispatchSnapshot( · 547 unmodified lines | |||
| 560 | }; | 560 | }; |
| 561 | } | 561 | } |
| 562 | 562 | ||
| 563 | /** | ||
| 564 | * The timeline renders committed user rows from `visibleTurnItems`, but | ||
| 565 | * `message.updated` can land in `projection.messages` one event earlier than | ||
| 566 | * the matching `turn-item.updated`. Basing optimistic eviction on visible user | ||
| 567 | * turn items avoids dropping steer rows in that gap. | ||
| 568 | */ | ||
| 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 | export function hasServerAcknowledgedLocalDispatch(input: { | 579 | export function hasServerAcknowledgedLocalDispatch(input: { |
| 564 | localDispatch: LocalDispatchSnapshot | null; | 580 | localDispatch: LocalDispatchSnapshot | null; |
| 565 | phase: SessionPhase; | 581 | phase: SessionPhase; |
| @@ -140,7 +140,6 @@ import { · 139 unmodified lines | |||
| 140 | selectThreadPanelOpen, | 140 | selectThreadPanelOpen, |
| 141 | selectThreadRightPanelState, | 141 | selectThreadRightPanelState, |
| 142 | type RightPanelSurface, | 142 | type RightPanelSurface, |
| 143 | updatePullRequestTabStatus, | ||
| 144 | useRightPanelStore, | 143 | useRightPanelStore, |
| 145 | } from "../rightPanelStore"; | 144 | } from "../rightPanelStore"; |
| 146 | import { | 145 | import { |
| @@ -157,11 +156,7 @@ import { · 10 unmodified lines | |||
| 157 | selectThreadPreviewMiniPlayer, | 156 | selectThreadPreviewMiniPlayer, |
| 158 | usePreviewMiniPlayerStore, | 157 | usePreviewMiniPlayerStore, |
| 159 | } from "../previewMiniPlayerStore"; | 158 | } from "../previewMiniPlayerStore"; |
| 160 | import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic"; | 159 | import { RightPanelTabs } from "./RightPanelTabs"; |
| 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"; | ||
| 165 | import { AgentsPanel } from "./AgentsPanel"; | 160 | import { AgentsPanel } from "./AgentsPanel"; |
| 166 | import { | 161 | import { |
| 167 | deriveAgentPanelModel, | 162 | deriveAgentPanelModel, |
| @@ -183,14 +178,9 @@ import { · 15 unmodified lines | |||
| 183 | projectScriptIdFromCommand, | 178 | projectScriptIdFromCommand, |
| 184 | } from "~/projectScripts"; | 179 | } from "~/projectScripts"; |
| 185 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; | 180 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; |
| 186 | import { useBrowserHistoryStore } from "~/browserHistoryStore"; | ||
| 187 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; | 181 | import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; |
| 188 | import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; | 182 | import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; |
| 189 | import { | 183 | import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; |
| 190 | useClientSettings, | ||
| 191 | useClientSettingsHydrated, | ||
| 192 | useEnvironmentSettings, | ||
| 193 | } from "../hooks/useSettings"; | ||
| 194 | import { useNowMinute } from "../hooks/useNowMinute"; | 184 | import { useNowMinute } from "../hooks/useNowMinute"; |
| 195 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | 185 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; |
| 196 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; | 186 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| @@ -198,7 +188,6 @@ import { getTerminalFocusOwner } from "../lib/terminalFocus"; · 1 unmodified line | |||
| 198 | import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; | 188 | import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; |
| 199 | import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; | 189 | import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; |
| 200 | import { | 190 | import { |
| 201 | derivePhysicalProjectKey, | ||
| 202 | deriveLogicalProjectKeyFromSettings, | 191 | deriveLogicalProjectKeyFromSettings, |
| 203 | selectProjectGroupingSettings, | 192 | selectProjectGroupingSettings, |
| 204 | } from "../logicalProject"; | 193 | } from "../logicalProject"; |
| @@ -311,10 +300,9 @@ import { · 106 unmodified lines | |||
| 311 | buildThreadTurnInterruptInput, | 300 | buildThreadTurnInterruptInput, |
| 312 | collectUserMessageBlobPreviewUrls, | 301 | collectUserMessageBlobPreviewUrls, |
| 313 | createLocalDispatchSnapshot, | 302 | createLocalDispatchSnapshot, |
| 303 | deriveCommittedServerUserMessageIds, | ||
| 314 | deriveComposerSendState, | 304 | deriveComposerSendState, |
| 315 | dismissBranchMismatchForSession, | 305 | dismissBranchMismatchForSession, |
| 316 | hasEnvironmentReconnectWarningGraceElapsed, | ||
| 317 | scheduleEnvironmentReconnectWarning, | ||
| 318 | hasServerAcknowledgedLocalDispatch, | 306 | hasServerAcknowledgedLocalDispatch, |
| 319 | isBranchMismatchDismissedForSession, | 307 | isBranchMismatchDismissedForSession, |
| 320 | shouldShowBranchMismatchBanner, | 308 | shouldShowBranchMismatchBanner, |
| @@ -519,14 +507,6 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { · 198 unmodified lines | |||
| 519 | if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; | 507 | if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; |
| 520 | if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false; | 508 | if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false; |
| 521 | 509 | ||
| 522 | // The right-panel surface launcher claims its shortcut letters while it is | ||
| 523 | // visible (data attribute set in RightPanelTabs); those keys open surfaces | ||
| 524 | // instead of typing into the composer. | ||
| 525 | const launcherKeys = document | ||
| 526 | .querySelector("[data-surface-launcher-keys]") | ||
| 527 | ?.getAttribute("data-surface-launcher-keys"); | ||
| 528 | if (launcherKeys && launcherKeys.toLowerCase().includes(event.key.toLowerCase())) return false; | ||
| 529 | |||
| 530 | return true; | 510 | return true; |
| 531 | } | 511 | } |
| 532 | 512 | ||
| @@ -1297,8 +1277,8 @@ function ChatViewContent(props: ChatViewProps) { · 764 unmodified lines | |||
| 1297 | routeKind === "server" ? routeThreadRef : null, | 1277 | routeKind === "server" ? routeThreadRef : null, |
| 1298 | ); | 1278 | ); |
| 1299 | const committedServerMessageIds = useMemo( | 1279 | const committedServerMessageIds = useMemo( |
| 1300 | () => new Set(serverProjection?.messages.map((message) => message.id) ?? []), | 1280 | () => deriveCommittedServerUserMessageIds(serverVisibleTurnItems), |
| 1301 | [serverProjection], | 1281 | [serverVisibleTurnItems], |
| 1302 | ); | 1282 | ); |
| 1303 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); | 1283 | const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); |
| 1304 | const activeThreadLastVisitedAt = useUiStateStore( | 1284 | const activeThreadLastVisitedAt = useUiStateStore( |
| @@ -1636,7 +1616,11 @@ function ChatViewContent(props: ChatViewProps) { · 331 unmodified lines | |||
| 1636 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; | 1616 | const isLocalDraftThread = !isServerThread && localDraftThread !== undefined; |
| 1637 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; | 1617 | const canCheckoutPullRequestIntoThread = isLocalDraftThread; |
| 1638 | const activeThreadId = activeThread?.id ?? null; | 1618 | const activeThreadId = activeThread?.id ?? null; |
| 1639 | const activeMessageCount = isServerThread ? committedServerMessageIds.size : 0; | 1619 | // Prefer the larger of turn-item-committed ids and projection messages so |
| 1620 | // env lock does not unlock while turn items lag projection hydration. | ||
| 1621 | const activeMessageCount = isServerThread | ||
| 1622 | ? Math.max(committedServerMessageIds.size, serverProjection?.messages.length ?? 0) | ||
| 1623 | : 0; | ||
| 1640 | const runningTerminalIds = useThreadRunningTerminalIds({ | 1624 | const runningTerminalIds = useThreadRunningTerminalIds({ |
| 1641 | environmentId: activeThread?.environmentId ?? null, | 1625 | environmentId: activeThread?.environmentId ?? null, |
| 1642 | threadId: activeThreadId, | 1626 | threadId: activeThreadId, |
| @@ -1672,11 +1656,8 @@ function ChatViewContent(props: ChatViewProps) { · 29 unmodified lines | |||
| 1672 | return labels; | 1656 | return labels; |
| 1673 | }, [activeThreadKnownSessions]); | 1657 | }, [activeThreadKnownSessions]); |
| 1674 | const activeThreadRef = useMemo( | 1658 | const activeThreadRef = useMemo( |
| 1675 | () => | 1659 | () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), |
| 1676 | activeThreadEnvironmentId && activeThreadId | 1660 | [activeThread], |
| 1677 | ? scopeThreadRef(activeThreadEnvironmentId, activeThreadId) | ||
| 1678 | : null, | ||
| 1679 | [activeThreadEnvironmentId, activeThreadId], | ||
| 1680 | ); | 1661 | ); |
| 1681 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; | 1662 | const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; |
| 1682 | const [timelineAnchor, setTimelineAnchor] = useState<{ | 1663 | const [timelineAnchor, setTimelineAnchor] = useState<{ |
| @@ -1697,23 +1678,6 @@ function ChatViewContent(props: ChatViewProps) { · 14 unmodified lines | |||
| 1697 | const activeRightPanelSurface = useRightPanelStore((state) => | 1678 | const activeRightPanelSurface = useRightPanelStore((state) => |
| 1698 | selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), | 1679 | selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), |
| 1699 | ); | 1680 | ); |
| 1700 | const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState< | ||
| 1701 | Record<string, PullRequestTabStatus> | ||
| 1702 | >({}); | ||
| 1703 | // Keyed by the surface the panel is showing rather than by a key rebuilt from the status, so | ||
| 1704 | // the tab is found again whether or not that surface was opened with an environment on it. | ||
| 1705 | const activePullRequestSurfaceId = | ||
| 1706 | activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; | ||
| 1707 | const updatePullRequestTabStatusFromPanel = useCallback( | ||
| 1708 | (status: PullRequestTabStatus) => { | ||
| 1709 | const id = activePullRequestSurfaceId; | ||
| 1710 | if (id === undefined) return; | ||
| 1711 | setPullRequestTabStatuses((current) => updatePullRequestTabStatus(current, id, status)); | ||
| 1712 | }, | ||
| 1713 | [activePullRequestSurfaceId], | ||
| 1714 | ); | ||
| 1715 | const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); | ||
| 1716 | const sidebarPrRefreshKeyRef = useRef<string | null>(null); | ||
| 1717 | const activeFileSurface = | 1681 | const activeFileSurface = |
| 1718 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; | 1682 | activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; |
| 1719 | const activePreviewState = useThreadPreviewState(activeThreadRef); | 1683 | const activePreviewState = useThreadPreviewState(activeThreadRef); |
| @@ -1847,7 +1811,6 @@ function ChatViewContent(props: ChatViewProps) { · 127 unmodified lines | |||
| 1847 | const activeProjectKey = activeProject | 1811 | const activeProjectKey = activeProject |
| 1848 | ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` | 1812 | ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` |
| 1849 | : null; | 1813 | : null; |
| 1850 | const clientSettingsHydrated = useClientSettingsHydrated(); | ||
| 1851 | const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< | 1814 | const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< |
| 1852 | ReadonlyMap<string, ReadonlySet<string>> | 1815 | ReadonlyMap<string, ReadonlySet<string>> |
| 1853 | >(() => new Map()); | 1816 | >(() => new Map()); |
| @@ -1886,54 +1849,11 @@ function ChatViewContent(props: ChatViewProps) { · 32 unmodified lines | |||
| 1886 | // drive the environment picker in BranchToolbar. | 1849 | // drive the environment picker in BranchToolbar. |
| 1887 | const allProjects = useProjects(); | 1850 | const allProjects = useProjects(); |
| 1888 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; | 1851 | const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; |
| 1889 | useEffect(() => { | ||
| 1890 | if (!clientSettingsHydrated || !activeThreadRef || !activeProject) return; | ||
| 1891 | // Reuse the sidebar's grouping so history follows the project rows the user | ||
| 1892 | // sees. Deriving the key from the active project alone would miss the | ||
| 1893 | // identity a duplicate row borrows from its siblings. | ||
| 1894 | const logicalKeyByPhysicalKey = buildPhysicalToLogicalProjectKeyMap({ | ||
| 1895 | projects: allProjects, | ||
| 1896 | settings: projectGroupingSettings, | ||
| 1897 | primaryEnvironmentId, | ||
| 1898 | }); | ||
| 1899 | useBrowserHistoryStore | ||
| 1900 | .getState() | ||
| 1901 | .registerThreadProject( | ||
| 1902 | activeThreadRef, | ||
| 1903 | logicalKeyByPhysicalKey.get(derivePhysicalProjectKey(activeProject)) ?? | ||
| 1904 | deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings), | ||
| 1905 | ); | ||
| 1906 | }, [ | ||
| 1907 | activeProject, | ||
| 1908 | activeThreadRef, | ||
| 1909 | allProjects, | ||
| 1910 | clientSettingsHydrated, | ||
| 1911 | primaryEnvironmentId, | ||
| 1912 | projectGroupingSettings, | ||
| 1913 | ]); | ||
| 1914 | const activeEnvironment = | 1852 | const activeEnvironment = |
| 1915 | activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); | 1853 | activeThread == null ? null : (environmentById.get(activeThread.environmentId) ?? null); |
| 1916 | const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; | 1854 | const activeEnvironmentConnectionPhase = activeEnvironment?.connection.phase ?? "available"; |
| 1917 | const activeEnvironmentUnavailable = | 1855 | const activeEnvironmentUnavailable = |
| 1918 | activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; | 1856 | activeEnvironment !== null && activeEnvironmentConnectionPhase !== "connected"; |
| 1919 | const activeReconnectingEnvironmentId = | ||
| 1920 | activeEnvironmentConnectionPhase === "connecting" || | ||
| 1921 | activeEnvironmentConnectionPhase === "reconnecting" | ||
| 1922 | ? (activeEnvironment?.environmentId ?? null) | ||
| 1923 | : null; | ||
| 1924 | const [reconnectWarningGraceElapsedEnvironmentId, setReconnectWarningGraceElapsedEnvironmentId] = | ||
| 1925 | useState<EnvironmentId | null>(null); | ||
| 1926 | const reconnectWarningGraceElapsed = hasEnvironmentReconnectWarningGraceElapsed( | ||
| 1927 | activeReconnectingEnvironmentId, | ||
| 1928 | reconnectWarningGraceElapsedEnvironmentId, | ||
| 1929 | ); | ||
| 1930 | useEffect(() => { | ||
| 1931 | setReconnectWarningGraceElapsedEnvironmentId(null); | ||
| 1932 | if (activeReconnectingEnvironmentId === null) return; | ||
| 1933 | return scheduleEnvironmentReconnectWarning(() => | ||
| 1934 | setReconnectWarningGraceElapsedEnvironmentId(activeReconnectingEnvironmentId), | ||
| 1935 | ); | ||
| 1936 | }, [activeReconnectingEnvironmentId]); | ||
| 1937 | const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; | 1857 | const activeEnvironmentUnavailableLabel = activeEnvironment?.label ?? null; |
| 1938 | const activeEnvironmentUnavailableState = useMemo<EnvironmentUnavailableState | null>(() => { | 1858 | const activeEnvironmentUnavailableState = useMemo<EnvironmentUnavailableState | null>(() => { |
| 1939 | if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { | 1859 | if (!activeEnvironmentUnavailable || !activeEnvironmentUnavailableLabel || !activeEnvironment) { |
| @@ -1962,6 +1882,7 @@ function ChatViewContent(props: ChatViewProps) { · 22 unmodified lines | |||
| 1962 | }, | 1882 | }, |
| 1963 | [retryEnvironment], | 1883 | [retryEnvironment], |
| 1964 | ); | 1884 | ); |
| 1885 | const projectGroupingSettings = selectProjectGroupingSettings(settings); | ||
| 1965 | const logicalProjectEnvironments = useMemo(() => { | 1886 | const logicalProjectEnvironments = useMemo(() => { |
| 1966 | if (!activeProject) return []; | 1887 | if (!activeProject) return []; |
| 1967 | const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); | 1888 | const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); |
| @@ -2115,8 +2036,6 @@ function ChatViewContent(props: ChatViewProps) { · 147 unmodified lines | |||
| 2115 | const serverConfig = activeThread | 2036 | const serverConfig = activeThread |
| 2116 | ? (activeEnvironment?.serverConfig ?? null) | 2037 | ? (activeEnvironment?.serverConfig ?? null) |
| 2117 | : (primaryEnvironment?.serverConfig ?? null); | 2038 | : (primaryEnvironment?.serverConfig ?? null); |
| 2118 | const pullRequestsCapabilityKnown = serverConfig !== null; | ||
| 2119 | const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; | ||
| 2120 | const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); | 2039 | const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); |
| 2121 | const versionMismatchDismissKey = | 2040 | const versionMismatchDismissKey = |
| 2122 | versionMismatch && activeThread | 2041 | versionMismatch && activeThread |
| @@ -3266,27 +3185,6 @@ function ChatViewContent(props: ChatViewProps) { · 1143 unmodified lines | |||
| 3266 | }, | 3185 | }, |
| 3267 | [activeProject, activeThreadRef], | 3186 | [activeProject, activeThreadRef], |
| 3268 | ); | 3187 | ); |
| 3269 | // The thread's own change request, placed against the project it belongs to. Without a | ||
| 3270 | // project there is nothing to resolve it against, so the caller falls back to the browser. | ||
| 3271 | const threadRepository = activeProject?.repositoryIdentity?.displayName ?? null; | ||
| 3272 | const openThreadPullRequest = useCallback( | ||
| 3273 | (number: number) => { | ||
| 3274 | if ( | ||
| 3275 | !supportsPullRequests || | ||
| 3276 | !activeThreadRef || | ||
| 3277 | !activeProject || | ||
| 3278 | threadRepository === null | ||
| 3279 | ) { | ||
| 3280 | return; | ||
| 3281 | } | ||
| 3282 | useRightPanelStore.getState().openPullRequest(activeThreadRef, { | ||
| 3283 | projectId: activeProject.id, | ||
| 3284 | repository: threadRepository, | ||
| 3285 | number, | ||
| 3286 | }); | ||
| 3287 | }, | ||
| 3288 | [activeProject, activeThreadRef, supportsPullRequests, threadRepository], | ||
| 3289 | ); | ||
| 3290 | const togglePreviewPanel = useCallback(() => { | 3188 | const togglePreviewPanel = useCallback(() => { |
| 3291 | if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; | 3189 | if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; |
| 3292 | if (previewPanelOpen) { | 3190 | if (previewPanelOpen) { |
| @@ -4136,113 +4034,6 @@ function ChatViewContent(props: ChatViewProps) { · 843 unmodified lines | |||
| 4136 | threadBranch: activeThread?.branch ?? null, | 4034 | threadBranch: activeThread?.branch ?? null, |
| 4137 | gitStatus: gitStatusQuery.data ?? null, | 4035 | gitStatus: gitStatusQuery.data ?? null, |
| 4138 | }); | 4036 | }); |
| 4139 | const handlePullRequestTabStatusChange = useCallback( | ||
| 4140 | (status: PullRequestTabStatus) => { | ||
| 4141 | updatePullRequestTabStatusFromPanel(status); | ||
| 4142 | const source = threadPullRequestRefreshSource({ | ||
| 4143 | panel: status, | ||
| 4144 | thread: { | ||
| 4145 | repository: threadRepository, | ||
| 4146 | number: linkedThreadPullRequest?.number ?? activeThreadPr?.number ?? null, | ||
| 4147 | state: activeThreadPr?.state ?? null, | ||
| 4148 | linked: linkedThreadPullRequest !== null, | ||
| 4149 | }, | ||
| 4150 | }); | ||
| 4151 | if (source === null) { | ||
| 4152 | sidebarPrRefreshKeyRef.current = null; | ||
| 4153 | return; | ||
| 4154 | } | ||
| 4155 | const refreshKey = `${activeThreadKey}:${source}:${status.repository}#${status.number}:${status.state}`; | ||
| 4156 | if (sidebarPrRefreshKeyRef.current === refreshKey) return; | ||
| 4157 | sidebarPrRefreshKeyRef.current = refreshKey; | ||
| 4158 | |||
| 4159 | if (source === "linked-detail" && activeThreadRef && linkedThreadPullRequest) { | ||
| 4160 | appAtomRegistry.refresh( | ||
| 4161 | linkedPullRequestDetailAtom({ | ||
| 4162 | environmentId: activeThreadRef.environmentId, | ||
| 4163 | input: { | ||
| 4164 | projectId: linkedThreadPullRequest.projectId, | ||
| 4165 | repository: linkedThreadPullRequest.repository, | ||
| 4166 | number: linkedThreadPullRequest.number, | ||
| 4167 | }, | ||
| 4168 | }), | ||
| 4169 | ); | ||
| 4170 | return; | ||
| 4171 | } | ||
| 4172 | if (source === "vcs" && activeThreadRef && gitCwd !== null) { | ||
| 4173 | void refreshVcsStatus({ | ||
| 4174 | environmentId: activeThreadRef.environmentId, | ||
| 4175 | input: { cwd: gitCwd }, | ||
| 4176 | }).then(() => { | ||
| 4177 | if (sidebarPrRefreshKeyRef.current === refreshKey) { | ||
| 4178 | sidebarPrRefreshKeyRef.current = null; | ||
| 4179 | } | ||
| 4180 | }); | ||
| 4181 | } | ||
| 4182 | }, | ||
| 4183 | [ | ||
| 4184 | activeThreadKey, | ||
| 4185 | activeThreadPr?.number, | ||
| 4186 | activeThreadPr?.state, | ||
| 4187 | activeThreadRef, | ||
| 4188 | gitCwd, | ||
| 4189 | linkedThreadPullRequest, | ||
| 4190 | refreshVcsStatus, | ||
| 4191 | threadRepository, | ||
| 4192 | updatePullRequestTabStatusFromPanel, | ||
| 4193 | ], | ||
| 4194 | ); | ||
| 4195 | const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef); | ||
| 4196 | const activeThreadReferenceCopyTarget = useMemo( | ||
| 4197 | () => | ||
| 4198 | activeThreadId === null || !isServerThread | ||
| 4199 | ? null | ||
| 4200 | : resolveThreadReferenceCopyTarget({ | ||
| 4201 | threadId: activeThreadId, | ||
| 4202 | openPanelPullRequestUrl, | ||
| 4203 | linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, | ||
| 4204 | detectedPullRequestUrl: activeThreadPr?.url ?? null, | ||
| 4205 | }), | ||
| 4206 | [ | ||
| 4207 | activeThreadId, | ||
| 4208 | activeThreadPr?.url, | ||
| 4209 | isServerThread, | ||
| 4210 | linkedThreadPullRequest?.url, | ||
| 4211 | openPanelPullRequestUrl, | ||
| 4212 | ], | ||
| 4213 | ); | ||
| 4214 | const copyActiveThreadReference = useCallback(() => { | ||
| 4215 | const target = activeThreadReferenceCopyTarget; | ||
| 4216 | if (target === null) return; | ||
| 4217 | void writeTextToClipboard(target.value, target.clipboardTarget).then( | ||
| 4218 | (didCopy) => { | ||
| 4219 | if (!didCopy) return; | ||
| 4220 | toastManager.add({ | ||
| 4221 | type: "success", | ||
| 4222 | title: target.successTitle, | ||
| 4223 | description: target.value, | ||
| 4224 | }); | ||
| 4225 | }, | ||
| 4226 | (error) => { | ||
| 4227 | console.error(error); | ||
| 4228 | toastManager.add( | ||
| 4229 | stackedThreadToast({ | ||
| 4230 | type: "error", | ||
| 4231 | title: target.failureTitle, | ||
| 4232 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 4233 | }), | ||
| 4234 | ); | ||
| 4235 | }, | ||
| 4236 | ); | ||
| 4237 | }, [activeThreadReferenceCopyTarget]); | ||
| 4238 | // The right panel offers the thread's own change request, so it can only offer it once the | ||
| 4239 | // branch has one; until then the picker says so rather than opening an empty panel. | ||
| 4240 | const addPullRequestSurface = useCallback(() => { | ||
| 4241 | if (activeThreadPr === null) return; | ||
| 4242 | openThreadPullRequest(activeThreadPr.number); | ||
| 4243 | }, [activeThreadPr, openThreadPullRequest]); | ||
| 4244 | const pullRequestSurfaceAvailable = | ||
| 4245 | supportsPullRequests && activeThreadPr !== null && threadRepository !== null; | ||
| 4246 | const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; | 4037 | const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; |
| 4247 | const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; | 4038 | const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; |
| 4248 | const nowMinute = useNowMinute(); | 4039 | const nowMinute = useNowMinute(); |
| @@ -4965,7 +4756,6 @@ function ChatViewContent(props: ChatViewProps) { · 716 unmodified lines | |||
| 4965 | "This will discard newer messages and turn diffs in this thread.", | 4756 | "This will discard newer messages and turn diffs in this thread.", |
| 4966 | "This action cannot be undone.", | 4757 | "This action cannot be undone.", |
| 4967 | ].join("\n"), | 4758 | ].join("\n"), |
| 4968 | { variant: "destructive" }, | ||
| 4969 | ); | 4759 | ); |
| 4970 | if (!confirmed) { | 4760 | if (!confirmed) { |
| 4971 | return; | 4761 | return; |
| @@ -6313,47 +6103,6 @@ function ChatViewContent(props: ChatViewProps) { · 1341 unmodified lines | |||
| 6313 | workspaceMutationId={workspaceMutationId} | 6103 | workspaceMutationId={workspaceMutationId} |
| 6314 | /> | 6104 | /> |
| 6315 | </Suspense> | 6105 | </Suspense> |
| 6316 | ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( | ||
| 6317 | <PullRequestDetailGhost /> | ||
| 6318 | ) : activeRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( | ||
| 6319 | <PullRequestsUnavailableState | ||
| 6320 | title="Pull requests unavailable" | ||
| 6321 | error="Update this environment's T3 Code server to browse pull requests." | ||
| 6322 | /> | ||
| 6323 | ) : activeRightPanelSurface?.kind === "pull-request" ? ( | ||
| 6324 | // No onClose: the surface tab's own X owns closing here, and a second X in the header | ||
| 6325 | // would be the same action twice. The thread context also drops the checkout button, so it | ||
| 6326 | // is only right for the thread's own pull request, whose branch is already under the | ||
| 6327 | // reader's feet. A link the agent wrote can open any other one here, and that one has to be | ||
| 6328 | // checkable out like it is anywhere else. | ||
| 6329 | <PullRequestDetailPanel | ||
| 6330 | key={`${activeRightPanelSurface.repository}#${activeRightPanelSurface.number}`} | ||
| 6331 | environmentId={activeThread.environmentId} | ||
| 6332 | reference={{ | ||
| 6333 | projectId: activeRightPanelSurface.projectId as ProjectId, | ||
| 6334 | repository: activeRightPanelSurface.repository, | ||
| 6335 | number: activeRightPanelSurface.number, | ||
| 6336 | }} | ||
| 6337 | context={ | ||
| 6338 | isThreadOwnPullRequest( | ||
| 6339 | { | ||
| 6340 | projectId: activeProject?.id ?? null, | ||
| 6341 | repository: threadRepository, | ||
| 6342 | number: activeThreadPr?.number ?? null, | ||
| 6343 | }, | ||
| 6344 | { | ||
| 6345 | projectId: activeRightPanelSurface.projectId, | ||
| 6346 | repository: activeRightPanelSurface.repository, | ||
| 6347 | number: activeRightPanelSurface.number, | ||
| 6348 | }, | ||
| 6349 | ) | ||
| 6350 | ? "thread" | ||
| 6351 | : "page" | ||
| 6352 | } | ||
| 6353 | chromeVariant="collapse" | ||
| 6354 | composerDraftTarget={composerDraftTarget} | ||
| 6355 | onStateChange={handlePullRequestTabStatusChange} | ||
| 6356 | /> | ||
| 6357 | ) : activeRightPanelSurface?.kind === "agents" ? ( | 6106 | ) : activeRightPanelSurface?.kind === "agents" ? ( |
| 6358 | <AgentsPanel | 6107 | <AgentsPanel |
| 6359 | model={agentPanelModel} | 6108 | model={agentPanelModel} |
| @@ -6838,16 +6587,10 @@ function ChatViewContent(props: ChatViewProps) { · 478 unmodified lines | |||
| 6838 | onAddTerminal={addTerminalSurface} | 6587 | onAddTerminal={addTerminalSurface} |
| 6839 | onAddDiff={addDiffSurface} | 6588 | onAddDiff={addDiffSurface} |
| 6840 | onAddFiles={addFilesSurface} | 6589 | onAddFiles={addFilesSurface} |
| 6841 | onAddPullRequest={addPullRequestSurface} | ||
| 6842 | onAddAgents={addAgentsSurface} | 6590 | onAddAgents={addAgentsSurface} |
| 6843 | browserAvailable={isPreviewSupportedInRuntime()} | 6591 | browserAvailable={isPreviewSupportedInRuntime()} |
| 6844 | terminalAvailable={activeProject !== null} | ||
| 6845 | diffAvailable={isServerThread && isGitRepo} | 6592 | diffAvailable={isServerThread && isGitRepo} |
| 6846 | filesAvailable={activeProject !== null} | 6593 | filesAvailable={activeProject !== null} |
| 6847 | pullRequestAvailable={pullRequestSurfaceAvailable} | ||
| 6848 | agentsAvailable | ||
| 6849 | pullRequestStatuses={pullRequestTabStatuses} | ||
| 6850 | liveAgentCount={agentPanelModel.liveCount} | ||
| 6851 | > | 6594 | > |
| 6852 | {rightPanelContent} | 6595 | {rightPanelContent} |
| 6853 | </RightPanelTabs> | 6596 | </RightPanelTabs> |
| @@ -6873,16 +6616,10 @@ function ChatViewContent(props: ChatViewProps) { · 19 unmodified lines | |||
| 6873 | onAddTerminal={addTerminalSurface} | 6616 | onAddTerminal={addTerminalSurface} |
| 6874 | onAddDiff={addDiffSurface} | 6617 | onAddDiff={addDiffSurface} |
| 6875 | onAddFiles={addFilesSurface} | 6618 | onAddFiles={addFilesSurface} |
| 6876 | onAddPullRequest={addPullRequestSurface} | ||
| 6877 | onAddAgents={addAgentsSurface} | 6619 | onAddAgents={addAgentsSurface} |
| 6878 | browserAvailable={isPreviewSupportedInRuntime()} | 6620 | browserAvailable={isPreviewSupportedInRuntime()} |
| 6879 | terminalAvailable={activeProject !== null} | ||
| 6880 | diffAvailable={isServerThread && isGitRepo} | 6621 | diffAvailable={isServerThread && isGitRepo} |
| 6881 | filesAvailable={activeProject !== null} | 6622 | filesAvailable={activeProject !== null} |
| 6882 | pullRequestAvailable={pullRequestSurfaceAvailable} | ||
| 6883 | agentsAvailable | ||
| 6884 | pullRequestStatuses={pullRequestTabStatuses} | ||
| 6885 | liveAgentCount={agentPanelModel.liveCount} | ||
| 6886 | > | 6623 | > |
| 6887 | {rightPanelContent} | 6624 | {rightPanelContent} |
| 6888 | </RightPanelTabs> | 6625 | </RightPanelTabs> |
| @@ -1369,10 +1369,6 @@ function ProposedPlanTimelineRow({ · 1368 unmodified lines | |||
| 1369 | 1369 | ||
| 1370 | type V2EventTone = "muted" | "warning" | "danger" | "success"; | 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 | function v2EventPresentation(item: OrchestrationV2TurnItem): { | 1372 | function v2EventPresentation(item: OrchestrationV2TurnItem): { |
| 1377 | readonly label: string; | 1373 | readonly label: string; |
| 1378 | readonly detail: string | null; | 1374 | readonly detail: string | null; |
| @@ -1429,14 +1425,6 @@ function v2EventPresentation(item: OrchestrationV2TurnItem): { · 50 unmodified lines | |||
| 1429 | icon: MinusIcon, | 1425 | icon: MinusIcon, |
| 1430 | }; | 1426 | }; |
| 1431 | } | 1427 | } |
| 1432 | case "subagent": | ||
| 1433 | return { | ||
| 1434 | label: subagentDisplayTitle(item.title ?? "Subagent"), | ||
| 1435 | detail: item.result ?? item.progress ?? item.prompt, | ||
| 1436 | tone: | ||
| 1437 | item.status === "failed" ? "danger" : item.status === "completed" ? "success" : "muted", | ||
| 1438 | icon: BotIcon, | ||
| 1439 | }; | ||
| 1440 | case "approval_request": | 1428 | case "approval_request": |
| 1441 | return { | 1429 | return { |
| 1442 | label: "Approval requested", | 1430 | label: "Approval requested", |
| @@ -1511,7 +1499,14 @@ function V2EventTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "event" · 68 unmodified lines | |||
| 1511 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> | 1499 | <div className="flex flex-wrap items-center gap-x-2 gap-y-1"> |
| 1512 | <span className="text-xs font-medium text-foreground/90">{presentation.label}</span> | 1500 | <span className="text-xs font-medium text-foreground/90">{presentation.label}</span> |
| 1513 | {item.status !== "completed" ? ( | 1501 | {item.status !== "completed" ? ( |
| 1514 | <span className="rounded-full border border-border/70 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"> | 1502 | <span |
| 1503 | className={cn( | ||
| 1504 | "rounded-full border px-1.5 py-0.5 font-mono text-[10px]", | ||
| 1505 | item.status === "failed" | ||
| 1506 | ? "border-destructive/40 text-destructive" | ||
| 1507 | : "border-border/70 text-muted-foreground", | ||
| 1508 | )} | ||
| 1509 | > | ||
| 1515 | {item.status} | 1510 | {item.status} |
| 1516 | </span> | 1511 | </span> |
| 1517 | ) : null} | 1512 | ) : null} |
| @@ -2303,8 +2298,19 @@ function workEntryPreview( · 785 unmodified lines | |||
| 2303 | workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">, | 2298 | workEntry: Pick<TimelineWorkEntry, "detail" | "command" | "changedFiles">, |
| 2304 | workspaceRoot: string | undefined, | 2299 | workspaceRoot: string | undefined, |
| 2305 | ) { | 2300 | ) { |
| 2301 | // Prefer stdout/detail so completed shell/monitor results are visible collapsed | ||
| 2302 | // (command alone hid ls listings behind expand-only inspector JSON). | ||
| 2303 | if (workEntry.detail?.trim()) { | ||
| 2304 | const lines = workEntry.detail | ||
| 2305 | .trim() | ||
| 2306 | .split(/\r?\n/) | ||
| 2307 | .map((line) => line.trim()) | ||
| 2308 | .filter((line) => line.length > 0); | ||
| 2309 | if (lines.length > 0) { | ||
| 2310 | return lines.slice(0, 3).join(" · "); | ||
| 2311 | } | ||
| 2312 | } | ||
| 2306 | if (workEntry.command) return workEntry.command; | 2313 | if (workEntry.command) return workEntry.command; |
| 2307 | if (workEntry.detail) return workEntry.detail; | ||
| 2308 | if ((workEntry.changedFiles?.length ?? 0) === 0) return null; | 2314 | if ((workEntry.changedFiles?.length ?? 0) === 0) return null; |
| 2309 | const [firstPath] = workEntry.changedFiles ?? []; | 2315 | const [firstPath] = workEntry.changedFiles ?? []; |
| 2310 | if (!firstPath) return null; | 2316 | if (!firstPath) return null; |
| @@ -594,6 +594,13 @@ describe("chat/editor shortcuts", () => { · 593 unmodified lines | |||
| 594 | platform: "Linux", | 594 | platform: "Linux", |
| 595 | }), | 595 | }), |
| 596 | ); | 596 | ); |
| 597 | assert.isFalse( | ||
| 598 | isOpenFavoriteEditorShortcut( | ||
| 599 | event({ key: "o", metaKey: true, repeat: true }), | ||
| 600 | DEFAULT_BINDINGS, | ||
| 601 | { platform: "MacIntel" }, | ||
| 602 | ), | ||
| 603 | ); | ||
| 597 | }); | 604 | }); |
| 598 | 605 | ||
| 599 | it("matches commandPalette.toggle shortcut outside terminal focus", () => { | 606 | it("matches commandPalette.toggle shortcut outside terminal focus", () => { |
| @@ -14,6 +14,7 @@ export interface ShortcutEventLike { · 13 unmodified lines | |||
| 14 | type?: string; | 14 | type?: string; |
| 15 | code?: string; | 15 | code?: string; |
| 16 | key: string; | 16 | key: string; |
| 17 | repeat?: boolean; | ||
| 17 | metaKey: boolean; | 18 | metaKey: boolean; |
| 18 | ctrlKey: boolean; | 19 | ctrlKey: boolean; |
| 19 | shiftKey: boolean; | 20 | shiftKey: boolean; |
| @@ -440,7 +441,10 @@ export function isOpenFavoriteEditorShortcut( · 420 unmodified lines | |||
| 440 | keybindings: ResolvedKeybindingsConfig, | 441 | keybindings: ResolvedKeybindingsConfig, |
| 441 | options?: ShortcutMatchOptions, | 442 | options?: ShortcutMatchOptions, |
| 442 | ): boolean { | 443 | ): boolean { |
| 443 | return matchesCommandShortcut(event, keybindings, "editor.openFavorite", options); | 444 | return ( |
| 445 | event.repeat !== true && | ||
| 446 | matchesCommandShortcut(event, keybindings, "editor.openFavorite", options) | ||
| 447 | ); | ||
| 444 | } | 448 | } |
| 445 | 449 | ||
| 446 | export function isTerminalClearShortcut( | 450 | export function isTerminalClearShortcut( |
| @@ -39,6 +39,7 @@ function shouldShowLocalTurnItem( · 38 unmodified lines | |||
| 39 | item, | 39 | item, |
| 40 | runs: projection.runs, | 40 | runs: projection.runs, |
| 41 | attempts: projection.attempts, | 41 | attempts: projection.attempts, |
| 42 | items: projection.turnItems, | ||
| 42 | }); | 43 | }); |
| 43 | } | 44 | } |
| 44 | 45 | ||
| @@ -1,5 +1,6 @@ | |||
| 1 | import * as Context from "effect/Context"; | 1 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Stdio from "effect/Stdio"; | ||
| 3 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 4 | import * as Schema from "effect/Schema"; | 5 | import * as Schema from "effect/Schema"; |
| 5 | import * as Scope from "effect/Scope"; | 6 | import * as Scope from "effect/Scope"; |
| @@ -26,16 +27,10 @@ export interface AcpClientOptions { · 20 unmodified lines | |||
| 26 | readonly logIncoming?: boolean; | 27 | readonly logIncoming?: boolean; |
| 27 | readonly logOutgoing?: boolean; | 28 | readonly logOutgoing?: boolean; |
| 28 | readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>; | 29 | readonly logger?: (event: AcpProtocol.AcpProtocolLogEvent) => Effect.Effect<void, never>; |
| 29 | /** Transforms child output before protocol logging and parsing. */ | 30 | readonly onIncomingRequest?: AcpProtocol.AcpPatchedProtocolOptions["onIncomingRequest"]; |
| 30 | readonly transformStdout?: ( | 31 | readonly onTermination?: AcpProtocol.AcpPatchedProtocolOptions["onTermination"]; |
| 31 | stdout: ChildProcessSpawner.ChildProcessHandle["stdout"], | 32 | readonly onOutgoingResponseFailure?: AcpProtocol.AcpPatchedProtocolOptions["onOutgoingResponseFailure"]; |
| 32 | ) => AcpProtocol.AcpStdio["stdin"]; | 33 | readonly onOutgoingResponse?: AcpProtocol.AcpPatchedProtocolOptions["onOutgoingResponse"]; |
| 33 | /** Transforms decoded session updates before buffering or delivery. */ | ||
| 34 | readonly transformSessionUpdate?: ( | ||
| 35 | notification: AcpSchema.SessionNotification, | ||
| 36 | ) => AcpSchema.SessionNotification; | ||
| 37 | /** Reports input failures and process exits, even between requests. */ | ||
| 38 | readonly onTermination?: (error: AcpError.AcpError) => Effect.Effect<void, never, never>; | ||
| 39 | } | 34 | } |
| 40 | 35 | ||
| 41 | type AcpClientRaw = { | 36 | type AcpClientRaw = { |
| @@ -316,7 +311,7 @@ interface BufferedNotificationHandler<A> { · 274 unmodified lines | |||
| 316 | } | 311 | } |
| 317 | 312 | ||
| 318 | export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( | 313 | export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( |
| 319 | stdio: AcpProtocol.AcpStdio, | 314 | stdio: Stdio.Stdio, |
| 320 | options: AcpClientOptions = {}, | 315 | options: AcpClientOptions = {}, |
| 321 | terminationError?: Effect.Effect<AcpError.AcpError>, | 316 | terminationError?: Effect.Effect<AcpError.AcpError>, |
| 322 | ): Effect.fn.Return<AcpClient["Service"], never, Scope.Scope> { | 317 | ): Effect.fn.Return<AcpClient["Service"], never, Scope.Scope> { |
| @@ -413,10 +408,12 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( · 90 unmodified lines | |||
| 413 | ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}), | 408 | ...(options.logIncoming !== undefined ? { logIncoming: options.logIncoming } : {}), |
| 414 | ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}), | 409 | ...(options.logOutgoing !== undefined ? { logOutgoing: options.logOutgoing } : {}), |
| 415 | ...(options.logger ? { logger: options.logger } : {}), | 410 | ...(options.logger ? { logger: options.logger } : {}), |
| 416 | ...(options.transformSessionUpdate | 411 | ...(options.onIncomingRequest ? { onIncomingRequest: options.onIncomingRequest } : {}), |
| 417 | ? { transformSessionUpdate: options.transformSessionUpdate } | ||
| 418 | : {}), | ||
| 419 | ...(options.onTermination ? { onTermination: options.onTermination } : {}), | 412 | ...(options.onTermination ? { onTermination: options.onTermination } : {}), |
| 413 | ...(options.onOutgoingResponseFailure | ||
| 414 | ? { onOutgoingResponseFailure: options.onOutgoingResponseFailure } | ||
| 415 | : {}), | ||
| 416 | ...(options.onOutgoingResponse ? { onOutgoingResponse: options.onOutgoingResponse } : {}), | ||
| 420 | onNotification: dispatchNotification, | 417 | onNotification: dispatchNotification, |
| 421 | onExtRequest: dispatchExtRequest, | 418 | onExtRequest: dispatchExtRequest, |
| 422 | }); | 419 | }); |
| @@ -585,19 +582,14 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( · 162 unmodified lines | |||
| 585 | }); | 582 | }); |
| 586 | }); | 583 | }); |
| 587 | 584 | ||
| 588 | export const layer = ( | 585 | export const layer = (stdio: Stdio.Stdio, options: AcpClientOptions = {}): Layer.Layer<AcpClient> => |
| 589 | stdio: AcpProtocol.AcpStdio, | 586 | Layer.effect(AcpClient, make(stdio, options)); |
| 590 | options: AcpClientOptions = {}, | ||
| 591 | ): Layer.Layer<AcpClient> => Layer.effect(AcpClient, make(stdio, options)); | ||
| 592 | 587 | ||
| 593 | export const layerChildProcess = ( | 588 | export const layerChildProcess = ( |
| 594 | handle: ChildProcessSpawner.ChildProcessHandle, | 589 | handle: ChildProcessSpawner.ChildProcessHandle, |
| 595 | options: AcpClientOptions = {}, | 590 | options: AcpClientOptions = {}, |
| 596 | ): Layer.Layer<AcpClient> => { | 591 | ): Layer.Layer<AcpClient> => { |
| 597 | const stdio = { | 592 | const stdio = makeChildStdio(handle); |
| 598 | ...makeChildStdio(handle), | ||
| 599 | stdin: options.transformStdout?.(handle.stdout) ?? handle.stdout, | ||
| 600 | }; | ||
| 601 | const terminationError = makeTerminationError(handle); | 593 | const terminationError = makeTerminationError(handle); |
| 602 | return Layer.effect(AcpClient, make(stdio, options, terminationError)); | 594 | return Layer.effect(AcpClient, make(stdio, options, terminationError)); |
| 603 | }; | 595 | }; |
| @@ -3,8 +3,12 @@ import * as AcpError from "./errors.ts"; · 2 unmodified lines | |||
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Deferred from "effect/Deferred"; | 4 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Fiber from "effect/Fiber"; | 5 | import * as Fiber from "effect/Fiber"; |
| 6 | import * as Exit from "effect/Exit"; | ||
| 6 | import * as Queue from "effect/Queue"; | 7 | import * as Queue from "effect/Queue"; |
| 7 | import * as Schema from "effect/Schema"; | 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 | import * as Stream from "effect/Stream"; | 12 | import * as Stream from "effect/Stream"; |
| 9 | import * as Ref from "effect/Ref"; | 13 | import * as Ref from "effect/Ref"; |
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 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 | it.effect("cleans up interrupted extension requests before a late response arrives", () => | 711 | it.effect("cleans up interrupted extension requests before a late response arrives", () => |
| 537 | Effect.gen(function* () { | 712 | Effect.gen(function* () { |
| 538 | const { stdio, input, output } = yield* makeInMemoryStdio(); | 713 | const { stdio, input, output } = yield* makeInMemoryStdio(); |
| @@ -638,6 +813,31 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { · 99 unmodified lines | |||
| 638 | }), | 813 | }), |
| 639 | ); | 814 | ); |
| 640 | 815 | ||
| 816 | it.effect("does not treat intentional serverProtocol.end as a transport failure", () => | ||
| 817 | Effect.gen(function* () { | ||
| 818 | const { stdio, input } = yield* makeInMemoryStdio(); | ||
| 819 | const terminations = yield* Ref.make(0); | ||
| 820 | const writerExit = yield* Deferred.make<{ readonly intentionalEnd: boolean }>(); | ||
| 821 | const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ | ||
| 822 | stdio, | ||
| 823 | serverRequestMethods: new Set(), | ||
| 824 | onTermination: () => Ref.update(terminations, (count) => count + 1), | ||
| 825 | testHooks: { | ||
| 826 | onOutgoingWriterExit: (detail) => | ||
| 827 | Deferred.succeed(writerExit, detail).pipe(Effect.asVoid), | ||
| 828 | }, | ||
| 829 | }); | ||
| 830 | |||
| 831 | yield* transport.serverProtocol.end(0); | ||
| 832 | const exitDetail = yield* Deferred.await(writerExit); | ||
| 833 | assert.isTrue(exitDetail.intentionalEnd); | ||
| 834 | assert.equal(yield* Ref.get(terminations), 0); | ||
| 835 | |||
| 836 | // Tear down the forever input reader so the scoped test can finish. | ||
| 837 | yield* Queue.end(input); | ||
| 838 | }), | ||
| 839 | ); | ||
| 840 | |||
| 641 | it.effect("does not emit a second process-exit error after a decode failure", () => | 841 | it.effect("does not emit a second process-exit error after a decode failure", () => |
| 642 | Effect.gen(function* () { | 842 | Effect.gen(function* () { |
| 643 | const handle = yield* makeHandle({ | 843 | const handle = yield* makeHandle({ |
| @@ -4,7 +4,6 @@ import * as Deferred from "effect/Deferred"; · 3 unmodified lines | |||
| 4 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| 5 | import * as Queue from "effect/Queue"; | 5 | import * as Queue from "effect/Queue"; |
| 6 | import * as Ref from "effect/Ref"; | 6 | import * as Ref from "effect/Ref"; |
| 7 | import type * as PlatformError from "effect/PlatformError"; | ||
| 8 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| 9 | import * as Scope from "effect/Scope"; | 8 | import * as Scope from "effect/Scope"; |
| 10 | import * as Stream from "effect/Stream"; | 9 | import * as Stream from "effect/Stream"; |
| @@ -43,21 +42,18 @@ export type AcpIncomingNotification = · 32 unmodified lines | |||
| 43 | readonly params: unknown; | 42 | readonly params: unknown; |
| 44 | }; | 43 | }; |
| 45 | 44 | ||
| 46 | /** Standard I/O whose input can report provider-specific ACP failures. */ | ||
| 47 | export interface AcpStdio extends Omit<Stdio.Stdio, "stdin"> { | ||
| 48 | readonly stdin: Stream.Stream<Uint8Array, PlatformError.PlatformError | AcpError.AcpError>; | ||
| 49 | } | ||
| 50 | |||
| 51 | export interface AcpPatchedProtocolOptions { | 45 | export interface AcpPatchedProtocolOptions { |
| 52 | readonly stdio: AcpStdio; | 46 | readonly stdio: Stdio.Stdio; |
| 53 | readonly terminationError?: Effect.Effect<AcpError.AcpError>; | 47 | readonly terminationError?: Effect.Effect<AcpError.AcpError>; |
| 54 | readonly serverRequestMethods: ReadonlySet<string>; | 48 | readonly serverRequestMethods: ReadonlySet<string>; |
| 55 | readonly logIncoming?: boolean; | 49 | readonly logIncoming?: boolean; |
| 56 | readonly logOutgoing?: boolean; | 50 | readonly logOutgoing?: boolean; |
| 57 | readonly logger?: (event: AcpProtocolLogEvent) => Effect.Effect<void, never>; | 51 | readonly logger?: (event: AcpProtocolLogEvent) => Effect.Effect<void, never>; |
| 58 | readonly transformSessionUpdate?: ( | 52 | readonly onIncomingRequest?: ( |
| 59 | notification: AcpSchema.SessionNotification, | 53 | requestId: string, |
| 60 | ) => AcpSchema.SessionNotification; | 54 | method: string, |
| 55 | payload: unknown, | ||
| 56 | ) => Effect.Effect<void, never>; | ||
| 61 | readonly onNotification?: ( | 57 | readonly onNotification?: ( |
| 62 | notification: AcpIncomingNotification, | 58 | notification: AcpIncomingNotification, |
| 63 | ) => Effect.Effect<void, AcpError.AcpError, never>; | 59 | ) => Effect.Effect<void, AcpError.AcpError, never>; |
| @@ -66,6 +62,18 @@ export interface AcpPatchedProtocolOptions { · 2 unmodified lines | |||
| 66 | params: unknown, | 62 | params: unknown, |
| 67 | ) => Effect.Effect<unknown, AcpError.AcpError, never>; | 63 | ) => Effect.Effect<unknown, AcpError.AcpError, never>; |
| 68 | readonly onTermination?: (error: AcpError.AcpError) => Effect.Effect<void, never, never>; | 64 | readonly onTermination?: (error: AcpError.AcpError) => Effect.Effect<void, never, never>; |
| 65 | readonly onOutgoingResponseFailure?: ( | ||
| 66 | requestId: string, | ||
| 67 | error: AcpError.AcpError, | ||
| 68 | ) => Effect.Effect<void, never>; | ||
| 69 | readonly onOutgoingResponse?: (requestId: string) => Effect.Effect<void, never>; | ||
| 70 | readonly testHooks?: { | ||
| 71 | readonly onOutgoingWriteAdmitted?: () => Effect.Effect<void>; | ||
| 72 | /** Invoked from the stdout writer exit path (after intentional/unexpected handling). */ | ||
| 73 | readonly onOutgoingWriterExit?: (input: { | ||
| 74 | readonly intentionalEnd: boolean; | ||
| 75 | }) => Effect.Effect<void>; | ||
| 76 | }; | ||
| 69 | } | 77 | } |
| 70 | 78 | ||
| 71 | export interface AcpPatchedProtocol { | 79 | export interface AcpPatchedProtocol { |
| @@ -81,6 +89,17 @@ interface AcpPendingRequest { · 9 unmodified lines | |||
| 81 | readonly method: string; | 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 | const decodeSessionUpdate = Schema.decodeUnknownEffect(AcpSchema.SessionNotification); | 103 | const decodeSessionUpdate = Schema.decodeUnknownEffect(AcpSchema.SessionNotification); |
| 85 | const decodeElicitationComplete = Schema.decodeUnknownEffect( | 104 | const decodeElicitationComplete = Schema.decodeUnknownEffect( |
| 86 | AcpSchema.ElicitationCompleteNotification, | 105 | AcpSchema.ElicitationCompleteNotification, |
| @@ -108,16 +127,15 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 21 unmodified lines | |||
| 108 | MAX_BUFFERED_RAW_NOTIFICATIONS, | 127 | MAX_BUFFERED_RAW_NOTIFICATIONS, |
| 109 | ); | 128 | ); |
| 110 | const disconnects = yield* Queue.unbounded<number>(); | 129 | const disconnects = yield* Queue.unbounded<number>(); |
| 111 | const outgoing = yield* Queue.unbounded<string | Uint8Array, Cause.Done<void>>(); | 130 | const outgoing = yield* Queue.unbounded<AcpOutgoingWrite, AcpError.AcpError | Cause.Done<void>>(); |
| 112 | const nextRequestId = yield* Ref.make(1); | 131 | const outgoingWriterState = yield* Ref.make<AcpOutgoingWriterState>({ |
| 132 | intentionalEnd: false, | ||
| 133 | outstandingAcknowledgements: new Set(), | ||
| 134 | }); | ||
| 135 | const nextRequestId = yield* Ref.make(1n); | ||
| 113 | const terminationHandled = yield* Ref.make(false); | 136 | const terminationHandled = yield* Ref.make(false); |
| 114 | const terminationFailure = yield* Deferred.make<never, AcpError.AcpError>(); | ||
| 115 | const extPending = yield* Ref.make(new Map<string, AcpPendingRequest>()); | 137 | const extPending = yield* Ref.make(new Map<string, AcpPendingRequest>()); |
| 116 | 138 | ||
| 117 | const ensureActive = Ref.get(terminationHandled).pipe( | ||
| 118 | Effect.flatMap((terminated) => (terminated ? Deferred.await(terminationFailure) : Effect.void)), | ||
| 119 | ); | ||
| 120 | |||
| 121 | const logProtocol = (event: AcpProtocolLogEvent) => { | 139 | const logProtocol = (event: AcpProtocolLogEvent) => { |
| 122 | if (event.direction === "incoming" && !options.logIncoming) { | 140 | if (event.direction === "incoming" && !options.logIncoming) { |
| 123 | return Effect.void; | 141 | return Effect.void; |
| @@ -139,7 +157,6 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 15 unmodified lines | |||
| 139 | if (message._tag === "Interrupt") { | 157 | if (message._tag === "Interrupt") { |
| 140 | return; | 158 | return; |
| 141 | } | 159 | } |
| 142 | yield* ensureActive; | ||
| 143 | yield* logProtocol({ | 160 | yield* logProtocol({ |
| 144 | direction: "outgoing", | 161 | direction: "outgoing", |
| 145 | stage: "decoded", | 162 | stage: "decoded", |
| @@ -166,8 +183,66 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 20 unmodified lines | |||
| 166 | payload: typeof encoded === "string" ? encoded : new TextDecoder().decode(encoded), | 183 | payload: typeof encoded === "string" ? encoded : new TextDecoder().decode(encoded), |
| 167 | }); | 184 | }); |
| 168 | 185 | ||
| 169 | yield* ensureActive; | 186 | const acknowledgement = |
| 170 | yield* Queue.offer(outgoing, encoded).pipe(Effect.asVoid); | 187 | message._tag === "Exit" ? yield* Deferred.make<void, AcpError.AcpError>() : undefined; |
| 188 | const admissionError = yield* Ref.modify(outgoingWriterState, (state) => { | ||
| 189 | if (state.terminalError !== undefined) { | ||
| 190 | return [state.terminalError, state] as const; | ||
| 191 | } | ||
| 192 | if (acknowledgement === undefined) { | ||
| 193 | return [undefined, state] as const; | ||
| 194 | } | ||
| 195 | return [ | ||
| 196 | undefined, | ||
| 197 | { | ||
| 198 | ...state, | ||
| 199 | outstandingAcknowledgements: new Set(state.outstandingAcknowledgements).add( | ||
| 200 | acknowledgement, | ||
| 201 | ), | ||
| 202 | }, | ||
| 203 | ] as const; | ||
| 204 | }); | ||
| 205 | if (admissionError !== undefined) { | ||
| 206 | return yield* admissionError; | ||
| 207 | } | ||
| 208 | if (options.testHooks?.onOutgoingWriteAdmitted !== undefined) { | ||
| 209 | yield* options.testHooks.onOutgoingWriteAdmitted(); | ||
| 210 | } | ||
| 211 | yield* Queue.offer(outgoing, { | ||
| 212 | payload: encoded, | ||
| 213 | ...(acknowledgement === undefined ? {} : { acknowledgement }), | ||
| 214 | }).pipe( | ||
| 215 | Effect.mapError( | ||
| 216 | (cause) => | ||
| 217 | new AcpError.AcpTransportError({ | ||
| 218 | detail: "Failed to queue an outgoing ACP message", | ||
| 219 | cause, | ||
| 220 | }), | ||
| 221 | ), | ||
| 222 | Effect.filterOrFail( | ||
| 223 | (accepted) => accepted, | ||
| 224 | () => | ||
| 225 | new AcpError.AcpTransportError({ | ||
| 226 | detail: "The ACP output queue was closed before accepting a message", | ||
| 227 | cause: "Output queue closed", | ||
| 228 | }), | ||
| 229 | ), | ||
| 230 | Effect.tapError((error) => | ||
| 231 | acknowledgement === undefined | ||
| 232 | ? Effect.void | ||
| 233 | : Ref.update(outgoingWriterState, (state) => { | ||
| 234 | const updated = new Set(state.outstandingAcknowledgements); | ||
| 235 | updated.delete(acknowledgement); | ||
| 236 | return { ...state, outstandingAcknowledgements: updated }; | ||
| 237 | }).pipe(Effect.andThen(Deferred.fail(acknowledgement, error)), Effect.asVoid), | ||
| 238 | ), | ||
| 239 | ); | ||
| 240 | if (acknowledgement !== undefined) { | ||
| 241 | yield* Deferred.await(acknowledgement); | ||
| 242 | } | ||
| 243 | if (message._tag === "Exit" && options.onOutgoingResponse !== undefined) { | ||
| 244 | yield* options.onOutgoingResponse(message.requestId); | ||
| 245 | } | ||
| 171 | } | 246 | } |
| 172 | }); | 247 | }); |
| 173 | 248 | ||
| @@ -233,7 +308,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 59 unmodified lines | |||
| 233 | }), | 308 | }), |
| 234 | }).pipe(Effect.asVoid); | 309 | }).pipe(Effect.asVoid); |
| 235 | 310 | ||
| 236 | const handleTermination = (classify: () => Effect.Effect<AcpError.AcpError>) => | 311 | const handleTermination = (classify: () => Effect.Effect<AcpError.AcpError | undefined>) => |
| 237 | Ref.modify(terminationHandled, (handled) => { | 312 | Ref.modify(terminationHandled, (handled) => { |
| 238 | if (handled) { | 313 | if (handled) { |
| 239 | return [Effect.void, true] as const; | 314 | return [Effect.void, true] as const; |
| @@ -242,7 +317,9 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 2 unmodified lines | |||
| 242 | Effect.gen(function* () { | 317 | Effect.gen(function* () { |
| 243 | yield* Queue.offer(disconnects, 0); | 318 | yield* Queue.offer(disconnects, 0); |
| 244 | const error = yield* classify(); | 319 | const error = yield* classify(); |
| 245 | yield* Deferred.fail(terminationFailure, error); | 320 | if (!error) { |
| 321 | return; | ||
| 322 | } | ||
| 246 | yield* failAllExtPending(error); | 323 | yield* failAllExtPending(error); |
| 247 | yield* emitClientProtocolError(error); | 324 | yield* emitClientProtocolError(error); |
| 248 | if (options.onTermination) { | 325 | if (options.onTermination) { |
| @@ -303,7 +380,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 54 unmodified lines | |||
| 303 | ({ | 380 | ({ |
| 304 | _tag: "SessionUpdate", | 381 | _tag: "SessionUpdate", |
| 305 | method: CLIENT_METHODS.session_update, | 382 | method: CLIENT_METHODS.session_update, |
| 306 | params: options.transformSessionUpdate?.(params) ?? params, | 383 | params, |
| 307 | }) satisfies AcpIncomingNotification, | 384 | }) satisfies AcpIncomingNotification, |
| 308 | ), | 385 | ), |
| 309 | Effect.mapError((cause) => | 386 | Effect.mapError((cause) => |
| @@ -343,8 +420,11 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 33 unmodified lines | |||
| 343 | }); | 420 | }); |
| 344 | } | 421 | } |
| 345 | 422 | ||
| 423 | const observeIncoming = | ||
| 424 | options.onIncomingRequest?.(message.id, message.tag, message.payload) ?? Effect.void; | ||
| 425 | |||
| 346 | if (!options.serverRequestMethods.has(message.tag)) { | 426 | if (!options.serverRequestMethods.has(message.tag)) { |
| 347 | return handleExtRequest(message).pipe( | 427 | return observeIncoming.pipe(Effect.andThen(handleExtRequest(message))).pipe( |
| 348 | Effect.catchTags({ | 428 | Effect.catchTags({ |
| 349 | AcpProtocolParseError: (error) => | 429 | AcpProtocolParseError: (error) => |
| 350 | Effect.logWarning(error).pipe( | 430 | Effect.logWarning(error).pipe( |
| @@ -369,7 +449,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 18 unmodified lines | |||
| 369 | ); | 449 | ); |
| 370 | } | 450 | } |
| 371 | 451 | ||
| 372 | return Queue.offer(serverQueue, message).pipe(Effect.asVoid); | 452 | return observeIncoming.pipe(Effect.andThen(Queue.offer(serverQueue, message)), Effect.asVoid); |
| 373 | }; | 453 | }; |
| 374 | 454 | ||
| 375 | const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) => | 455 | const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) => |
| @@ -511,7 +591,112 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 135 unmodified lines | |||
| 511 | Effect.forkScoped, | 591 | Effect.forkScoped, |
| 512 | ); | 592 | ); |
| 513 | 593 | ||
| 514 | yield* Stream.fromQueue(outgoing).pipe(Stream.run(options.stdio.stdout()), Effect.forkScoped); | 594 | const failOutgoingWrites = (error: AcpError.AcpError) => |
| 595 | Ref.modify(outgoingWriterState, (state) => { | ||
| 596 | const terminalError = state.terminalError ?? error; | ||
| 597 | return [ | ||
| 598 | [terminalError, state.outstandingAcknowledgements] as const, | ||
| 599 | { | ||
| 600 | ...state, | ||
| 601 | terminalError, | ||
| 602 | outstandingAcknowledgements: new Set<Deferred.Deferred<void, AcpError.AcpError>>(), | ||
| 603 | }, | ||
| 604 | ] as const; | ||
| 605 | }).pipe( | ||
| 606 | Effect.flatMap((acknowledgements) => | ||
| 607 | Effect.forEach( | ||
| 608 | acknowledgements[1], | ||
| 609 | (acknowledgement) => Deferred.fail(acknowledgement, acknowledgements[0]), | ||
| 610 | { concurrency: "unbounded", discard: true }, | ||
| 611 | ), | ||
| 612 | ), | ||
| 613 | Effect.andThen(Queue.fail(outgoing, error)), | ||
| 614 | Effect.asVoid, | ||
| 615 | ); | ||
| 616 | const completeOutgoingWrites = () => | ||
| 617 | Ref.modify(outgoingWriterState, (state) => { | ||
| 618 | return [ | ||
| 619 | state.outstandingAcknowledgements, | ||
| 620 | { | ||
| 621 | ...state, | ||
| 622 | outstandingAcknowledgements: new Set<Deferred.Deferred<void, AcpError.AcpError>>(), | ||
| 623 | }, | ||
| 624 | ] as const; | ||
| 625 | }).pipe( | ||
| 626 | Effect.flatMap((acknowledgements) => | ||
| 627 | Effect.forEach( | ||
| 628 | acknowledgements, | ||
| 629 | (acknowledgement) => Deferred.succeed(acknowledgement, undefined), | ||
| 630 | { concurrency: "unbounded", discard: true }, | ||
| 631 | ), | ||
| 632 | ), | ||
| 633 | Effect.asVoid, | ||
| 634 | ); | ||
| 635 | const acknowledgeOutgoingWrite = (acknowledgement: Deferred.Deferred<void, AcpError.AcpError>) => | ||
| 636 | Ref.update(outgoingWriterState, (state) => { | ||
| 637 | const updated = new Set(state.outstandingAcknowledgements); | ||
| 638 | updated.delete(acknowledgement); | ||
| 639 | return { ...state, outstandingAcknowledgements: updated }; | ||
| 640 | }).pipe(Effect.andThen(Deferred.succeed(acknowledgement, undefined)), Effect.asVoid); | ||
| 641 | yield* Stream.fromQueue(outgoing).pipe( | ||
| 642 | Stream.flatMap((write) => { | ||
| 643 | const acknowledgement = write.acknowledgement; | ||
| 644 | const completion = | ||
| 645 | acknowledgement === undefined | ||
| 646 | ? Stream.empty | ||
| 647 | : Stream.fromEffect(acknowledgeOutgoingWrite(acknowledgement)).pipe(Stream.drain); | ||
| 648 | return Stream.make(write.payload).pipe(Stream.concat(completion)); | ||
| 649 | }), | ||
| 650 | Stream.run(options.stdio.stdout()), | ||
| 651 | Effect.onExit((exit) => | ||
| 652 | Exit.match(exit, { | ||
| 653 | onFailure: (cause) => { | ||
| 654 | const error = new AcpError.AcpTransportError({ | ||
| 655 | detail: Cause.hasInterruptsOnly(cause) | ||
| 656 | ? "The ACP output writer was interrupted while closing" | ||
| 657 | : "Failed to write an outgoing ACP message", | ||
| 658 | cause, | ||
| 659 | }); | ||
| 660 | return failOutgoingWrites(error).pipe( | ||
| 661 | Effect.andThen( | ||
| 662 | Cause.hasInterruptsOnly(cause) | ||
| 663 | ? Effect.void | ||
| 664 | : handleTermination(() => Effect.succeed(error)), | ||
| 665 | ), | ||
| 666 | ); | ||
| 667 | }, | ||
| 668 | onSuccess: () => | ||
| 669 | Ref.get(outgoingWriterState).pipe( | ||
| 670 | Effect.flatMap((state) => { | ||
| 671 | // Intentional Queue.end (serverProtocol.end) completes the writer | ||
| 672 | // successfully. Do not invent a transport failure or client protocol | ||
| 673 | // error for a clean RPC shutdown. | ||
| 674 | const handled = state.intentionalEnd | ||
| 675 | ? completeOutgoingWrites() | ||
| 676 | : (() => { | ||
| 677 | const error = new AcpError.AcpTransportError({ | ||
| 678 | detail: "ACP output writer ended before the protocol closed", | ||
| 679 | cause: "Output writer ended", | ||
| 680 | }); | ||
| 681 | return failOutgoingWrites(error).pipe( | ||
| 682 | Effect.andThen(handleTermination(() => Effect.succeed(error))), | ||
| 683 | ); | ||
| 684 | })(); | ||
| 685 | return handled.pipe( | ||
| 686 | Effect.andThen( | ||
| 687 | options.testHooks?.onOutgoingWriterExit === undefined | ||
| 688 | ? Effect.void | ||
| 689 | : options.testHooks.onOutgoingWriterExit({ | ||
| 690 | intentionalEnd: state.intentionalEnd, | ||
| 691 | }), | ||
| 692 | ), | ||
| 693 | ); | ||
| 694 | }), | ||
| 695 | ), | ||
| 696 | }), | ||
| 697 | ), | ||
| 698 | Effect.forkScoped, | ||
| 699 | ); | ||
| 515 | 700 | ||
| 516 | const clientProtocol = RpcClient.Protocol.of({ | 701 | const clientProtocol = RpcClient.Protocol.of({ |
| 517 | run: (_clientId, f) => | 702 | run: (_clientId, f) => |
| @@ -542,8 +727,19 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 24 unmodified lines | |||
| 542 | Effect.forever, | 727 | Effect.forever, |
| 543 | ), | 728 | ), |
| 544 | disconnects, | 729 | disconnects, |
| 545 | send: (_clientId, response) => offerOutgoing(response).pipe(Effect.orDie), | 730 | send: (_clientId, response) => |
| 546 | end: (_clientId) => Queue.end(outgoing), | 731 | offerOutgoing(response).pipe( |
| 732 | Effect.tapError((error) => | ||
| 733 | response._tag === "Exit" && options.onOutgoingResponseFailure !== undefined | ||
| 734 | ? options.onOutgoingResponseFailure(response.requestId, error) | ||
| 735 | : Effect.void, | ||
| 736 | ), | ||
| 737 | Effect.orDie, | ||
| 738 | ), | ||
| 739 | end: (_clientId) => | ||
| 740 | Ref.update(outgoingWriterState, (state) => ({ ...state, intentionalEnd: true })).pipe( | ||
| 741 | Effect.andThen(Queue.end(outgoing)), | ||
| 742 | ), | ||
| 547 | clientIds: Effect.succeed(new Set([0])), | 743 | clientIds: Effect.succeed(new Set([0])), |
| 548 | initialMessage: Effect.succeedNone, | 744 | initialMessage: Effect.succeedNone, |
| 549 | supportsAck: true, | 745 | supportsAck: true, |
| @@ -558,7 +754,6 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 8 unmodified lines | |||
| 558 | method: string, | 754 | method: string, |
| 559 | payload: unknown, | 755 | payload: unknown, |
| 560 | ) { | 756 | ) { |
| 561 | yield* ensureActive; | ||
| 562 | yield* logProtocol({ | 757 | yield* logProtocol({ |
| 563 | direction: "outgoing", | 758 | direction: "outgoing", |
| 564 | stage: "decoded", | 759 | stage: "decoded", |
| @@ -574,12 +769,10 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 9 unmodified lines | |||
| 574 | } | 769 | } |
| 575 | const encoded = `${exit.value}\n`; | 770 | const encoded = `${exit.value}\n`; |
| 576 | yield* logProtocol({ direction: "outgoing", stage: "raw", payload: encoded }); | 771 | yield* logProtocol({ direction: "outgoing", stage: "raw", payload: encoded }); |
| 577 | yield* ensureActive; | ||
| 578 | yield* Queue.offer(outgoing, encoded); | 772 | yield* Queue.offer(outgoing, encoded); |
| 579 | }); | 773 | }); |
| 580 | 774 | ||
| 581 | const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) { | 775 | const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) { |
| 582 | yield* ensureActive; | ||
| 583 | const requestId = yield* Ref.modify( | 776 | const requestId = yield* Ref.modify( |
| 584 | nextRequestId, | 777 | nextRequestId, |
| 585 | (current) => [current, current + 1] as const, | 778 | (current) => [current, current + 1] as const, |
| @@ -56,4 +56,20 @@ describe("projectThreadAwarenessV2", () => { · 55 unmodified lines | |||
| 56 | }), | 56 | }), |
| 57 | ).toMatchObject({ phase: "waiting_for_input", headline: "Waiting for input" }); | 57 | ).toMatchObject({ phase: "waiting_for_input", headline: "Waiting for input" }); |
| 58 | }); | 58 | }); |
| 59 | |||
| 60 | it("does not present authentication refreshes as user approvals", () => { | ||
| 61 | expect( | ||
| 62 | projectThreadAwarenessV2({ | ||
| 63 | environmentId: "env-1" as EnvironmentId, | ||
| 64 | project, | ||
| 65 | thread: v2Thread({ | ||
| 66 | pendingRuntimeRequest: { | ||
| 67 | id: RuntimeRequestId.make("request-auth-refresh"), | ||
| 68 | kind: "auth_refresh", | ||
| 69 | createdAt: updatedAt, | ||
| 70 | }, | ||
| 71 | }), | ||
| 72 | }), | ||
| 73 | ).toMatchObject({ phase: "running", headline: "Agent is working" }); | ||
| 74 | }); | ||
| 59 | }); | 75 | }); |
| @@ -87,7 +87,10 @@ function resolveThreadAwarenessPhaseV2( · 86 unmodified lines | |||
| 87 | if (thread.pendingRuntimeRequest?.kind === "user_input") { | 87 | if (thread.pendingRuntimeRequest?.kind === "user_input") { |
| 88 | return "waiting_for_input"; | 88 | return "waiting_for_input"; |
| 89 | } | 89 | } |
| 90 | if (thread.pendingRuntimeRequest !== null) { | 90 | if ( |
| 91 | thread.pendingRuntimeRequest !== null && | ||
| 92 | thread.pendingRuntimeRequest.kind !== "auth_refresh" | ||
| 93 | ) { | ||
| 91 | return "waiting_for_approval"; | 94 | return "waiting_for_approval"; |
| 92 | } | 95 | } |
| 93 | switch (thread.status) { | 96 | switch (thread.status) { |
| @@ -7,22 +7,52 @@ const runId = RunId.make("run:timeline-visibility"); · 6 unmodified lines | |||
| 7 | const nodeId = NodeId.make("node:timeline-visibility"); | 7 | const nodeId = NodeId.make("node:timeline-visibility"); |
| 8 | 8 | ||
| 9 | describe("isOrchestrationV2TurnItemVisible", () => { | 9 | describe("isOrchestrationV2TurnItemVisible", () => { |
| 10 | it("hides interruption results from superseded attempts", () => { | 10 | it("hides unpaired interruption results from superseded attempts", () => { |
| 11 | expect( | 11 | expect( |
| 12 | isOrchestrationV2TurnItemVisible({ | 12 | isOrchestrationV2TurnItemVisible({ |
| 13 | item: { type: "run_interrupt_result", runId, nodeId }, | 13 | item: { type: "run_interrupt_result", runId, nodeId }, |
| 14 | runs: [{ id: runId, status: "running" }], | 14 | runs: [{ id: runId, status: "running" }], |
| 15 | attempts: [{ runId, rootNodeId: nodeId, status: "superseded" }], | 15 | attempts: [{ runId, rootNodeId: nodeId, status: "superseded" }], |
| 16 | items: [{ type: "run_interrupt_result", runId, nodeId }], | ||
| 16 | }), | 17 | }), |
| 17 | ).toBe(false); | 18 | ).toBe(false); |
| 18 | }); | 19 | }); |
| 19 | 20 | ||
| 20 | it("keeps interruption results from terminal attempts", () => { | 21 | it("keeps paired interruption results from superseded attempts", () => { |
| 22 | expect( | ||
| 23 | isOrchestrationV2TurnItemVisible({ | ||
| 24 | item: { type: "run_interrupt_result", runId, nodeId }, | ||
| 25 | runs: [{ id: runId, status: "running" }], | ||
| 26 | attempts: [{ runId, rootNodeId: nodeId, status: "superseded" }], | ||
| 27 | items: [ | ||
| 28 | { type: "run_interrupt_request", runId, nodeId }, | ||
| 29 | { type: "run_interrupt_result", runId, nodeId }, | ||
| 30 | ], | ||
| 31 | }), | ||
| 32 | ).toBe(true); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("keeps interruption results from terminal attempts without a request", () => { | ||
| 21 | expect( | 36 | expect( |
| 22 | isOrchestrationV2TurnItemVisible({ | 37 | isOrchestrationV2TurnItemVisible({ |
| 23 | item: { type: "run_interrupt_result", runId, nodeId }, | 38 | item: { type: "run_interrupt_result", runId, nodeId }, |
| 24 | runs: [{ id: runId, status: "interrupted" }], | 39 | runs: [{ id: runId, status: "interrupted" }], |
| 25 | attempts: [{ runId, rootNodeId: nodeId, status: "interrupted" }], | 40 | attempts: [{ runId, rootNodeId: nodeId, status: "interrupted" }], |
| 41 | items: [{ type: "run_interrupt_result", runId, nodeId }], | ||
| 42 | }), | ||
| 43 | ).toBe(true); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("keeps interruption results from terminal attempts with a request", () => { | ||
| 47 | expect( | ||
| 48 | isOrchestrationV2TurnItemVisible({ | ||
| 49 | item: { type: "run_interrupt_result", runId, nodeId }, | ||
| 50 | runs: [{ id: runId, status: "interrupted" }], | ||
| 51 | attempts: [{ runId, rootNodeId: nodeId, status: "interrupted" }], | ||
| 52 | items: [ | ||
| 53 | { type: "run_interrupt_request", runId, nodeId }, | ||
| 54 | { type: "run_interrupt_result", runId, nodeId }, | ||
| 55 | ], | ||
| 26 | }), | 56 | }), |
| 27 | ).toBe(true); | 57 | ).toBe(true); |
| 28 | }); | 58 | }); |
| @@ -40,6 +70,7 @@ describe("isOrchestrationV2TurnItemVisible", () => { · 11 unmodified lines | |||
| 40 | }, | 70 | }, |
| 41 | { runId, rootNodeId: nodeId, status: "interrupted" }, | 71 | { runId, rootNodeId: nodeId, status: "interrupted" }, |
| 42 | ], | 72 | ], |
| 73 | items: [{ type: "run_interrupt_result", runId, nodeId }], | ||
| 43 | }), | 74 | }), |
| 44 | ).toBe(true); | 75 | ).toBe(true); |
| 45 | }); | 76 | }); |
| @@ -11,24 +11,36 @@ type TimelineTurnItem = Pick<OrchestrationV2TurnItem, "type" | "runId" | "nodeId · 10 unmodified lines | |||
| 11 | export function isOrchestrationV2SupersededInterrupt(input: { | 11 | export function isOrchestrationV2SupersededInterrupt(input: { |
| 12 | readonly item: TimelineTurnItem; | 12 | readonly item: TimelineTurnItem; |
| 13 | readonly attempts: ReadonlyArray<TimelineRunAttempt>; | 13 | readonly attempts: ReadonlyArray<TimelineRunAttempt>; |
| 14 | readonly items: ReadonlyArray<TimelineTurnItem>; | ||
| 14 | }): boolean { | 15 | }): boolean { |
| 15 | const { item } = input; | 16 | const { item } = input; |
| 16 | if (item.type !== "run_interrupt_result" || item.runId === null || item.nodeId === null) { | 17 | if (item.type !== "run_interrupt_result" || item.runId === null || item.nodeId === null) { |
| 17 | return false; | 18 | return false; |
| 18 | } | 19 | } |
| 19 | 20 | ||
| 20 | return input.attempts.some( | 21 | const isSuperseded = input.attempts.some( |
| 21 | (attempt) => | 22 | (attempt) => |
| 22 | attempt.runId === item.runId && | 23 | attempt.runId === item.runId && |
| 23 | attempt.rootNodeId === item.nodeId && | 24 | attempt.rootNodeId === item.nodeId && |
| 24 | attempt.status === "superseded", | 25 | attempt.status === "superseded", |
| 25 | ); | 26 | ); |
| 27 | if (!isSuperseded) { | ||
| 28 | return false; | ||
| 29 | } | ||
| 30 | |||
| 31 | // Paired stop-then-steer results have a matching request on the same run and | ||
| 32 | // must stay visible. Legacy plain-steer results have no request and stay hidden. | ||
| 33 | const hasMatchingRequest = input.items.some( | ||
| 34 | (candidate) => candidate.type === "run_interrupt_request" && candidate.runId === item.runId, | ||
| 35 | ); | ||
| 36 | return !hasMatchingRequest; | ||
| 26 | } | 37 | } |
| 27 | 38 | ||
| 28 | export function isOrchestrationV2TurnItemVisible(input: { | 39 | export function isOrchestrationV2TurnItemVisible(input: { |
| 29 | readonly item: TimelineTurnItem; | 40 | readonly item: TimelineTurnItem; |
| 30 | readonly runs: ReadonlyArray<TimelineRun>; | 41 | readonly runs: ReadonlyArray<TimelineRun>; |
| 31 | readonly attempts: ReadonlyArray<TimelineRunAttempt>; | 42 | readonly attempts: ReadonlyArray<TimelineRunAttempt>; |
| 43 | readonly items: ReadonlyArray<TimelineTurnItem>; | ||
| 32 | }): boolean { | 44 | }): boolean { |
| 33 | const { item } = input; | 45 | const { item } = input; |
| 34 | if ( | 46 | if ( |
| @@ -38,5 +50,9 @@ export function isOrchestrationV2TurnItemVisible(input: { · 3 unmodified lines | |||
| 38 | return false; | 50 | return false; |
| 39 | } | 51 | } |
| 40 | 52 | ||
| 41 | return !isOrchestrationV2SupersededInterrupt({ item, attempts: input.attempts }); | 53 | return !isOrchestrationV2SupersededInterrupt({ |
| 54 | item, | ||
| 55 | attempts: input.attempts, | ||
| 56 | items: input.items, | ||
| 57 | }); | ||
| 42 | } | 58 | } |