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 }
+}