mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
co-authored by
Paweł Kotowski
parent
be29644ec9
commit
ef950401d3
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user