Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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: '<system-reminder>\nThe user sent a new message…</system-reminder>' }]
})
}

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: '<task-notification>done</task-notification>', commandMode: 'task-notification' },
{
prompt: '<task-notification>done</task-notification>',
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: '<bash-input> gcloud auth login</bash-input>' }
})
expect(decodeClaudeTranscriptLine(line, 'fallback')?.blocks).toEqual([
{ type: 'text', text: '! gcloud auth login' }
])
})
})
40 changes: 37 additions & 3 deletions src/main/native-chat/transcript-line-decoders-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Require a single Bash-input block before converting the user row.

If a user row contains a <bash-input> text block and another text block, this map converts the first block to a ! command. isClaudeBashInputRecord does not recognize that multi-block row and emits a working turn. Apply the single-envelope condition to the complete row so display and lifecycle decoding use the same boundary.

: 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<string, unknown>
): Record<string, unknown> | 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 {
Expand Down
36 changes: 36 additions & 0 deletions src/main/native-chat/transcript-turn-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,42 @@ describe('native chat transcript turn lifecycle', () => {
).toBeNull()
})

it.each([
'<bash-input> git status</bash-input>',
[{ type: 'text', text: '<bash-input>ls</bash-input>' }]
])('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(
Expand Down
16 changes: 15 additions & 1 deletion src/main/native-chat/transcript-turn-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string, unknown>): 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
Expand Down
19 changes: 19 additions & 0 deletions src/shared/claude-bash-input.test.ts
Original file line number Diff line number Diff line change
@@ -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('<bash-input> gcloud auth login</bash-input>')).toBe(
'! gcloud auth login'
)
expect(claudeBashInputAsTypedCommand('<bash-input>ls -la</bash-input>')).toBe('!ls -la')
})
it.each([
'run <bash-input>ls</bash-input>',
'<bash-input>ls</bash-input> and more',
'<bash-stdout>ok</bash-stdout>',
'<bash-input>a</bash-input><bash-input>b</bash-input>'
])('leaves anything else alone (%s)', (text) => {
expect(claudeBashInputAsTypedCommand(text)).toBeNull()
})
})
18 changes: 18 additions & 0 deletions src/shared/claude-bash-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { NativeChatBlock } from './native-chat-types'

const BASH_INPUT = /^\s*<bash-input>([\s\S]*)<\/bash-input>\s*$/

/** Claude records a `!` shell command as `<bash-input>cmd</bash-input>`, 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('<bash-input>') ? 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 }
}