authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-06 02:15:40-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 02:15:40-07:00
log4c7cd17a832269ef17edcf99e987ac25ae1b640a
tree9e0e4de73937c64e7be32e0c16803ed3f6c52cbb
parentac11bd29b03ec568f2616c78d885574eff16ad3c
signature Signed by PGP key B5690EEEBB952194

refactor(server): share Claude result status and error mapping (#10296)


2 files changed, 73 insertions(+), 62 deletions(-)

apps/server/src/provider/Layers/ClaudeAdapter.test.ts+48
...@@ -2253,6 +2253,54 @@ describe("ClaudeAdapterLive", () => {...@@ -2253,6 +2253,54 @@ describe("ClaudeAdapterLive", () => {
2253 return Effect.forEach(reasons, runDeadTurn, { discard: true });2253 return Effect.forEach(reasons, runDeadTurn, { discard: true });
2254 });2254 });
22552255
2256 it.effect.each(["success", "error_during_execution"] as const)(
2257 "preserves %s behavior for an unknown runtime terminal reason",
2258 (subtype) => {
2259 const harness = makeHarness();
2260 return Effect.gen(function* () {
2261 const adapter = yield* ClaudeAdapter;
2262 const completionFiber = yield* adapter.streamEvents.pipe(
2263 Stream.filter((event) => event.type === "turn.completed"),
2264 Stream.runHead,
2265 Effect.forkChild,
2266 );
2267 const session = yield* adapter.startSession({
2268 threadId: THREAD_ID,
2269 provider: ProviderDriverKind.make("claudeAgent"),
2270 runtimeMode: "full-access",
2271 });
2272 yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", attachments: [] });
2273 // An installed CLI can send a terminal reason newer than the bundled SDK.
2274 harness.query.emit({
2275 type: "result",
2276 subtype,
2277 is_error: subtype !== "success",
2278 result: "",
2279 errors: subtype === "success" ? [] : ["Provider error detail"],
2280 stop_reason: null,
2281 terminal_reason: "future_terminal_reason",
2282 session_id: "sdk-session-future-reason",
2283 uuid: "result-future-reason",
2284 } as unknown as SDKMessage);
2285 const completed = yield* Fiber.join(completionFiber);
2286 assert.equal(completed._tag, "Some");
2287 if (completed._tag === "Some" && completed.value.type === "turn.completed") {
2288 assert.equal(
2289 completed.value.payload.state,
2290 subtype === "success" ? "completed" : "failed",
2291 );
2292 assert.equal(
2293 completed.value.payload.errorMessage,
2294 subtype === "success" ? undefined : "Provider error detail",
2295 );
2296 }
2297 }).pipe(
2298 Effect.provideService(Random.Random, makeDeterministicRandomService()),
2299 Effect.provide(harness.layer),
2300 );
2301 },
2302 );
2303
2256 it.effect("fails a turn when a success result reports a 529 overload", () => {2304 it.effect("fails a turn when a success result reports a 529 overload", () => {
2257 const harness = makeHarness();2305 const harness = makeHarness();
2258 return Effect.gen(function* () {2306 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+25-62
...@@ -434,26 +434,9 @@ function resultErrorsText(result: SDKResultMessage): string {...@@ -434,26 +434,9 @@ function resultErrorsText(result: SDKResultMessage): string {
434 : "";434 : "";
435}435}
436436
437/**437/** Failure text for structured terminal reasons, including success-tagged failures. */
438 * First user-facing error from a non-success result. "[ede_diagnostic] ..."438function terminalResultError(reason: SDKResultMessage["terminal_reason"]): string | undefined {
439 * entries are CLI-internal telemetry (the CLI hides them from its own UI too),439 switch (reason) {
440 * so they must never become the error banner.
441 */
442function resultUserFacingError(result: SDKResultMessage): string | undefined {
443 const listed =
444 result.subtype === "success" || !Array.isArray(result.errors)
445 ? undefined
446 : result.errors.find((error) => !error.startsWith("[ede_diagnostic]"));
447 if (listed) {
448 return listed;
449 }
450 // Structured failure markers for results whose error list is empty or
451 // diagnostic-only: an overloaded API (529) and the terminal reasons the
452 // CLI stamps when it gives up on a turn.
453 if (isOverloadedResult(result)) {
454 return "Claude API is overloaded (529). Try again shortly.";
455 }
456 switch (result.terminal_reason) {
457 case "api_error":440 case "api_error":
458 return "Claude gave up after repeated API errors.";441 return "Claude gave up after repeated API errors.";
459 case "malformed_tool_use_exhausted":442 case "malformed_tool_use_exhausted":
...@@ -1559,27 +1542,6 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (...@@ -1559,27 +1542,6 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
1559 return buildUserMessage({ sdkContent });1542 return buildUserMessage({ sdkContent });
1560});1543});
15611544
1562/**
1563 * terminal_reason values the CLI classifies as dead turns: the turn died
1564 * rather than finished, even when the result subtype is success and the
1565 * error list is empty. Kept in sync with the messages in
1566 * resultUserFacingError.
1567 */
1568const FAILED_TERMINAL_REASONS: ReadonlySet<NonNullable<SDKResultMessage["terminal_reason"]>> =
1569 new Set([
1570 "api_error",
1571 "malformed_tool_use_exhausted",
1572 "budget_exhausted",
1573 "structured_output_retry_exhausted",
1574 "tool_deferred_unavailable",
1575 "turn_setup_failed",
1576 "blocking_limit",
1577 "rapid_refill_breaker",
1578 "prompt_too_long",
1579 "image_error",
1580 "model_error",
1581 ]);
1582
1583/**1545/**
1584 * The CLI reports repeated 529 overload failures as a success-subtype result1546 * The CLI reports repeated 529 overload failures as a success-subtype result
1585 * with api_error_status 529 and an empty error list; the status code is the1547 * with api_error_status 529 and an empty error list; the status code is the
...@@ -1589,25 +1551,27 @@ function isOverloadedResult(result: SDKResultMessage): boolean {...@@ -1589,25 +1551,27 @@ function isOverloadedResult(result: SDKResultMessage): boolean {
1589 return result.subtype === "success" && result.api_error_status === 529;1551 return result.subtype === "success" && result.api_error_status === 529;
1590}1552}
15911553
1592function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus {1554/** Derives turn status and its error from the same provider result. */
1593 if (1555function resultOutcome(result: SDKResultMessage): {
1594 isOverloadedResult(result) ||1556 status: ProviderRuntimeTurnStatus;
1595 (result.terminal_reason !== undefined && FAILED_TERMINAL_REASONS.has(result.terminal_reason))1557 errorMessage: string | undefined;
1596 ) {1558} {
1597 return "failed";1559 const structuredError = isOverloadedResult(result)
1598 }1560 ? "Claude API is overloaded (529). Try again shortly."
1599 if (result.subtype === "success") {1561 : terminalResultError(result.terminal_reason);
1600 return "completed";1562 // CLI diagnostic entries must not become the error banner.
1601 }1563 const listedError =
16021564 result.subtype === "success" || !Array.isArray(result.errors)
1603 const errors = resultErrorsText(result);1565 ? undefined
1604 if (isInterruptedResult(result)) {1566 : result.errors.find((error) => !error.startsWith("[ede_diagnostic]"));
1605 return "interrupted";1567 const errorMessage = listedError || structuredError;
1606 }1568 if (structuredError !== undefined) return { status: "failed", errorMessage };
1607 if (errors.includes("cancel")) {1569 if (result.subtype === "success") return { status: "completed", errorMessage };
1608 return "cancelled";1570 if (isInterruptedResult(result)) return { status: "interrupted", errorMessage };
1609 }1571 return {
1610 return "failed";1572 status: resultErrorsText(result).includes("cancel") ? "cancelled" : "failed",
1573 errorMessage,
1574 };
1611}1575}
16121576
1613function streamKindFromDeltaType(deltaType: string): ClaudeTextStreamKind {1577function streamKindFromDeltaType(deltaType: string): ClaudeTextStreamKind {
...@@ -3268,8 +3232,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -3268,8 +3232,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
3268 return;3232 return;
3269 }3233 }
32703234
3271 const status = turnStatusFromResult(message);3235 const { status, errorMessage } = resultOutcome(message);
3272 const errorMessage = resultUserFacingError(message);
32733236
3274 if (status === "failed") {3237 if (status === "failed") {
3275 yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed.");3238 yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed.");