From 0a2307d199740e0e43567a47843434504867708f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 19 May 2026 17:43:26 +0800 Subject: [PATCH] Avoid duplicate Claude result text (#1095) --- .../agent/providers/claude/agent.test.ts | 60 +++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 21 +++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index be2fec564..8f4ae15a1 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1461,4 +1461,64 @@ describe("ClaudeAgentSession context window usage", () => { expect(timelineEvents).toEqual([]); expect(events.some((event) => event.type === "turn_completed")).toBe(true); }); + + test("result.result is not duplicated when assistant text already streamed with zero token usage", async () => { + const queryFactory = createQueryFactoryForTurns([ + [ + { + type: "system", + subtype: "init", + session_id: "session-third-party", + permissionMode: "default", + }, + { + type: "assistant", + message: { + id: "assistant-third-party-1", + role: "assistant", + content: [{ type: "text", text: "Here is the answer." }], + usage: { + input_tokens: 0, + output_tokens: 0, + }, + }, + session_id: "session-third-party", + uuid: "assistant-third-party-event-1", + }, + { + type: "result", + subtype: "success", + result: "Here is the answer.", + is_error: false, + duration_ms: 100, + duration_api_ms: 80, + num_turns: 1, + stop_reason: null, + total_cost_usd: 0.01, + usage: { + input_tokens: 10, + cache_read_input_tokens: 0, + output_tokens: 0, + }, + permission_denials: [], + uuid: "result-third-party-1", + session_id: "session-third-party", + }, + ], + ]); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + }); + + const result = await session.run("turn"); + await session.close(); + + expect(result.timeline).toEqual([{ type: "assistant_message", text: "Here is the answer." }]); + }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index f84f66ea9..8d88d7929 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1568,6 +1568,7 @@ class ClaudeAgentSession implements AgentSession { private pendingInterruptAbort = false; private lastForegroundPromptText: string | null = null; private foregroundHasVisibleActivity = false; + private activeTurnHasAssistantText = false; private lastContextWindowUsedTokens: number | undefined; private lastContextWindowMaxTokens: number | undefined; private lastStreamRequestInputTokens: number | undefined; @@ -1692,6 +1693,7 @@ class ClaudeAgentSession implements AgentSession { const turnId = this.createTurnId("foreground"); this.activeForegroundTurnId = turnId; this.foregroundHasVisibleActivity = false; + this.activeTurnHasAssistantText = false; this.transitionTurnState("foreground", "foreground turn started"); this.clearRecentStderr(); @@ -2630,6 +2632,7 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundTurnId = null; this.lastForegroundPromptText = null; this.cancelCurrentTurn = null; + this.activeTurnHasAssistantText = false; this.syncTurnState("foreground turn terminal"); } @@ -2645,9 +2648,11 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundTurnId = null; this.lastForegroundPromptText = null; this.cancelCurrentTurn = null; + this.activeTurnHasAssistantText = false; this.syncTurnState("foreground turn terminal"); } else if (this.autonomousTurn) { this.autonomousTurn = null; + this.activeTurnHasAssistantText = false; this.syncTurnState("autonomous turn terminal"); } } @@ -2660,6 +2665,7 @@ class ClaudeAgentSession implements AgentSession { this.autonomousTurn = { id: this.createTurnId("autonomous"), }; + this.activeTurnHasAssistantText = false; this.notifySubscribers({ type: "turn_started", provider: "claude" }); this.syncTurnState("autonomous turn started"); } @@ -2670,6 +2676,7 @@ class ClaudeAgentSession implements AgentSession { } this.notifySubscribers({ type: "turn_completed", provider: "claude" }); this.autonomousTurn = null; + this.activeTurnHasAssistantText = false; this.syncTurnState("autonomous turn completed"); } @@ -2898,7 +2905,6 @@ class ClaudeAgentSession implements AgentSession { if (events.length === 0) { return; } - if ( this.pendingInterruptAbort && message.type === "result" && @@ -2909,6 +2915,11 @@ class ClaudeAgentSession implements AgentSession { this.logger.debug("Suppressing stale Claude interrupt terminal result"); return; } + if ( + events.some((event) => event.type === "timeline" && event.item.type === "assistant_message") + ) { + this.activeTurnHasAssistantText = true; + } if ( this.activeForegroundTurnId && events.some( @@ -3231,12 +3242,12 @@ class ClaudeAgentSession implements AgentSession { if (message.subtype === "success") { // Built-in slash commands (e.g. /voice, /usage, "Unknown command: …") // run client-side in the Claude CLI with no model turn — output_tokens - // is 0 and the user-visible text is carried in `result`. Surface it as - // an assistant message so the turn doesn't end silently. Normal turns - // have output_tokens > 0 and their text is already in the stream. + // is 0 and the user-visible text is carried in `result`. Surface it only + // when the turn has not already emitted assistant text so zero-token + // accounting from provider gateways does not duplicate streamed output. const resultText = typeof message.result === "string" ? message.result.trim() : ""; const outputTokens = message.usage?.output_tokens; - if (resultText.length > 0 && outputTokens === 0) { + if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) { events.push({ type: "timeline", provider: "claude",