fix(worker): don't double-append streamed answer when completed event carries content
This commit is contained in:
@@ -42,3 +42,19 @@ export const resolveTurnOutcome = (args: {
|
|||||||
}
|
}
|
||||||
return { text };
|
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 "<answer><answer>". 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();
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import type { AdapterWorkspace, Claim } from './runtime/provider';
|
|||||||
import type { BoxHandle } from './user-container';
|
import type { BoxHandle } from './user-container';
|
||||||
import type { AgentRuntimeName } from './runtime/agent-runtime';
|
import type { AgentRuntimeName } from './runtime/agent-runtime';
|
||||||
import { getAdapter } 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 './runtime/register';
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import {
|
import {
|
||||||
@@ -344,7 +347,7 @@ const handleAgentEvent = async (args: {
|
|||||||
workspace.agentTurnActive = false;
|
workspace.agentTurnActive = false;
|
||||||
workspace.resolveTurn?.();
|
workspace.resolveTurn?.();
|
||||||
workspace.resolveTurn = undefined;
|
workspace.resolveTurn = undefined;
|
||||||
if (event.content) {
|
if (shouldAppendCompletedContent(assistantContent.value, event.content)) {
|
||||||
assistantContent.value = truncate(
|
assistantContent.value = truncate(
|
||||||
`${assistantContent.value}${event.content}`,
|
`${assistantContent.value}${event.content}`,
|
||||||
40_000,
|
40_000,
|
||||||
|
|||||||
@@ -1,6 +1,36 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
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 "<answer><answer>".
|
||||||
|
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', () => {
|
describe('resolveTurnOutcome', () => {
|
||||||
test('partial streamed text + error is surfaced as failed and text preserved', () => {
|
test('partial streamed text + error is surfaced as failed and text preserved', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user