diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 7e0cddfe5..4f7ccdb77 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -198,6 +198,22 @@ describe('createGatewayEventHandler', () => { expect(appended[3]?.text).not.toContain('```diff') }) + it('keeps full final responses from duplicating flushed pre-diff narration', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + const diff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' + const block = `\`\`\`diff\n${diff}\n\`\`\`` + + onEvent({ payload: { text: 'Before edit. ' }, type: 'message.delta' } as any) + onEvent({ payload: { context: 'foo.ts', name: 'patch', tool_id: 'tool-1' }, type: 'tool.start' } as any) + onEvent({ payload: { inline_diff: diff, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any) + onEvent({ payload: { text: 'After edit.' }, type: 'message.delta' } as any) + onEvent({ payload: { text: 'Before edit. After edit.' }, type: 'message.complete' } as any) + + expect(appended.map(msg => msg.text.trim()).filter(Boolean)).toEqual(['Before edit.', block, 'After edit.']) + expect(appended[1]?.tools?.[0]).toContain('Patch') + }) + it('drops the diff segment when the final assistant text narrates the same diff', () => { const appended: Msg[] = [] const onEvent = createGatewayEventHandler(buildCtx(appended)) diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 0dadbfbcd..540d3793d 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -40,6 +40,22 @@ const diffSegmentBody = (msg: Msg): null | string => { const hasDetails = (msg: Msg): boolean => Boolean(msg.thinking || msg.tools?.length || msg.toolTokens) +const textSegments = (segments: Msg[]) => segments.filter(msg => msg.role === 'assistant' && msg.kind !== 'diff').map(msg => msg.text) + +const finalTail = (finalText: string, segments: Msg[]) => { + let tail = finalText + + for (const text of textSegments(segments)) { + const trimmed = text.trim() + + if (trimmed && tail.startsWith(trimmed)) { + tail = tail.slice(trimmed.length).trimStart() + } + } + + return tail +} + export interface InterruptDeps { appendMessage: (msg: Msg) => void gw: { request: (method: string, params?: Record) => Promise } @@ -294,7 +310,7 @@ class TurnController { recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) { const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart() const split = splitReasoning(rawText) - const finalText = split.text + const finalText = finalTail(split.text, this.segmentMessages) const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim() const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n') const savedToolTokens = this.toolTokenAcc