Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
88c0e5c43This file is pinned to the selected release. Download the original to open it in your design tool.
import { describe, expect, it } from 'vitest'
import { foldTranscript, lineToEvents, newTurnState, type LiveEvent } from './normalize.js'
const userPrompt = (text: string): string =>
JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } })
const assistantText = (text: string, stopReason?: string): string =>
JSON.stringify({
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text }], stop_reason: stopReason ?? null },
})
/**
* A first attach folds the whole transcript through `lineToEvents` and drops the events, so old turns
* do not replay live. That is only safe while the history is complete. When a prompt lands during the
* attach, the history ends mid-turn and the live turn's `turn_started` is in the part being dropped —
* the commander mirror then never opens the turn and its close yields no recap. `cli.ts` recovers by
* replaying the last `turn_started` when the folded state says a turn is still open, which needs both
* halves of that contract to hold.
*/
describe('folding a transcript that ends mid-turn', () => {
it('leaves the turn open and the last turn_started recoverable', () => {
const state = newTurnState()
const events: LiveEvent[] = []
for (const line of [
userPrompt('first question'),
assistantText('first answer', 'end_turn'),
userPrompt('second question'), // still running when the attach folded the file
assistantText('partial answer'), // no terminal stop reason yet
]) {
events.push(...lineToEvents(line, state))
}
expect(state.turnOpen).toBe(true)
const opened = events.findLast((event) => event.type === 'turn_started')
expect(opened).toEqual({ type: 'turn_started', payload: { userMessage: 'second question' } })
})
it('leaves no open turn when the history is complete', () => {
const state = newTurnState()
const events: LiveEvent[] = []
for (const line of [userPrompt('a question'), assistantText('an answer', 'end_turn')]) {
events.push(...lineToEvents(line, state))
}
// Nothing to recover: replaying here would resurrect a finished turn as a live one.
expect(state.turnOpen).toBe(false)
expect(events.map((event) => event.type)).toEqual(['turn_started', 'text_delta', 'turn_ended'])
})
it('keeps the turn open while a tool call is unresolved', () => {
// A turn parked in a tool has no terminal stop reason either, and it is just as live.
const state = newTurnState()
for (const line of [
userPrompt('run something'),
JSON.stringify({
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'ls' } }],
stop_reason: 'tool_use',
},
}),
]) {
lineToEvents(line, state)
}
expect(state.turnOpen).toBe(true)
expect(state.pendingTools.size).toBe(1)
})
})
/**
* Cancelling a turn (device Stop, web C-c, or ESC in the terminal) leaves a synthetic user line in the
* transcript. It carries no flag, so only its text tells it apart from a typed prompt — and reading it as
* one opened a fresh turn the moment a turn was killed. The device then showed "Working" and counted
* seconds forever, because no assistant would ever answer that turn and no recap would ever close it.
*/
describe('an interrupted turn', () => {
const INTERRUPTS = ['[Request interrupted by user]', '[Request interrupted by user for tool use]']
for (const marker of INTERRUPTS) {
it(`closes the turn instead of opening one: ${marker}`, () => {
const state = newTurnState()
const events: LiveEvent[] = []
for (const line of [userPrompt('do a long thing'), assistantText('starting'), userPrompt(marker)]) {
events.push(...lineToEvents(line, state))
}
expect(state.turnOpen).toBe(false)
expect(events.filter((event) => event.type === 'turn_started')).toHaveLength(1)
// `aborted` is what tells the mirror to clear the tile WITHOUT a recap or a done beep.
expect(events.at(-1)).toEqual({ type: 'turn_ended', payload: { aborted: true } })
})
}
it('emits nothing when no turn is open', () => {
// Folding history on attach replays interrupts from turns that ended long ago; none of them may
// close a turn that a later line opened, nor synthesize an event out of nothing.
const state = newTurnState()
expect(lineToEvents(userPrompt(INTERRUPTS[0]), state)).toEqual([])
expect(state.turnOpen).toBe(false)
})
it('still treats a real prompt that only mentions an interrupt as a prompt', () => {
const state = newTurnState()
const events = lineToEvents(userPrompt('why do I keep seeing [Request interrupted by user]?'), state)
expect(events[0]).toMatchObject({ type: 'turn_started' })
expect(state.turnOpen).toBe(true)
})
})
// Regression: a `/loop` fires every N minutes and each iteration is written as an isMeta user line.
// Blanket-suppressing isMeta made every iteration after the first invisible on the device — the loop ran
// fine in the terminal, produced no turn_started, and so produced no recap. Shapes taken verbatim from a
// real ~/.claude JSONL.
describe('a /loop iteration', () => {
const line = (o: Record<string, unknown>) => JSON.stringify(o)
it('opens a turn: it is a real prompt the system submitted on the user behalf', () => {
const st = newTurnState()
const ev = lineToEvents(line({
type: 'user', isMeta: true, promptSource: 'system', queuePriority: 'later',
message: { role: 'user', content: 'Báo giá Bitcoin' },
}), st)
expect(ev.map((e) => e.type)).toContain('turn_started')
expect(st.turnOpen).toBe(true)
})
it('still suppresses real bookkeeping meta — a slash command expansion has no promptSource', () => {
const st = newTurnState()
const ev = lineToEvents(line({
type: 'user', isMeta: true, userType: 'external', entrypoint: 'cli',
message: { role: 'user', content: [{ type: 'text', text: '# /loop — schedule a recurring prompt' }] },
}), st)
expect(ev).toEqual([])
expect(st.turnOpen).toBe(false)
})
it('still suppresses the compact summary', () => {
const st = newTurnState()
expect(lineToEvents(line({
type: 'user', isCompactSummary: true, promptSource: 'system',
message: { role: 'user', content: 'This session is being continued…' },
}), st)).toEqual([])
expect(st.turnOpen).toBe(false)
})
})
/**
* Folding a transcript that already exists on disk.
*
* The default is silent: attaching to a session means catching up on turns the user has already seen, so
* the events are swallowed and only a half-open turn is replayed. That is wrong for a transcript BORN
* AFTER its agent — the file is then the live first turn, and swallowing it loses the question, the tool
* cards and the answer with no trace at all, because no `turn_started` means no `turn_ended` and no recap
* either. Measured on a real daemon: a codex attach logging `lines=8` over a completed first turn
* produced not one line of output.
*/
describe('foldTranscript', () => {
it('withholds the events as history by default, and reports the open turn', () => {
const state = newTurnState()
const out = foldTranscript((l) => lineToEvents(l, state), [userPrompt('hi')], () => state.turnOpen, { live: false })
expect(out.live).toEqual([])
expect(out.history.map((e) => e.type)).toContain('turn_started')
expect(out.turnOpen).toBe(true)
})
it('hands the same events over for emission when the file is the live first turn', () => {
const state = newTurnState()
const out = foldTranscript((l) => lineToEvents(l, state), [userPrompt('hi')], () => state.turnOpen, { live: true })
expect(out.history).toEqual([])
expect(out.live.map((e) => e.type)).toContain('turn_started')
})
it('forces turnOpen false when live, so the caller does not replay turn_started twice', () => {
// The caller's mid-turn rescue re-emits the last `turn_started` whenever turnOpen is true. Combined
// with a live fold that already carries that event, the turn would open, close and open again —
// exactly the 44ms double-start seen on a real claude pane.
const state = newTurnState()
const out = foldTranscript((l) => lineToEvents(l, state), [userPrompt('hi')], () => state.turnOpen, { live: true })
expect(state.turnOpen).toBe(true)
expect(out.turnOpen).toBe(false)
})
it('leaves the normalizer hydrated either way — the fold is what warms it up', () => {
const history = newTurnState()
const live = newTurnState()
const lines = [userPrompt('hi')]
foldTranscript((l) => lineToEvents(l, history), lines, () => history.turnOpen, { live: false })
foldTranscript((l) => lineToEvents(l, live), lines, () => live.turnOpen, { live: true })
expect(history.turnOpen).toBe(true)
expect(live.turnOpen).toBe(true)
})
it('is a no-op on an empty transcript', () => {
const state = newTurnState()
const out = foldTranscript((l) => lineToEvents(l, state), [], () => state.turnOpen, { live: true })
expect(out).toEqual({ history: [], live: [], turnOpen: false })
})
})
/**
* On a Local model the web tools are served by the `harness` MCP server, so a Claude Code transcript
* records `mcp__harness__web_search` where a Subscription model records `WebSearch`. The user sees an
* agent searching the web, not which road it took — so the cards are the native ones.
*/
describe('harness web tools render as the native cards', () => {
const assistantToolUse = (id: string, name: string, input: Record<string, unknown>): string =>
JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } })
const toolResult = (toolUseId: string, text: string): string =>
JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId, content: text }] } })
it('maps mcp__harness__web_search to WebSearch, keeping its query', () => {
const state = newTurnState()
const events = [
userPrompt('what is the BTC price'),
assistantToolUse('t1', 'mcp__harness__web_search', { query: 'BTC price', num_results: 5 }),
toolResult('t1', '{"results":[]}'),
].flatMap((line) => lineToEvents(line, state))
expect(events.find((e) => e.type === 'tool_start')).toEqual({
type: 'tool_start',
payload: { id: 't1', tool: 'WebSearch', input: { query: 'BTC price', num_results: 5 } },
})
expect(events.find((e) => e.type === 'tool_end')).toMatchObject({ payload: { id: 't1', tool: 'WebSearch', summary: 'Fetched' } })
})
it('maps mcp__harness__web_read to WebFetch, giving the card the `url` it reads', () => {
const state = newTurnState()
const events = [
userPrompt('read these'),
assistantToolUse('t2', 'mcp__harness__web_read', { urls: ['https://a.example/', 'https://b.example/'], max_chars: 6000 }),
toolResult('t2', '{"results":[]}'),
].flatMap((line) => lineToEvents(line, state))
expect(events.find((e) => e.type === 'tool_start')).toEqual({
type: 'tool_start',
payload: {
id: 't2',
tool: 'WebFetch',
// The original key stays beside the one the card reads, so an expanded card shows the real input.
input: { urls: ['https://a.example/', 'https://b.example/'], max_chars: 6000, url: 'https://a.example/, https://b.example/' },
},
})
expect(events.find((e) => e.type === 'tool_end')).toMatchObject({ payload: { id: 't2', tool: 'WebFetch' } })
})
it('leaves every other MCP tool alone', () => {
const state = newTurnState()
const events = [
userPrompt('hi'),
assistantToolUse('t3', 'mcp__github__get_issue', { number: 1 }),
].flatMap((line) => lineToEvents(line, state))
expect(events.find((e) => e.type === 'tool_start')).toMatchObject({ payload: { tool: 'mcp__github__get_issue', input: { number: 1 } } })
})
})