| ... | ... | @@ -2090,6 +2090,177 @@ describe("ClaudeAdapterLive", () => { |
| 2090 | 2090 | ); |
| 2091 | 2091 | }); |
| 2092 | 2092 | |
| 2093 | it.effect("fails a turn when the result carries a give-up terminal_reason", () => { |
| 2094 | const harness = makeHarness(); |
| 2095 | return Effect.gen(function* () { |
| 2096 | const adapter = yield* ClaudeAdapter; |
| 2097 | |
| 2098 | const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( |
| 2099 | Stream.runCollect, |
| 2100 | Effect.forkChild, |
| 2101 | ); |
| 2102 | |
| 2103 | const session = yield* adapter.startSession({ |
| 2104 | threadId: THREAD_ID, |
| 2105 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2106 | runtimeMode: "full-access", |
| 2107 | }); |
| 2108 | |
| 2109 | const turn = yield* adapter.sendTurn({ |
| 2110 | threadId: session.threadId, |
| 2111 | input: "hello", |
| 2112 | attachments: [], |
| 2113 | }); |
| 2114 | |
| 2115 | // The CLI stamps subtype success with an empty error list when it |
| 2116 | // gives up after exhausting API retries; the terminal_reason is the |
| 2117 | // only structured failure signal. |
| 2118 | harness.query.emit({ |
| 2119 | type: "result", |
| 2120 | subtype: "success", |
| 2121 | is_error: false, |
| 2122 | result: "", |
| 2123 | errors: [], |
| 2124 | stop_reason: null, |
| 2125 | terminal_reason: "api_error", |
| 2126 | session_id: "sdk-session-api-error", |
| 2127 | uuid: "result-api-error", |
| 2128 | } as unknown as SDKMessage); |
| 2129 | |
| 2130 | const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); |
| 2131 | assert.deepEqual( |
| 2132 | runtimeEvents.map((event) => event.type), |
| 2133 | [ |
| 2134 | "session.started", |
| 2135 | "session.configured", |
| 2136 | "session.state.changed", |
| 2137 | "turn.started", |
| 2138 | "thread.started", |
| 2139 | "runtime.error", |
| 2140 | "turn.completed", |
| 2141 | ], |
| 2142 | ); |
| 2143 | |
| 2144 | const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; |
| 2145 | assert.equal(turnCompleted?.type, "turn.completed"); |
| 2146 | if (turnCompleted?.type === "turn.completed") { |
| 2147 | assert.equal(String(turnCompleted.turnId), String(turn.turnId)); |
| 2148 | assert.equal(turnCompleted.payload.state, "failed"); |
| 2149 | assert.equal( |
| 2150 | turnCompleted.payload.errorMessage, |
| 2151 | "Claude gave up after repeated API errors.", |
| 2152 | ); |
| 2153 | } |
| 2154 | }).pipe( |
| 2155 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| 2156 | Effect.provide(harness.layer), |
| 2157 | ); |
| 2158 | }); |
| 2159 | |
| 2160 | it.effect("fails a turn for every dead-turn terminal_reason", () => { |
| 2161 | const reasons = [ |
| 2162 | "blocking_limit", |
| 2163 | "rapid_refill_breaker", |
| 2164 | "prompt_too_long", |
| 2165 | "image_error", |
| 2166 | "model_error", |
| 2167 | "malformed_tool_use_exhausted", |
| 2168 | "budget_exhausted", |
| 2169 | "structured_output_retry_exhausted", |
| 2170 | "tool_deferred_unavailable", |
| 2171 | "turn_setup_failed", |
| 2172 | ]; |
| 2173 | // One harness per reason: the fake query settles a single turn. |
| 2174 | const runDeadTurn = (reason: string) => { |
| 2175 | const harness = makeHarness(); |
| 2176 | return Effect.gen(function* () { |
| 2177 | const adapter = yield* ClaudeAdapter; |
| 2178 | const completionFiber = yield* adapter.streamEvents.pipe( |
| 2179 | Stream.filter((event) => event.type === "turn.completed"), |
| 2180 | Stream.runHead, |
| 2181 | Effect.forkChild, |
| 2182 | ); |
| 2183 | const session = yield* adapter.startSession({ |
| 2184 | threadId: THREAD_ID, |
| 2185 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2186 | runtimeMode: "full-access", |
| 2187 | }); |
| 2188 | yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", attachments: [] }); |
| 2189 | harness.query.emit({ |
| 2190 | type: "result", |
| 2191 | subtype: "success", |
| 2192 | is_error: false, |
| 2193 | result: "", |
| 2194 | errors: [], |
| 2195 | stop_reason: null, |
| 2196 | terminal_reason: reason, |
| 2197 | session_id: "sdk-session-dead-turn", |
| 2198 | uuid: `result-${reason}`, |
| 2199 | } as unknown as SDKMessage); |
| 2200 | const completed = yield* Fiber.join(completionFiber); |
| 2201 | assert.equal(completed._tag, "Some"); |
| 2202 | if (completed._tag === "Some" && completed.value.type === "turn.completed") { |
| 2203 | assert.equal(completed.value.payload.state, "failed", reason); |
| 2204 | assert.ok(completed.value.payload.errorMessage, `${reason} carries an error message`); |
| 2205 | } |
| 2206 | }).pipe( |
| 2207 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| 2208 | Effect.provide(harness.layer), |
| 2209 | ); |
| 2210 | }; |
| 2211 | return Effect.forEach(reasons, runDeadTurn, { discard: true }); |
| 2212 | }); |
| 2213 | |
| 2214 | it.effect("fails a turn when a success result reports a 529 overload", () => { |
| 2215 | const harness = makeHarness(); |
| 2216 | return Effect.gen(function* () { |
| 2217 | const adapter = yield* ClaudeAdapter; |
| 2218 | |
| 2219 | const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( |
| 2220 | Stream.runCollect, |
| 2221 | Effect.forkChild, |
| 2222 | ); |
| 2223 | |
| 2224 | const session = yield* adapter.startSession({ |
| 2225 | threadId: THREAD_ID, |
| 2226 | provider: ProviderDriverKind.make("claudeAgent"), |
| 2227 | runtimeMode: "full-access", |
| 2228 | }); |
| 2229 | |
| 2230 | yield* adapter.sendTurn({ |
| 2231 | threadId: session.threadId, |
| 2232 | input: "hello", |
| 2233 | attachments: [], |
| 2234 | }); |
| 2235 | |
| 2236 | harness.query.emit({ |
| 2237 | type: "result", |
| 2238 | subtype: "success", |
| 2239 | is_error: true, |
| 2240 | api_error_status: 529, |
| 2241 | result: "", |
| 2242 | errors: [], |
| 2243 | stop_reason: null, |
| 2244 | session_id: "sdk-session-overload", |
| 2245 | uuid: "result-overload", |
| 2246 | } as unknown as SDKMessage); |
| 2247 | |
| 2248 | const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); |
| 2249 | const turnCompleted = runtimeEvents[runtimeEvents.length - 1]; |
| 2250 | assert.equal(turnCompleted?.type, "turn.completed"); |
| 2251 | if (turnCompleted?.type === "turn.completed") { |
| 2252 | assert.equal(turnCompleted.payload.state, "failed"); |
| 2253 | assert.equal( |
| 2254 | turnCompleted.payload.errorMessage, |
| 2255 | "Claude API is overloaded (529). Try again shortly.", |
| 2256 | ); |
| 2257 | } |
| 2258 | }).pipe( |
| 2259 | Effect.provideService(Random.Random, makeDeterministicRandomService()), |
| 2260 | Effect.provide(harness.layer), |
| 2261 | ); |
| 2262 | }); |
| 2263 | |
| 2093 | 2264 | it.effect("interruptTurn settles live tasks and closes the provider session", () => { |
| 2094 | 2265 | const harness = makeHarness(); |
| 2095 | 2266 | return Effect.gen(function* () { |
| ... | ... | @@ -3088,7 +3259,36 @@ describe("ClaudeAdapterLive", () => { |
| 3088 | 3259 | { type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" }, |
| 3089 | 3260 | { type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" }, |
| 3090 | 3261 | { type: "system", subtype: "elicitation_complete", session_id: "session", uuid: "ec" }, |
| 3262 | { |
| 3263 | type: "system", |
| 3264 | subtype: "control_request_progress", |
| 3265 | request_id: "ctrl-1", |
| 3266 | status: "started", |
| 3267 | session_id: "session", |
| 3268 | uuid: "crp", |
| 3269 | }, |
| 3270 | { |
| 3271 | type: "system", |
| 3272 | subtype: "worker_shutting_down", |
| 3273 | reason: "host_exit", |
| 3274 | session_id: "session", |
| 3275 | uuid: "wsd", |
| 3276 | }, |
| 3277 | { |
| 3278 | type: "system", |
| 3279 | subtype: "informational", |
| 3280 | content: "Loaded 3 skills", |
| 3281 | level: "notice", |
| 3282 | session_id: "session", |
| 3283 | uuid: "info", |
| 3284 | }, |
| 3091 | 3285 | { type: "prompt_suggestion", suggestion: "try this", session_id: "session", uuid: "ps" }, |
| 3286 | { |
| 3287 | type: "conversation_reset", |
| 3288 | new_conversation_id: "conv-2", |
| 3289 | session_id: "session", |
| 3290 | uuid: "cr", |
| 3291 | }, |
| 3092 | 3292 | { |
| 3093 | 3293 | type: "system", |
| 3094 | 3294 | subtype: "notification", |
| ... | ... | @@ -3111,6 +3311,27 @@ describe("ClaudeAdapterLive", () => { |
| 3111 | 3311 | session_id: "session", |
| 3112 | 3312 | uuid: "notif-high", |
| 3113 | 3313 | } as unknown as SDKMessage); |
| 3314 | // Warning-level informational notes and refusals without a fallback |
| 3315 | // model surface as warning rows too. |
| 3316 | harness.query.emit({ |
| 3317 | type: "system", |
| 3318 | subtype: "informational", |
| 3319 | content: "Stop hook prevented continuation", |
| 3320 | level: "warning", |
| 3321 | prevent_continuation: true, |
| 3322 | session_id: "session", |
| 3323 | uuid: "info-warn", |
| 3324 | } as unknown as SDKMessage); |
| 3325 | harness.query.emit({ |
| 3326 | type: "system", |
| 3327 | subtype: "model_refusal_no_fallback", |
| 3328 | original_model: "claude-opus-5", |
| 3329 | request_id: null, |
| 3330 | api_refusal_explanation: "The request was declined by the API.", |
| 3331 | content: "Model refused", |
| 3332 | session_id: "session", |
| 3333 | uuid: "mrnf", |
| 3334 | } as unknown as SDKMessage); |
| 3114 | 3335 | // session_state_changed maps to the matching session states. |
| 3115 | 3336 | for (const [state, uuid] of [ |
| 3116 | 3337 | ["running", "ssc-run"], |
| ... | ... | @@ -3141,10 +3362,15 @@ describe("ClaudeAdapterLive", () => { |
| 3141 | 3362 | yield* Effect.yieldNow; |
| 3142 | 3363 | |
| 3143 | 3364 | const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning"); |
| 3144 | | // Exactly one warning: the high-priority notification. Nothing else. |
| 3365 | // Exactly three warnings: the high-priority notification, the |
| 3366 | // warning-level informational note, and the refusal. Nothing else. |
| 3145 | 3367 | assert.deepEqual( |
| 3146 | 3368 | warnings.map((event) => event.payload.message), |
| 3147 | | ["context window nearly full"], |
| 3369 | [ |
| 3370 | "context window nearly full", |
| 3371 | "Stop hook prevented continuation", |
| 3372 | "The request was declined by the API.", |
| 3373 | ], |
| 3148 | 3374 | ); |
| 3149 | 3375 | const sessionStates = runtimeEvents |
| 3150 | 3376 | .filter((event) => event.type === "session.state.changed") |
| ... | ... | @@ -4190,6 +4416,7 @@ describe("ClaudeAdapterLive", () => { |
| 4190 | 4416 | { command: "pwd" }, |
| 4191 | 4417 | { |
| 4192 | 4418 | signal: new AbortController().signal, |
| 4419 | requestId: "request-1", |
| 4193 | 4420 | suggestions: [ |
| 4194 | 4421 | { |
| 4195 | 4422 | type: "setMode", |
| ... | ... | @@ -4301,6 +4528,7 @@ describe("ClaudeAdapterLive", () => { |
| 4301 | 4528 | { title: "hello" }, |
| 4302 | 4529 | { |
| 4303 | 4530 | signal: new AbortController().signal, |
| 4531 | requestId: "request-2", |
| 4304 | 4532 | suggestions: [], |
| 4305 | 4533 | toolUseID: "tool-use-mcp-1", |
| 4306 | 4534 | }, |
| ... | ... | @@ -4327,6 +4555,7 @@ describe("ClaudeAdapterLive", () => { |
| 4327 | 4555 | { command: "git status" }, |
| 4328 | 4556 | { |
| 4329 | 4557 | signal: new AbortController().signal, |
| 4558 | requestId: "request-3", |
| 4330 | 4559 | suggestions: [ |
| 4331 | 4560 | { |
| 4332 | 4561 | type: "addRules", |
| ... | ... | @@ -4385,6 +4614,7 @@ describe("ClaudeAdapterLive", () => { |
| 4385 | 4614 | {}, |
| 4386 | 4615 | { |
| 4387 | 4616 | signal: new AbortController().signal, |
| 4617 | requestId: "request-4", |
| 4388 | 4618 | toolUseID: "tool-agent-1", |
| 4389 | 4619 | }, |
| 4390 | 4620 | ); |
| ... | ... | @@ -4409,6 +4639,7 @@ describe("ClaudeAdapterLive", () => { |
| 4409 | 4639 | { pattern: "foo", path: "src" }, |
| 4410 | 4640 | { |
| 4411 | 4641 | signal: new AbortController().signal, |
| 4642 | requestId: "request-5", |
| 4412 | 4643 | toolUseID: "tool-grep-approval-1", |
| 4413 | 4644 | }, |
| 4414 | 4645 | ); |
| ... | ... | @@ -4951,6 +5182,7 @@ describe("ClaudeAdapterLive", () => { |
| 4951 | 5182 | }, |
| 4952 | 5183 | { |
| 4953 | 5184 | signal: new AbortController().signal, |
| 5185 | requestId: "request-6", |
| 4954 | 5186 | toolUseID: "tool-exit-1", |
| 4955 | 5187 | }, |
| 4956 | 5188 | ); |
| ... | ... | @@ -5073,7 +5305,7 @@ describe("ClaudeAdapterLive", () => { |
| 5073 | 5305 | dialogKind: "resume_return", |
| 5074 | 5306 | payload: { sessionAgeMinutes: 145, estimatedTokens: 275123 }, |
| 5075 | 5307 | }, |
| 5076 | | { signal: new AbortController().signal }, |
| 5308 | { signal: new AbortController().signal, requestId: "request-dialog" }, |
| 5077 | 5309 | ); |
| 5078 | 5310 | |
| 5079 | 5311 | const requested = yield* Stream.runHead(adapter.streamEvents); |
| ... | ... | @@ -5173,6 +5405,7 @@ describe("ClaudeAdapterLive", () => { |
| 5173 | 5405 | |
| 5174 | 5406 | const permissionPromise = canUseTool("AskUserQuestion", askInput, { |
| 5175 | 5407 | signal: new AbortController().signal, |
| 5408 | requestId: "request-7", |
| 5176 | 5409 | toolUseID: "tool-ask-1", |
| 5177 | 5410 | }); |
| 5178 | 5411 | |
| ... | ... | @@ -5299,6 +5532,7 @@ describe("ClaudeAdapterLive", () => { |
| 5299 | 5532 | |
| 5300 | 5533 | const permissionPromise = canUseTool("AskUserQuestion", askInput, { |
| 5301 | 5534 | signal: new AbortController().signal, |
| 5535 | requestId: "request-8", |
| 5302 | 5536 | toolUseID: "tool-ask-2", |
| 5303 | 5537 | }); |
| 5304 | 5538 | |
| ... | ... | @@ -5364,6 +5598,7 @@ describe("ClaudeAdapterLive", () => { |
| 5364 | 5598 | }, |
| 5365 | 5599 | { |
| 5366 | 5600 | signal: controller.signal, |
| 5601 | requestId: "request-9", |
| 5367 | 5602 | toolUseID: "tool-ask-abort", |
| 5368 | 5603 | }, |
| 5369 | 5604 | ); |
| ... | ... | @@ -5439,6 +5674,7 @@ describe("ClaudeAdapterLive", () => { |
| 5439 | 5674 | }, |
| 5440 | 5675 | { |
| 5441 | 5676 | signal: controller.signal, |
| 5677 | requestId: "request-10", |
| 5442 | 5678 | toolUseID: "tool-ask-pre-aborted", |
| 5443 | 5679 | }, |
| 5444 | 5680 | ); |
| ... | ... | @@ -5495,7 +5731,11 @@ describe("ClaudeAdapterLive", () => { |
| 5495 | 5731 | }, |
| 5496 | 5732 | ], |
| 5497 | 5733 | }, |
| 5498 | | { signal: new AbortController().signal, toolUseID: "tool-ask-stop" }, |
| 5734 | { |
| 5735 | signal: new AbortController().signal, |
| 5736 | requestId: "request-stop", |
| 5737 | toolUseID: "tool-ask-stop", |
| 5738 | }, |
| 5499 | 5739 | ); |
| 5500 | 5740 | |
| 5501 | 5741 | const requestedEvent = yield* Stream.runHead(adapter.streamEvents); |