diff --git a/apps/agent-worker/src/runtime/turn-outcome.ts b/apps/agent-worker/src/runtime/turn-outcome.ts index 44e689e..f7b45ce 100644 --- a/apps/agent-worker/src/runtime/turn-outcome.ts +++ b/apps/agent-worker/src/runtime/turn-outcome.ts @@ -42,3 +42,19 @@ export const resolveTurnOutcome = (args: { } return { text }; }; + +// Pure decision for whether the `content` on an `assistant_completed` event +// should be folded into the accumulated assistant text. +// +// Some runtimes (Claude `-p --output-format stream-json`, OpenCode) emit the +// final answer TWICE: once as streamed `assistant_delta` chunks and again as +// the `content` on the terminal `assistant_completed`/`result` event. Appending +// both yields a doubled "". So only fold in the completed +// content when nothing has streamed yet (a runtime that emits only a final +// result). When deltas already carried the answer, skip it. The truly-empty +// case is still covered by resolveTurnOutcome's recoveredText/finalMessage +// fallback, so nothing is lost. +export const shouldAppendCompletedContent = ( + currentText: string, + completedContent?: string, +): boolean => Boolean(completedContent) && !currentText.trim(); diff --git a/apps/agent-worker/src/worker.ts b/apps/agent-worker/src/worker.ts index 4cae2e8..6953619 100644 --- a/apps/agent-worker/src/worker.ts +++ b/apps/agent-worker/src/worker.ts @@ -18,7 +18,10 @@ import type { AdapterWorkspace, Claim } from './runtime/provider'; import type { BoxHandle } from './user-container'; import type { AgentRuntimeName } from './runtime/agent-runtime'; import { getAdapter } from './runtime/agent-runtime'; -import { resolveTurnOutcome } from './runtime/turn-outcome'; +import { + resolveTurnOutcome, + shouldAppendCompletedContent, +} from './runtime/turn-outcome'; import './runtime/register'; import { env } from './env'; import { @@ -344,7 +347,7 @@ const handleAgentEvent = async (args: { workspace.agentTurnActive = false; workspace.resolveTurn?.(); workspace.resolveTurn = undefined; - if (event.content) { + if (shouldAppendCompletedContent(assistantContent.value, event.content)) { assistantContent.value = truncate( `${assistantContent.value}${event.content}`, 40_000, diff --git a/apps/agent-worker/tests/unit/turn-outcome.test.ts b/apps/agent-worker/tests/unit/turn-outcome.test.ts index 16a34ab..72892bb 100644 --- a/apps/agent-worker/tests/unit/turn-outcome.test.ts +++ b/apps/agent-worker/tests/unit/turn-outcome.test.ts @@ -1,6 +1,36 @@ import { describe, expect, test } from 'vitest'; -import { resolveTurnOutcome } from '../../src/runtime/turn-outcome'; +import { + resolveTurnOutcome, + shouldAppendCompletedContent, +} from '../../src/runtime/turn-outcome'; + +describe('shouldAppendCompletedContent', () => { + test('does not re-append when deltas already streamed the answer', () => { + // Claude/OpenCode emit the final answer as assistant_delta AND as the + // content-bearing assistant_completed event. Folding the completed + // content in again would produce "". + expect(shouldAppendCompletedContent('the answer', 'the answer')).toBe( + false, + ); + }); + + test('appends completed content when nothing has streamed yet', () => { + expect(shouldAppendCompletedContent('', 'the final result')).toBe(true); + }); + + test('treats whitespace-only streamed text as empty', () => { + expect(shouldAppendCompletedContent(' \n', 'the final result')).toBe( + true, + ); + }); + + test('does not append when the completed event carries no content', () => { + expect(shouldAppendCompletedContent('the answer', undefined)).toBe(false); + expect(shouldAppendCompletedContent('', undefined)).toBe(false); + expect(shouldAppendCompletedContent('', '')).toBe(false); + }); +}); describe('resolveTurnOutcome', () => { test('partial streamed text + error is surfaced as failed and text preserved', () => {