From 835bc49487f8638ccbc21022d0717295dc3668a5 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:59:37 +0000 Subject: [PATCH] fix(claude): retain final usage when a turn fails (#8519) Non-success SDK results computed final token counts and cost but emitted only failure. Publish the existing usage update before the terminal event so clients retain completed work accounting. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../claude/agent.error-result.test.ts | 38 ++++++++++++++++++- .../server/agent/providers/claude/agent.ts | 1 + 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.error-result.test.ts b/packages/server/src/server/agent/providers/claude/agent.error-result.test.ts index 9bbaa49186..9f8c2c5a6d 100644 --- a/packages/server/src/server/agent/providers/claude/agent.error-result.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.error-result.test.ts @@ -13,6 +13,11 @@ interface ScriptedTurn { before?: string; after?: string; isError: boolean; + subtype?: + | "error_max_turns" + | "error_during_execution" + | "error_max_budget_usd" + | "error_max_structured_output_retries"; usage?: { inputTokens: number; cachedInputTokens: number; @@ -57,11 +62,12 @@ function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory { if (turn.after) yield assistant(`after-${index}`, turn.after); yield { type: "result", - subtype: "success", + subtype: turn.subtype ?? "success", session_id: "error-session", duration_ms: 1, duration_api_ms: 0, is_error: turn.isError, + ...(turn.subtype ? { errors: ["Reached maximum number of turns (1)"] } : {}), num_turns: 1, result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""), stop_reason: "stop_sequence", @@ -188,3 +194,33 @@ test("an error reply the turn moves past is still shown", async () => { expect(terminal(completed)).toMatchObject({ type: "turn_completed" }); expect(assistantTexts(completed)).toEqual([LIMIT]); }); + +test.each([ + "error_max_turns", + "error_during_execution", + "error_max_budget_usd", + "error_max_structured_output_retries", +] as const)("a %s result publishes final usage before failure", async (subtype) => { + const usage = { + inputTokens: 2, + cachedInputTokens: 12577, + outputTokens: 110, + totalCostUsd: 0.00365537, + }; + const [failed, next] = await runTurns([ + { before: "Running the command.", isError: true, subtype, usage }, + { after: "Done.", isError: false }, + ]); + + // Collection ends at the terminal event, so this also checks ordering. + expect(failed).toContainEqual( + expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }), + ); + expect(terminal(failed)).toMatchObject({ + type: "turn_failed", + error: "Reached maximum number of turns (1)", + }); + expect(assistantTexts(failed)).toEqual(["Running the command."]); + expect(terminal(next)).toMatchObject({ type: "turn_completed" }); + expect(assistantTexts(next)).toEqual(["Done."]); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 749bbe016f..cc30825f94 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -5037,6 +5037,7 @@ class ClaudeAgentSession implements AgentSession { "errors" in message && Array.isArray(message.errors) && message.errors.length > 0 ? message.errors.join("\n") : "Claude run failed"; + if (usage) events.push({ type: "usage_updated", provider: "claude", usage }); events.push(...this.sidechainTracker.finishAll("failed")); events.push(this.buildTurnFailedEvent(errorMessage)); }