From 40340ef50533068ae02ed9e4bd3608efa6e915db Mon Sep 17 00:00:00 2001 From: ytahdn <1294726970@qq.com> Date: Tue, 7 Jul 2026 21:38:56 +0800 Subject: [PATCH] fix(serve): classify interrupted model stream errors (#6422) * fix(serve): classify interrupted model streams * fix(serve): address interrupted stream review * test(webui): cover legacy terminated turn error fallback * fix(web-shell): preserve error message data shape * test(daemon): cover turn error fallback boundaries * fix(web-shell): preserve classified error data --------- Co-authored-by: ytahdn --- packages/acp-bridge/src/bridge.test.ts | 60 ++++++++- packages/acp-bridge/src/bridge.ts | 19 +++ packages/sdk-typescript/src/daemon/events.ts | 1 + packages/sdk-typescript/src/daemon/types.ts | 2 + .../src/daemon/ui/normalizer.ts | 2 + .../src/daemon/ui/transcript.ts | 3 + .../sdk-typescript/src/daemon/ui/types.ts | 1 + .../sdk-typescript/test/unit/daemonUi.test.ts | 97 ++++++++++++++ packages/web-shell/client/App.test.tsx | 43 ++++++ .../adapters/transcriptToMessages.test.ts | 125 ++++++++++++++++++ .../client/adapters/transcriptToMessages.ts | 44 ++++-- .../web-shell/client/hooks/useMessages.ts | 1 + packages/web-shell/client/i18n.tsx | 3 + .../src/daemon/transcriptAdapter.test.ts | 83 ++++++++++++ .../webui/src/daemon/transcriptAdapter.ts | 25 +++- 15 files changed, 495 insertions(+), 14 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index ee46a1082a..027f5859e3 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -40,7 +40,11 @@ import { WorkspaceMismatchError, } from './bridgeErrors.js'; import { MAX_WORKSPACE_PATH_LENGTH } from './workspacePaths.js'; -import { extractErrorMessage, extractErrorCode } from './bridge.js'; +import { + classifyTurnErrorKind, + extractErrorMessage, + extractErrorCode, +} from './bridge.js'; import { SERVE_STATUS_EXT_METHODS } from './status.js'; import type { ChannelFactory } from './channel.js'; import type { BridgeTelemetry } from './bridgeOptions.js'; @@ -3376,6 +3380,42 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('adds structured errorKind when a turn ends with terminated', async () => { + const handle = makeChannel({ + promptImpl: () => { + throw new RequestError(-32603, 'terminated'); + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const turnError = (async () => { + for await (const event of iter) { + if (event.type === 'turn_error') return event; + } + throw new Error('turn_error was not published'); + })(); + + await expect( + bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'stream may fail' }], + }), + ).rejects.toThrow('terminated'); + + const event = await turnError; + expect(event.data).toMatchObject({ + message: 'terminated', + errorKind: 'model_stream_interrupted', + }); + + abort.abort(); + await bridge.shutdown(); + }); + it('echoes user_message_chunk to ALL session subscribers (cross-client sync)', async () => { // Cross-client sync fix: a prompt sent by client A must be visible // to every SSE subscriber of the same session — not just the @@ -10287,6 +10327,24 @@ describe('extractErrorCode', () => { }); }); +describe('classifyTurnErrorKind', () => { + it('classifies bare terminated errors as model stream interruptions', () => { + expect(classifyTurnErrorKind('terminated')).toBe( + 'model_stream_interrupted', + ); + expect(classifyTurnErrorKind('Terminated')).toBe( + 'model_stream_interrupted', + ); + expect(classifyTurnErrorKind(' terminated ')).toBe( + 'model_stream_interrupted', + ); + }); + + it('leaves unrelated turn errors unclassified', () => { + expect(classifyTurnErrorKind('API rate limit exceeded')).toBeUndefined(); + }); +}); + // --------------------------------------------------------------------------- // §2.3 side-channel state layer: publish helpers + reconciliation + snapshot // --------------------------------------------------------------------------- diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 8fcfa8366c..e144e794a0 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -912,6 +912,14 @@ export function extractErrorCode(err: unknown): string | undefined { return undefined; } +export function classifyTurnErrorKind( + message: string, +): 'model_stream_interrupted' | undefined { + return message.trim().toLowerCase() === 'terminated' + ? 'model_stream_interrupted' + : undefined; +} + function broadcastTurnError( entry: SessionEntry, sessionId: string, @@ -921,6 +929,16 @@ function broadcastTurnError( ): void { const message = extractErrorMessage(err); const code = extractErrorCode(err); + const errorKind = classifyTurnErrorKind(message); + if (errorKind) { + writeServeDebugLine( + `turn_error classified session=${JSON.stringify(sessionId)} ` + + `message=${JSON.stringify(message)} ` + + `errorKind=${JSON.stringify(errorKind)}` + + (code ? ` code=${JSON.stringify(code)}` : '') + + (promptId ? ` promptId=${JSON.stringify(promptId)}` : ''), + ); + } entry.retryAllowed = true; try { entry.events.publish({ @@ -929,6 +947,7 @@ function broadcastTurnError( sessionId, message, ...(code ? { code } : {}), + ...(errorKind ? { errorKind } : {}), ...(promptId ? { promptId } : {}), }, ...(originatorClientId ? { originatorClientId } : {}), diff --git a/packages/sdk-typescript/src/daemon/events.ts b/packages/sdk-typescript/src/daemon/events.ts index 232a0d54a5..250ba0a48e 100644 --- a/packages/sdk-typescript/src/daemon/events.ts +++ b/packages/sdk-typescript/src/daemon/events.ts @@ -757,6 +757,7 @@ export interface DaemonTurnErrorData { sessionId: string; message: string; code?: string; + errorKind?: DaemonErrorKind | (string & {}); promptId?: string; [key: string]: unknown; } diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index fa7838f37d..fb4255f651 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -730,6 +730,8 @@ export const DAEMON_ERROR_KINDS = [ // An SSE writer's last successful flush was older than the daemon's // writer-idle deadline. 'writer_idle_timeout', + // The model response stream ended before a complete turn could be read. + 'model_stream_interrupted', ] as const; export type DaemonErrorKind = (typeof DAEMON_ERROR_KINDS)[number]; diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index d27d2b6c2c..37d697ea60 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -159,6 +159,7 @@ export function normalizeDaemonEvent( } case 'turn_error': { const code = getString(event.data, 'code'); + const errorKind = asDaemonErrorKind(getString(event.data, 'errorKind')); const promptId = getString(event.data, 'promptId'); return [ { @@ -167,6 +168,7 @@ export function normalizeDaemonEvent( source: 'turn_error', recoverable: true, ...(code ? { code } : {}), + ...(errorKind ? { errorKind } : {}), ...(promptId ? { promptId } : {}), text: getString(event.data, 'message') ?? diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 2a619c1ceb..bdc8ac1a63 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -1016,6 +1016,9 @@ function appendStatusBlock( ...(event?.type === 'error' && event.promptId ? { promptId: event.promptId } : {}), + ...(event?.type === 'error' && event.errorKind + ? { errorKind: event.errorKind } + : {}), ...(event?.type === 'error' && event.source ? { source: event.source } : {}), diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index ef3b64bffb..0b7be64816 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -852,6 +852,7 @@ export interface DaemonStatusTranscriptBlock extends DaemonTranscriptBlockBase { text: string; code?: string; promptId?: string; + errorKind?: DaemonErrorKind; source?: string; data?: unknown; } diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 10c90b9fa6..b45cca008c 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -1196,6 +1196,103 @@ describe('daemon UI normalizer and transcript reducer', () => { ); }); + it('preserves structured model stream interruption turn errors', () => { + expect( + normalizeDaemonEvent({ + id: 44, + v: 1, + type: 'turn_error', + data: { + sessionId: 'session-1', + message: 'terminated', + code: '-32603', + errorKind: 'model_stream_interrupted', + promptId: 'prompt-1', + }, + }), + ).toMatchObject([ + { + type: 'error', + source: 'turn_error', + recoverable: true, + code: '-32603', + errorKind: 'model_stream_interrupted', + promptId: 'prompt-1', + text: 'terminated', + }, + ]); + }); + + it('keeps structured model stream interruption on transcript blocks', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 100 }), + normalizeDaemonEvent({ + id: 46, + v: 1, + type: 'turn_error', + data: { + sessionId: 'session-1', + message: 'terminated', + code: '-32603', + errorKind: 'model_stream_interrupted', + promptId: 'prompt-1', + }, + }), + { now: 101 }, + ); + + expect(state.blocks).toMatchObject([ + { + kind: 'error', + source: 'turn_error', + text: 'terminated', + code: '-32603', + errorKind: 'model_stream_interrupted', + promptId: 'prompt-1', + }, + ]); + }); + + it('keeps older daemon terminated turn error text unchanged', () => { + expect( + normalizeDaemonEvent({ + id: 45, + v: 1, + type: 'turn_error', + data: { + sessionId: 'session-1', + message: 'terminated', + }, + }), + ).toMatchObject([ + { + type: 'error', + source: 'turn_error', + text: 'terminated', + }, + ]); + }); + + it('drops unrecognized errorKind from turn error events', () => { + const [event] = normalizeDaemonEvent({ + id: 47, + v: 1, + type: 'turn_error', + data: { + sessionId: 'session-1', + message: 'some error', + errorKind: 'some_future_kind', + }, + }); + + expect(event).toMatchObject({ + type: 'error', + source: 'turn_error', + text: 'some error', + }); + expect(event).not.toHaveProperty('errorKind'); + }); + it('normalizes daemon lifecycle and control events', () => { expect( normalizeDaemonEvent({ diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 08e9cd8014..5bd3197ee2 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -808,6 +808,49 @@ describe('App session callbacks', () => { ); }); + it('allows manual retry after a model stream interrupted turn error', async () => { + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = 'recover this stream'; + await clickSubmit(container); + expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith( + 'recover this stream', + expect.objectContaining({ retry: undefined }), + ); + + mockSessionActions.sendPrompt.mockClear(); + act(() => { + testState.blocks = [ + { + kind: 'error', + source: 'turn_error', + id: 'turn-error-stream-interrupted', + errorKind: 'model_stream_interrupted', + text: 'terminated', + }, + ]; + rerender(); + }); + + expect(container.querySelector('[data-testid="retry"]')).not.toBeNull(); + + await act(async () => { + container + .querySelector('[data-testid="retry"]') + ?.click(); + await Promise.resolve(); + }); + + expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith( + 'recover this stream', + expect.objectContaining({ + optimisticUserMessage: false, + retry: true, + }), + ); + }); + it('gates queued submissions and only enqueues after approval', async () => { let approve: (() => void) | undefined; const onSubmitBefore = vi.fn( diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index bf935630ef..cf8cdcaf10 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -2179,6 +2179,131 @@ describe('transcriptBlocksToDaemonMessages', () => { ]); }); + it('renders model stream interruption errors from structured errorKind labels', () => { + const messages = transcriptBlocksToDaemonMessages( + [ + { + id: 'err-1', + kind: 'error' as const, + source: 'turn_error' as const, + errorKind: 'model_stream_interrupted' as const, + text: 'terminated', + data: { diagnosticId: 'abc' }, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + ], + { + labels: { + modelStreamInterrupted: 'Localized stream interruption.', + }, + }, + ); + + expect(messages).toEqual([ + { + id: 'err-1', + role: 'system', + content: 'Localized stream interruption.', + variant: 'error', + retryable: true, + source: 'turn_error', + data: { + diagnosticId: 'abc', + errorKind: 'model_stream_interrupted', + }, + timestamp: 1, + }, + ]); + }); + + it('upgrades older daemon terminated turn errors to localized text', () => { + const messages = transcriptBlocksToDaemonMessages( + [ + { + id: 'err-1', + kind: 'error' as const, + source: 'turn_error' as const, + text: 'terminated', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + ], + { + labels: { + modelStreamInterrupted: 'Localized stream interruption.', + }, + }, + ); + + expect(messages[0]).toMatchObject({ + content: 'Localized stream interruption.', + retryable: true, + source: 'turn_error', + }); + }); + + it('does not add data solely for structured errorKind labels', () => { + const messages = transcriptBlocksToDaemonMessages( + [ + { + id: 'err-1', + kind: 'error' as const, + source: 'turn_error' as const, + errorKind: 'model_stream_interrupted' as const, + text: 'terminated', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + ], + { + labels: { + modelStreamInterrupted: 'Localized stream interruption.', + }, + }, + ); + + expect(messages[0]).toMatchObject({ + content: 'Localized stream interruption.', + retryable: true, + source: 'turn_error', + }); + expect(messages[0]).not.toHaveProperty('data'); + }); + + it('preserves non-object error data when adding structured errorKind', () => { + const messages = transcriptBlocksToDaemonMessages( + [ + { + id: 'err-1', + kind: 'error' as const, + source: 'turn_error' as const, + errorKind: 'model_stream_interrupted' as const, + text: 'terminated', + data: 'diagnostic text', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + ], + { + labels: { + modelStreamInterrupted: 'Localized stream interruption.', + }, + }, + ); + + expect(messages[0]).toMatchObject({ + data: { + value: 'diagnostic text', + errorKind: 'model_stream_interrupted', + }, + }); + }); + it('converts debug blocks to system messages with info variant', () => { const messages = transcriptBlocksToDaemonMessages([ { diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index 3b09fedfca..f847bbc000 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -33,11 +33,6 @@ type DaemonPermissionTranscriptBlock = Extract< { kind: 'permission' } >; -type ExtendedDaemonStatusTranscriptBlock = DaemonStatusTranscriptBlock & { - source?: string; - data?: unknown; -}; - type ExtendedDaemonTextTranscriptBlock = DaemonTextTranscriptBlock & { meta?: { source?: unknown; @@ -50,6 +45,7 @@ interface TranscriptMessageLabels { promptCancelled?: string; branchSuccess?: (name: string) => string; midTurnInserted?: (message: string) => string; + modelStreamInterrupted?: string; } interface TranscriptMessageOptions { @@ -63,6 +59,35 @@ function isIgnoredWebShellStatus(text: string): boolean { ); } +function getErrorDisplayText( + block: DaemonStatusTranscriptBlock, + labels?: TranscriptMessageLabels, +): string { + if ( + block.errorKind === 'model_stream_interrupted' || + // Older daemons emit this turn_error before they know about errorKind. + (block.source === 'turn_error' && + block.text.trim().toLowerCase() === 'terminated') + ) { + return labels?.modelStreamInterrupted ?? block.text; + } + return block.text; +} + +function getErrorMessageData( + data: unknown, + errorKind: DaemonStatusTranscriptBlock['errorKind'], +): { data?: unknown } { + if (data === undefined) return {}; + if (!errorKind) return { data }; + return { + data: { + ...(getRecord(data) ?? { value: data }), + errorKind, + }, + }; +} + function getSessionBranchDisplayName(data: unknown): string | null { if (!data || typeof data !== 'object') return null; const branchData = data as { @@ -530,7 +555,7 @@ export function transcriptBlocksToDaemonMessages( case 'status': case 'debug': { - const statusBlock = block as ExtendedDaemonStatusTranscriptBlock; + const statusBlock = block; const branchDisplayName = statusBlock.source === 'session_branched' ? getSessionBranchDisplayName(statusBlock.data) @@ -575,16 +600,17 @@ export function transcriptBlocksToDaemonMessages( } case 'error': { - const errorBlock = block as ExtendedDaemonStatusTranscriptBlock; + const errorBlock = block; + const errorKind = errorBlock.errorKind; messages.push({ id: block.id, role: 'system', - content: errorBlock.text, + content: getErrorDisplayText(errorBlock, options.labels), variant: 'error', retryable: errorBlock.source === 'turn_error', timestamp: blockTime, ...(errorBlock.source ? { source: errorBlock.source } : {}), - ...(errorBlock.data !== undefined ? { data: errorBlock.data } : {}), + ...getErrorMessageData(errorBlock.data, errorKind), }); needsNewContentMessage = true; break; diff --git a/packages/web-shell/client/hooks/useMessages.ts b/packages/web-shell/client/hooks/useMessages.ts index 9959ea3253..ddda0d1f14 100644 --- a/packages/web-shell/client/hooks/useMessages.ts +++ b/packages/web-shell/client/hooks/useMessages.ts @@ -14,6 +14,7 @@ export function useMessages( promptCancelled: t('request.cancelled'), branchSuccess: (name) => t('branch.success', { name }), midTurnInserted: (message) => t('midTurn.inserted', { message }), + modelStreamInterrupted: t('error.modelStreamInterrupted'), }, }), [blocks, t], diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index f231784c97..5c689abb2a 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -827,6 +827,8 @@ const EN: Messages = { 'bug.submitted': 'Bug report opened in a new tab.', 'clear.blocked': 'Cannot clear while streaming — cancel first (Esc).', 'error.unknown': 'Unknown error', + 'error.modelStreamInterrupted': + 'Model response stream was interrupted. Please retry.', 'shell.command': 'Shell Command', 'compact.enabled': 'Compact mode enabled', 'compact.disabled': 'Compact mode disabled', @@ -2439,6 +2441,7 @@ const ZH: Messages = { 'bug.submitted': 'Bug 报告已在新标签页中打开。', 'clear.blocked': '流式输出中无法清屏 — 先按 Esc 取消。', 'error.unknown': '未知错误', + 'error.modelStreamInterrupted': '模型响应流已中断,请重试。', 'shell.command': 'Shell 命令', 'compact.enabled': '紧凑模式已开启', 'compact.disabled': '紧凑模式已关闭', diff --git a/packages/webui/src/daemon/transcriptAdapter.test.ts b/packages/webui/src/daemon/transcriptAdapter.test.ts index cf0d0476ce..d87300331f 100644 --- a/packages/webui/src/daemon/transcriptAdapter.test.ts +++ b/packages/webui/src/daemon/transcriptAdapter.test.ts @@ -31,6 +31,89 @@ describe('daemonTranscriptToUnifiedMessages', () => { }); }); + it('renders model stream interruption turn errors with user-facing text', () => { + const [message] = daemonTranscriptToUnifiedMessages([ + { + id: 'error-1', + kind: 'error', + source: 'turn_error', + errorKind: 'model_stream_interrupted', + text: 'terminated', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + ]); + + expect(message).toMatchObject({ + type: 'tool_call', + toolCall: { + kind: 'system_error', + rawOutput: 'Model response stream was interrupted. Please retry.', + content: [ + { + content: { + text: 'Model response stream was interrupted. Please retry.', + error: 'Model response stream was interrupted. Please retry.', + }, + }, + ], + }, + }); + }); + + it('renders older daemon terminated turn errors with user-facing text', () => { + const [message] = daemonTranscriptToUnifiedMessages([ + { + id: 'error-legacy', + kind: 'error', + source: 'turn_error', + text: 'terminated', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + ]); + + expect(message).toMatchObject({ + type: 'tool_call', + toolCall: { + kind: 'system_error', + rawOutput: 'Model response stream was interrupted. Please retry.', + content: [ + { + content: { + text: 'Model response stream was interrupted. Please retry.', + error: 'Model response stream was interrupted. Please retry.', + }, + }, + ], + }, + }); + }); + + it('passes non-terminated turn error text through unchanged', () => { + const [message] = daemonTranscriptToUnifiedMessages([ + { + id: 'error-other', + kind: 'error', + source: 'turn_error', + text: 'context deadline exceeded', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + ]); + + expect(message).toMatchObject({ + type: 'tool_call', + toolCall: { + kind: 'system_error', + rawOutput: 'context deadline exceeded', + }, + }); + }); + it('maps daemon tool statuses without leaving terminal states spinning', () => { const messages = daemonTranscriptToUnifiedMessages([ createToolBlock('cancelled-tool', 'cancelled'), diff --git a/packages/webui/src/daemon/transcriptAdapter.ts b/packages/webui/src/daemon/transcriptAdapter.ts index 8492f23bbf..9fe3135840 100644 --- a/packages/webui/src/daemon/transcriptAdapter.ts +++ b/packages/webui/src/daemon/transcriptAdapter.ts @@ -10,6 +10,7 @@ import { isDaemonUiSensitiveKey, sanitizeDaemonTerminalText, type DaemonTranscriptBlock, + type DaemonStatusTranscriptBlock, type DaemonToolTranscriptBlock, } from '@qwen-code/sdk/daemon'; import type { UnifiedMessage } from '../adapters/types.js'; @@ -43,6 +44,9 @@ export interface DaemonTranscriptAdapterOptions { enrichToolDetailsWithPreview?: boolean; } +const MODEL_STREAM_INTERRUPTED_MESSAGE = + 'Model response stream was interrupted. Please retry.'; + export function daemonTranscriptToUnifiedMessages( blocks: readonly DaemonTranscriptBlock[], options: DaemonTranscriptAdapterOptions = {}, @@ -145,7 +149,8 @@ export function daemonTranscriptToUnifiedMessages( isLast, }, ]; - case 'error': + case 'error': { + const text = getErrorDisplayText(block); return [ { id: block.id, @@ -156,14 +161,14 @@ export function daemonTranscriptToUnifiedMessages( kind: 'system_error', title: 'System error', status: 'failed', - rawOutput: sanitizeDisplayText(block.text), + rawOutput: sanitizeDisplayText(text), content: [ { type: 'content', content: { type: 'error', - text: sanitizeDisplayText(block.text), - error: sanitizeDisplayText(block.text), + text: sanitizeDisplayText(text), + error: sanitizeDisplayText(text), }, }, ], @@ -172,6 +177,7 @@ export function daemonTranscriptToUnifiedMessages( isLast, }, ]; + } case 'status': return [ { @@ -196,6 +202,17 @@ export function daemonTranscriptToUnifiedMessages( }); } +function getErrorDisplayText(block: DaemonStatusTranscriptBlock): string { + if ( + block.errorKind === 'model_stream_interrupted' || + (block.source === 'turn_error' && + block.text.trim().toLowerCase() === 'terminated') + ) { + return MODEL_STREAM_INTERRUPTED_MESSAGE; + } + return block.text; +} + function daemonToolBlockToToolCallData( block: DaemonToolTranscriptBlock, enrichDetails: boolean = false,