From 9d9f101ab5e88f918fbf7eeb03f152d090c02ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Kotowski?= Date: Wed, 7 Oct 2026 12:39:17 +0200 Subject: [PATCH] fix(native-chat): place mid-turn and `!` prompts where they were sent Claude records a prompt sent while it is busy only as a `queued_command` attachment, and a `!` shell command as a `` row that chat hides as harness noise. Neither gave the optimistic echo a row to match, so the echo stayed pinned below every later turn. Decode both as the user row they stand for; keep `!` commands out of the turn lifecycle so they never mark the agent working. Fixes #14308, #20846, #25956. Co-Authored-By: Claude Opus 5.5 --- ...line-decoders-claude-queued-prompt.test.ts | 88 +++++++++++++++++++ .../transcript-line-decoders-claude.ts | 40 ++++++++- .../transcript-turn-lifecycle.test.ts | 36 ++++++++ .../native-chat/transcript-turn-lifecycle.ts | 16 +++- src/shared/claude-bash-input.test.ts | 19 ++++ src/shared/claude-bash-input.ts | 18 ++++ 6 files changed, 213 insertions(+), 4 deletions(-) create mode 100644 src/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.ts create mode 100644 src/shared/claude-bash-input.test.ts create mode 100644 src/shared/claude-bash-input.ts diff --git a/src/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.ts b/src/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.ts new file mode 100644 index 000000000000..7d2252ec6ae6 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' + +// Shape captured from Claude Code 2.1.292 for a prompt absorbed mid-turn. +function queuedCommand(attachment: Record): string { + return JSON.stringify({ + parentUuid: 'tool-result-row', + isSidechain: false, + attachment: { + type: 'queued_command', + source_uuid: 'cmd-1', + delivery_id: 'delivery-1', + timestamp: '2026-10-06T21:49:54.910Z', + ...attachment + }, + type: 'attachment', + uuid: 'queued-row', + timestamp: '2026-10-06T21:49:54.910Z', + rendered: [{ content: '\nThe user sent a new message…' }] + }) +} + +describe('Claude queued_command attachment', () => { + it('decodes a prompt absorbed mid-turn as the user row Claude never writes', () => { + const line = queuedCommand({ + prompt: 'or you know what, use fable as orchestrator', + commandMode: 'prompt', + origin: { kind: 'human' }, + humanTurn: true + }) + expect(decodeClaudeTranscriptLine(line, 'fallback')).toEqual({ + id: 'queued-row', + role: 'user', + blocks: [{ type: 'text', text: 'or you know what, use fable as orchestrator' }], + timestamp: Date.parse('2026-10-06T21:49:54.910Z'), + source: 'transcript', + parentId: 'tool-result-row' + }) + }) + + it('decodes legacy entries without an origin and content-block prompts', () => { + const decoded = decodeClaudeTranscriptLine( + queuedCommand({ + prompt: [{ type: 'text', text: 'is this true?' }], + commandMode: 'prompt' + }), + 'fallback' + ) + expect(decoded?.role).toBe('user') + expect(decoded?.blocks).toEqual([{ type: 'text', text: 'is this true?' }]) + }) + + it.each([ + { prompt: 'done', commandMode: 'task-notification' }, + { + prompt: 'done', + commandMode: 'task-notification', + origin: { kind: 'task-notification' } + }, + { prompt: 'from another agent', commandMode: 'prompt', origin: { kind: 'peer' } }, + { commandMode: 'prompt', origin: { kind: 'human' } } + ])('drops queue entries the user did not type (%o)', (attachment) => { + expect(decodeClaudeTranscriptLine(queuedCommand(attachment), 'fallback')).toBeNull() + }) + + it('still drops other attachment records', () => { + const line = JSON.stringify({ + type: 'attachment', + uuid: 'a', + attachment: { type: 'edited_text_file', filename: 'x.ts' } + }) + expect(decodeClaudeTranscriptLine(line, 'fallback')).toBeNull() + }) +}) + +describe('Claude `!` shell command row', () => { + it('decodes as the line the user typed so its echo can match', () => { + const line = JSON.stringify({ + type: 'user', + uuid: 'bash-row', + timestamp: '2026-10-06T10:00:00.000Z', + message: { role: 'user', content: ' gcloud auth login' } + }) + expect(decodeClaudeTranscriptLine(line, 'fallback')?.blocks).toEqual([ + { type: 'text', text: '! gcloud auth login' } + ]) + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts index 8693b4296a9f..5d8ec177ea93 100644 --- a/src/main/native-chat/transcript-line-decoders-claude.ts +++ b/src/main/native-chat/transcript-line-decoders-claude.ts @@ -16,6 +16,7 @@ import { import { imageSourcePathFromText } from '../../shared/native-chat-image-transcript-markers' import { claudeContentBlocks } from './transcript-record-blocks' import { unwrapClaudePastedContentBlock } from '../../shared/claude-pasted-content' +import { unwrapClaudeBashInputBlock } from '../../shared/claude-bash-input' import { claudeInterruptedMessageId } from './transcript-turn-markers' const MAX_EDIT_PATCH_HUNKS = 40 @@ -75,10 +76,11 @@ export function decodeClaudeTranscriptLine( line: string, fallbackId: string ): NativeChatMessage | null { - const record = parseJsonObject(line) - if (!record) { + const parsed = parseJsonObject(line) + if (!parsed) { return null } + const record = claudeQueuedPromptAsUserRecord(parsed) ?? parsed const role = record.type if (role !== 'user' && role !== 'assistant') { return null @@ -129,13 +131,45 @@ export function decodeClaudeTranscriptLine( return { id: messageId ?? fallbackId, role: claudeMessageRole(role, blocks), - blocks: role === 'user' ? blocks.map(unwrapClaudePastedContentBlock) : blocks, + // Why: a `!` command's echo reads `!cmd`; left as harness markup the row was + // hidden as noise and the echo stayed pinned at the tail (#25956). + blocks: + role === 'user' + ? blocks.map((block) => unwrapClaudeBashInputBlock(unwrapClaudePastedContentBlock(block))) + : blocks, timestamp, source: 'transcript', ...parent } } +/** A prompt sent mid-turn is recorded only as a `queued_command` attachment, never + * a `type:"user"` row; without one its echo had nothing to match and stayed + * pinned below every later turn (#14308, #20846). Task notifications and + * peer-agent messages share the attachment type but were not typed by the user. */ +function claudeQueuedPromptAsUserRecord( + record: Record +): Record | null { + if (record.type !== 'attachment') { + return null + } + const attachment = asRecord(record.attachment) + if (attachment?.type !== 'queued_command' || attachment.commandMode !== 'prompt') { + return null + } + // Why: older Claude versions omit `origin`; their prompt-mode queue entries + // were all typed by the user. + const originKind = asRecord(attachment.origin)?.kind + if (originKind !== undefined && originKind !== 'human') { + return null + } + const prompt = attachment.prompt + if (typeof prompt !== 'string' && !Array.isArray(prompt)) { + return null + } + return { ...record, type: 'user', message: { role: 'user', content: prompt } } +} + // Keep only genuine image companion records; a marker mixed with prose must // remain an injected turn (or be dropped), never become an image-source turn. function isImageSourceRecord(blocks: NativeChatBlock[]): boolean { diff --git a/src/main/native-chat/transcript-turn-lifecycle.test.ts b/src/main/native-chat/transcript-turn-lifecycle.test.ts index e46fcde00201..44bc59a33eda 100644 --- a/src/main/native-chat/transcript-turn-lifecycle.test.ts +++ b/src/main/native-chat/transcript-turn-lifecycle.test.ts @@ -243,6 +243,42 @@ describe('native chat transcript turn lifecycle', () => { ).toBeNull() }) + it.each([ + ' git status', + [{ type: 'text', text: 'ls' }] + ])('does not treat a `!` shell command as a new working generation', (content) => { + expect( + decodeClaudeTurnLifecycle( + JSON.stringify({ + type: 'user', + uuid: 'bash-1', + timestamp: '2026-07-16T23:46:12.000Z', + message: { role: 'user', content } + }), + 'fallback' + ) + ).toBeNull() + }) + + it('does not treat a prompt absorbed mid-turn as a new generation', () => { + expect( + decodeClaudeTurnLifecycle( + JSON.stringify({ + type: 'attachment', + uuid: 'queued-1', + timestamp: '2026-07-16T23:46:13.000Z', + attachment: { + type: 'queued_command', + prompt: 'also check the config', + commandMode: 'prompt', + origin: { kind: 'human' } + } + }), + 'fallback' + ) + ).toBeNull() + }) + it('excludes Claude tool-result rows that also carry text sidecars', () => { expect( decodeClaudeTurnLifecycle( diff --git a/src/main/native-chat/transcript-turn-lifecycle.ts b/src/main/native-chat/transcript-turn-lifecycle.ts index b91e4fa7beec..c243dc0ce762 100644 --- a/src/main/native-chat/transcript-turn-lifecycle.ts +++ b/src/main/native-chat/transcript-turn-lifecycle.ts @@ -1,6 +1,7 @@ import type { AgentType, NativeChatTurnLifecycle } from '../../shared/native-chat-types' import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { isNoiseMessage } from '../../shared/native-chat-noise' +import { claudeBashInputAsTypedCommand } from '../../shared/claude-bash-input' import { asRecord, extractString, @@ -120,12 +121,25 @@ export function decodeClaudeTurnLifecycle( // Why: harness noise (task-notification, system-reminder, …) is user-role in // the JSONL but not a new generation. Treating it as working would overwrite // a real terminal marker and re-stick the chat spinner after done/interrupt. - if (isNoiseMessage(decoded)) { + if (isNoiseMessage(decoded) || isClaudeBashInputRecord(record)) { return null } return { state: 'working', turnId: decoded.id, timestamp } } +// Why: a `!` command runs in the shell and starts no generation; the decoder shows +// it as a user row, but marking it working would stick the spinner. +function isClaudeBashInputRecord(record: Record): boolean { + const content = asRecord(record.message)?.content + const text = + typeof content === 'string' + ? content + : Array.isArray(content) && content.length === 1 + ? extractString(asRecord(content[0])?.text) + : null + return text != null && claudeBashInputAsTypedCommand(text) !== null +} + function lifecycleTimestamp(value: unknown): number | null { const parsed = timestampMs(value) return Number.isFinite(parsed) ? parsed : null diff --git a/src/shared/claude-bash-input.test.ts b/src/shared/claude-bash-input.test.ts new file mode 100644 index 000000000000..88626ecf1c59 --- /dev/null +++ b/src/shared/claude-bash-input.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { claudeBashInputAsTypedCommand } from './claude-bash-input' + +describe('claudeBashInputAsTypedCommand', () => { + it('restores the line as typed, keeping the space after `!`', () => { + expect(claudeBashInputAsTypedCommand(' gcloud auth login')).toBe( + '! gcloud auth login' + ) + expect(claudeBashInputAsTypedCommand('ls -la')).toBe('!ls -la') + }) + it.each([ + 'run ls', + 'ls and more', + 'ok', + 'ab' + ])('leaves anything else alone (%s)', (text) => { + expect(claudeBashInputAsTypedCommand(text)).toBeNull() + }) +}) diff --git a/src/shared/claude-bash-input.ts b/src/shared/claude-bash-input.ts new file mode 100644 index 000000000000..4c36a05c9f93 --- /dev/null +++ b/src/shared/claude-bash-input.ts @@ -0,0 +1,18 @@ +import type { NativeChatBlock } from './native-chat-types' + +const BASH_INPUT = /^\s*([\s\S]*)<\/bash-input>\s*$/ + +/** Claude records a `!` shell command as `cmd`, with the `!` + * stripped. Returns the line as typed, or null when the text is anything else. */ +export function claudeBashInputAsTypedCommand(text: string): string | null { + const command = BASH_INPUT.exec(text)?.[1] + return command === undefined || command.includes('') ? null : `!${command}` +} + +export function unwrapClaudeBashInputBlock(block: NativeChatBlock): NativeChatBlock { + if (block.type !== 'text') { + return block + } + const typed = claudeBashInputAsTypedCommand(block.text) + return typed === null ? block : { ...block, text: typed } +}