fix(native-chat): show prompts sent mid-turn and ! commands where Claude read them (#31204)

Co-authored-by: Paweł Kotowski <p.kotowski89@gmail.com>
This commit is contained in:
Kelvin Amoaba
2026-10-20 23:13:32 +00:00
committed by GitHub
co-authored by Paweł Kotowski
parent be29644ec9
commit ef950401d3
12 changed files with 382 additions and 8 deletions
@@ -0,0 +1,126 @@
import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../shared/native-chat-types'
import { projectNativeChatTranscriptMessages } from '../../shared/native-chat-transcript-projection'
import { readNativeChatTranscript } from './transcript-reader'
import { readNativeChatTranscriptTail, subscribeNativeChatTranscript } from './transcript-watch'
// A send while a tool runs: Claude stamps the queue entry at send time (10:00:10)
// but writes it after the tool result it read it with (10:00:30).
const PROMPT = {
type: 'user',
uuid: 'u-1',
timestamp: '2026-06-01T10:00:00.000Z',
message: { role: 'user', content: 'start the long task' }
}
const TOOL_CALL = {
type: 'assistant',
uuid: 'a-1',
timestamp: '2026-06-01T10:00:01.000Z',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'sleep 30' } }]
}
}
const TOOL_RESULT = {
type: 'user',
uuid: 'r-1',
timestamp: '2026-06-01T10:00:30.000Z',
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'done' }]
}
}
const QUEUED = {
type: 'attachment',
uuid: 'q-1',
parentUuid: 'r-1',
timestamp: '2026-06-01T10:00:10.000Z',
attachment: {
type: 'queued_command',
prompt: 'also check the config',
commandMode: 'prompt',
origin: { kind: 'human' },
timestamp: '2026-06-01T10:00:10.000Z'
}
}
const REPLY = {
type: 'assistant',
uuid: 'a-2',
timestamp: '2026-06-01T10:00:35.000Z',
message: {
role: 'assistant',
stop_reason: 'end_turn',
content: [{ type: 'text', text: 'both done' }]
}
}
const lines = (...records: object[]): string =>
records.map((record) => `${JSON.stringify(record)}\n`).join('')
let roots: string[] = []
afterEach(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })))
roots = []
})
async function transcriptFile(content: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-transcript-file-order-'))
roots.push(root)
const filePath = join(root, 'session.jsonl')
await writeFile(filePath, content)
return filePath
}
/** What the chat draws: the prompt after the tool run, with the call holding its result. */
function expectPromptAfterToolRun(messages: NativeChatMessage[]): void {
const rows = projectNativeChatTranscriptMessages(messages)
expect(rows.map((row) => row.id)).toEqual(['u-1', 'a-1', 'q-1', 'a-2'])
expect(rows[1]?.blocks.map((block) => block.type)).toEqual(['tool-call', 'tool-result'])
}
describe('transcript rows keep file order', () => {
it.each([
['full read', (filePath: string) => readNativeChatTranscript('claude', 's', { filePath })],
[
'tail read',
(filePath: string) =>
readNativeChatTranscriptTail({ agent: 'claude', sessionId: 's', filePath, limit: 40 })
]
])(
'places a mid-turn prompt after the tool result it was read with (%s)',
async (_name, read) => {
const filePath = await transcriptFile(lines(PROMPT, TOOL_CALL, TOOL_RESULT, QUEUED, REPLY))
const result = await read(filePath)
if (!('messages' in result)) {
throw new Error(result.error)
}
expectPromptAfterToolRun(result.messages)
}
)
it('places a mid-turn prompt appended after the initial snapshot', async () => {
const filePath = await transcriptFile(lines(PROMPT, TOOL_CALL, TOOL_RESULT))
const received: NativeChatMessage[] = []
const subscription = await subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 's',
filePath,
initialLimit: 40,
onInitialSnapshot: (messages) => received.push(...messages),
onAppend: (messages) => received.push(...messages),
debounceMs: 5,
reconciliationIntervalMs: 20
})
try {
await expect.poll(() => received.length).toBe(3)
await appendFile(filePath, lines(QUEUED, REPLY))
await expect.poll(() => received.length).toBe(5)
expectPromptAfterToolRun(received)
} finally {
subscription.unsubscribe()
}
})
})
@@ -0,0 +1,17 @@
import type { NativeChatMessage } from '../../shared/native-chat-types'
/** Desktop orders terminal transcript rows by time, but the file holds the true order:
* Claude stamps a prompt it took mid-turn with when it was queued, then writes it
* where it read it. So a row never sorts above the rows before it. `latest` is the
* newest time above this row; returns the row and the newest time including it. */
export function inTranscriptFileOrder(
message: NativeChatMessage,
latest: number
): { message: NativeChatMessage; latest: number } {
if (message.timestamp === null || message.timestamp >= latest) {
return { message, latest: Math.max(latest, message.timestamp ?? latest) }
}
// Why: one past, not equal: equal times fall back to id order, which can still put it first.
const timestamp = latest + 1
return { message: { ...message, timestamp }, latest: timestamp }
}
@@ -1,6 +1,7 @@
import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
import { transcriptFallbackId } from './transcript-fallback-id'
import { extendTranscriptBoundary } from './transcript-file-version'
import { inTranscriptFileOrder } from './transcript-file-order'
import {
MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES,
type NativeChatLineDecoder
@@ -16,6 +17,8 @@ export type IncrementalTranscriptState = {
pendingStart: number
pendingBytes: number
droppingOversizedRecord: boolean
/** Newest row time read so far, so an append never sorts above earlier rows. */
latestTimestamp: number
}
export function createIncrementalTranscriptState(): IncrementalTranscriptState {
@@ -25,7 +28,8 @@ export function createIncrementalTranscriptState(): IncrementalTranscriptState {
pendingChunks: [],
pendingStart: 0,
pendingBytes: 0,
droppingOversizedRecord: false
droppingOversizedRecord: false,
latestTimestamp: Number.NEGATIVE_INFINITY
}
}
@@ -36,6 +40,7 @@ export function resetIncrementalTranscriptState(state: IncrementalTranscriptStat
state.pendingStart = 0
state.pendingBytes = 0
state.droppingOversizedRecord = false
state.latestTimestamp = Number.NEGATIVE_INFINITY
}
export async function readIncrementalTranscriptMessages(
@@ -127,7 +132,9 @@ export async function readIncrementalTranscriptMessages(
if (!message) {
return
}
messages.push(message)
const ordered = inTranscriptFileOrder(message, state.latestTimestamp)
state.latestTimestamp = ordered.latest
messages.push(ordered.message)
if (onBatch && messages.length >= APPEND_BATCH_MESSAGE_LIMIT) {
onBatch(messages.splice(0))
}
@@ -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' }
])
})
})
@@ -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<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 {
@@ -2,6 +2,7 @@ import type { Readable } from 'node:stream'
import { StringDecoder } from 'node:string_decoder'
import type { NativeChatMessage } from '../../shared/native-chat-types'
import { transcriptFallbackId } from './transcript-fallback-id'
import { inTranscriptFileOrder } from './transcript-file-order'
type TranscriptDecoder = (line: string, fallbackId: string) => NativeChatMessage | null
@@ -15,6 +16,7 @@ export async function decodeTranscriptStream(
): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> {
const messages: NativeChatMessage[] = []
let consumedBytes = 0
let latest = Number.NEGATIVE_INFINITY
const framer = createTranscriptLineFramer((line, byteLength, terminated) => {
if (terminated || includeTrailingLine) {
decodeLine(line, consumedBytes)
@@ -35,7 +37,9 @@ export async function decodeTranscriptStream(
}
const message = decode(line, transcriptFallbackId(filePath, start + relativeOffset))
if (message) {
messages.push(message)
const ordered = inTranscriptFileOrder(message, latest)
latest = ordered.latest
messages.push(ordered.message)
}
}
}
+10 -1
View File
@@ -14,6 +14,7 @@ import {
decodeOmpTranscriptLine
} from './transcript-line-decoders'
import { transcriptFallbackId } from './transcript-fallback-id'
import { inTranscriptFileOrder } from './transcript-file-order'
import { extendTranscriptBoundary } from './transcript-file-version'
import {
nativeChatTurnLifecycleDecoderForAgent,
@@ -68,6 +69,8 @@ export async function readNativeChatTranscriptTailFile(
consumedTo: number
hasMore: boolean
beforeOffset: number
/** Newest row time up to `consumedTo`, where an incremental read continues. */
latestTimestamp?: number
malformedRecordCount?: number
oversizedRecordCount?: number
}> {
@@ -153,7 +156,12 @@ export async function readNativeChatTranscriptTailFile(
if (cursor === 0 && lineParts.length > 0 && newestFirst.length <= limit) {
decodeLine(0, newestFirst)
}
const chronological = newestFirst.toReversed()
let latest = Number.NEGATIVE_INFINITY
const chronological = newestFirst.toReversed().map((entry) => {
const ordered = inTranscriptFileOrder(entry.message, latest)
latest = ordered.latest
return { message: ordered.message, offset: entry.offset }
})
// Why: slice(-0) returns the whole array, so a non-positive limit must
// window to nothing explicitly rather than leak every buffered record.
const selected = limit > 0 ? chronological.slice(Math.max(0, chronological.length - limit)) : []
@@ -163,6 +171,7 @@ export async function readNativeChatTranscriptTailFile(
consumedTo,
hasMore: limit > 0 && chronological.length > limit,
beforeOffset: selected[0]?.offset ?? end,
...(Number.isFinite(latest) ? { latestTimestamp: latest } : {}),
...(malformedRecordCount > 0 ? { malformedRecordCount } : {}),
...(oversizedRecordCount > 0 ? { oversizedRecordCount } : {})
}
@@ -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(
@@ -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 `!` row is the shell command, not a prompt; any reply Claude writes after it
// carries its own completed marker, and with none a working mark would stick.
function isClaudeBashInputRecord(record: Record<string, unknown>): boolean {
const content = asRecord(record.message)?.content
const texts =
typeof content === 'string'
? [content]
: Array.isArray(content)
? content.map((block) => extractString(asRecord(block)?.text))
: []
return texts.some((text) => text != null && claudeBashInputAsTypedCommand(text) !== null)
}
function lifecycleTimestamp(value: unknown): number | null {
const parsed = timestampMs(value)
return Number.isFinite(parsed) ? parsed : null
@@ -164,6 +164,7 @@ export async function installTranscriptWatcher(
if (replacementSnapshot && onReplace) {
state.offset = replacementSnapshot.consumedTo
state.pendingStart = state.offset
state.latestTimestamp = replacementSnapshot.latestTimestamp ?? Number.NEGATIVE_INFINITY
onReplace(
replacementSnapshot.messages,
replacementSnapshot.hasMore,
@@ -186,6 +187,7 @@ export async function installTranscriptWatcher(
if (initialSnapshot) {
state.offset = initialSnapshot.consumedTo
state.pendingStart = state.offset
state.latestTimestamp = initialSnapshot.latestTimestamp ?? Number.NEGATIVE_INFINITY
onInitialSnapshot(
initialSnapshot.messages,
initialSnapshot.hasMore,
+19
View File
@@ -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
View File
@@ -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 }
}