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", () => {
22532253 return Effect.forEach(reasons, runDeadTurn, { discard: true });
22542254 });
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
22562304 it.effect("fails a turn when a success result reports a 529 overload", () => {
22572305 const harness = makeHarness();
22582306 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+25-62
......@@ -434,26 +434,9 @@ function resultErrorsText(result: SDKResultMessage): string {
434434 : "";
435435}
436436
437/**
438 * First user-facing error from a non-success result. "[ede_diagnostic] ..."
439 * entries are CLI-internal telemetry (the CLI hides them from its own UI too),
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) {
437/** Failure text for structured terminal reasons, including success-tagged failures. */
438function terminalResultError(reason: SDKResultMessage["terminal_reason"]): string | undefined {
439 switch (reason) {
457440 case "api_error":
458441 return "Claude gave up after repeated API errors.";
459442 case "malformed_tool_use_exhausted":
......@@ -1559,27 +1542,6 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* (
15591542 return buildUserMessage({ sdkContent });
15601543});
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
15831545/**
15841546 * The CLI reports repeated 529 overload failures as a success-subtype result
15851547 * with api_error_status 529 and an empty error list; the status code is the
......@@ -1589,25 +1551,27 @@ function isOverloadedResult(result: SDKResultMessage): boolean {
15891551 return result.subtype === "success" && result.api_error_status === 529;
15901552}
15911553
1592function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus {
1593 if (
1594 isOverloadedResult(result) ||
1595 (result.terminal_reason !== undefined && FAILED_TERMINAL_REASONS.has(result.terminal_reason))
1596 ) {
1597 return "failed";
1598 }
1599 if (result.subtype === "success") {
1600 return "completed";
1601 }
1602
1603 const errors = resultErrorsText(result);
1604 if (isInterruptedResult(result)) {
1605 return "interrupted";
1606 }
1607 if (errors.includes("cancel")) {
1608 return "cancelled";
1609 }
1610 return "failed";
1554/** Derives turn status and its error from the same provider result. */
1555function resultOutcome(result: SDKResultMessage): {
1556 status: ProviderRuntimeTurnStatus;
1557 errorMessage: string | undefined;
1558} {
1559 const structuredError = isOverloadedResult(result)
1560 ? "Claude API is overloaded (529). Try again shortly."
1561 : terminalResultError(result.terminal_reason);
1562 // CLI diagnostic entries must not become the error banner.
1563 const listedError =
1564 result.subtype === "success" || !Array.isArray(result.errors)
1565 ? undefined
1566 : result.errors.find((error) => !error.startsWith("[ede_diagnostic]"));
1567 const errorMessage = listedError || structuredError;
1568 if (structuredError !== undefined) return { status: "failed", errorMessage };
1569 if (result.subtype === "success") return { status: "completed", errorMessage };
1570 if (isInterruptedResult(result)) return { status: "interrupted", errorMessage };
1571 return {
1572 status: resultErrorsText(result).includes("cancel") ? "cancelled" : "failed",
1573 errorMessage,
1574 };
16111575}
16121576
16131577function streamKindFromDeltaType(deltaType: string): ClaudeTextStreamKind {
......@@ -3268,8 +3232,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
32683232 return;
32693233 }
32703234
3271 const status = turnStatusFromResult(message);
3272 const errorMessage = resultUserFacingError(message);
3235 const { status, errorMessage } = resultOutcome(message);
32733236
32743237 if (status === "failed") {
32753238 yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed.");