feat(sidechat): main-conversation Blocks, turn usage tail, reconnect banner
Rendering-layer upgrade for the Side Chat transcript (data already rides the raw events; the mapping just stopped discarding it): - Tool rows with a derivable structure now render the host's ui-primitives atoms instead of a generic double <pre>: bash calls map onto TerminalBlock (command from call args, output + exit pill recovered from the result's trailing exit marker), edit/write map onto DiffBlock (call-time literal replacement, refined by the result meta's contextual hunks), read maps onto ReadBlock (line-window meta, same semantic narrowing the host applies). Every narrowing declines to the generic row on malformed input. - turn/end now emits a turnSummary row: token usage aggregated from assistant/message.usage (output summed across steps, input from the last request) plus the envelope-time wall duration, printed with the main conversation's formatTokens/formatDurationMs cadence. - The client entry injects 'connection' (DSH 0.1.2-alpha.2+) and the thread view shows the host's ConnectionIndicator while the Remote transport is down; the reconnect control calls reconnect() and a disconnected -> connected transition pulls the transcript immediately instead of waiting for the next poll tick. 19 new locale keys propagated across zh/en and all 19 third-language dictionaries; unit coverage in sidechat-transcript.spec and a new sidechat-view render spec (jsdom). Real-host mount smoke: 14/14.
This commit is contained in:
@@ -8,7 +8,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SidebarHistoryEntry, SidebarSessionEvent } from '../src/context-types.ts'
|
||||
import { SIDE_BOUNDARY_PREFIX, SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN } from '../src/sidechat-core.ts'
|
||||
import { toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts'
|
||||
import {
|
||||
formatDurationMs,
|
||||
formatTokens,
|
||||
toolArgsSummary,
|
||||
transcriptRows,
|
||||
type SidechatTranscriptRow,
|
||||
} from '../src/client/sidechat-transcript.ts'
|
||||
|
||||
/** One history entry (event + optional view). */
|
||||
function entry(event: SidebarSessionEvent): SidebarHistoryEntry {
|
||||
@@ -164,6 +170,188 @@ describe('transcriptRows', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('turnSummary rows', () => {
|
||||
it('emits one tail row per turn: envelope-time duration + last-input/summed-output usage', () => {
|
||||
const entries = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('turn/start', 1, { turn: 1 })),
|
||||
entry(ev('assistant/message', 2, { turn: 1, step: 1, message: { content: textBlocks('a') }, usage: { inputTokens: 100, outputTokens: 40 } })),
|
||||
entry(ev('assistant/message', 3, { turn: 1, step: 2, message: { content: textBlocks('b') }, usage: { inputTokens: 160, outputTokens: 30 } })),
|
||||
entry(ev('turn/end', 4, { turn: 1, reason: 'completed' })),
|
||||
]
|
||||
const rows = transcriptRows(entries)
|
||||
expect(rows.at(-1)).toEqual({ kind: 'turnSummary', seq: 4, inputTokens: 160, outputTokens: 70, durationMs: 3000 })
|
||||
})
|
||||
|
||||
it('keeps a duration-only tail without usage and a usage-only tail without a turn/start', () => {
|
||||
const cancelled = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('turn/start', 2, { turn: 1 })),
|
||||
entry(ev('turn/end', 7, { turn: 1, reason: 'cancelled' })),
|
||||
]
|
||||
expect(transcriptRows(cancelled).at(-1)).toEqual({ kind: 'turnSummary', seq: 7, durationMs: 5000 })
|
||||
|
||||
const seedCutStart = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('assistant/message', 2, { turn: 1, step: 1, message: { content: textBlocks('a') }, usage: { inputTokens: 10, outputTokens: 5 } })),
|
||||
entry(ev('turn/end', 3, { turn: 1, reason: 'completed' })),
|
||||
]
|
||||
expect(transcriptRows(seedCutStart).at(-1)).toEqual({ kind: 'turnSummary', seq: 3, inputTokens: 10, outputTokens: 5 })
|
||||
})
|
||||
|
||||
it('skips the tail entirely when nothing is computable', () => {
|
||||
const entries = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('turn/end', 1, { turn: 1, reason: 'completed' })),
|
||||
]
|
||||
expect(transcriptRows(entries)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool cards', () => {
|
||||
const callRow = (seq: number, name: string, args: string): SidebarHistoryEntry =>
|
||||
entry(ev('tool/call', seq, { turn: 1, step: 1, callId: `c${seq}`, name, arguments: args }))
|
||||
|
||||
const resultRow = (seq: number, callId: string, text: string, extra: Record<string, unknown> = {}): SidebarHistoryEntry =>
|
||||
entry(ev('tool/result', seq, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
source: { kind: 'tool', callId },
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content: [{ type: 'text', text }] }],
|
||||
},
|
||||
...extra,
|
||||
}))
|
||||
|
||||
const toolRow = (entries: readonly SidebarHistoryEntry[]) => {
|
||||
const rows = transcriptRows(entries)
|
||||
expect(rows).toHaveLength(1)
|
||||
return rows[0] as Extract<SidechatTranscriptRow, { kind: 'tool' }>
|
||||
}
|
||||
|
||||
it('derives the call-time terminal card for bash and refines output/exit from the result marker', () => {
|
||||
const row = toolRow([
|
||||
entry(ev('session/end-seed', 0)),
|
||||
callRow(1, 'bash', '{"command":"echo hi","workdir":"/w"}'),
|
||||
resultRow(2, 'c1', 'hi\n[exit code: 3]'),
|
||||
])
|
||||
expect(row.card).toEqual({ type: 'terminal', command: 'echo hi', cwd: '/w', output: 'hi', exitCode: 3 })
|
||||
})
|
||||
|
||||
it('recovers the signal pill and defaults a marker-less settle to exit code 0', () => {
|
||||
const signaled = toolRow([
|
||||
entry(ev('session/end-seed', 0)),
|
||||
callRow(1, 'bash', '{"command":"x"}'),
|
||||
resultRow(2, 'c1', 'boom\n[killed by signal: SIGKILL]'),
|
||||
])
|
||||
expect(signaled.card).toEqual({ type: 'terminal', command: 'x', output: 'boom', signal: 'SIGKILL' })
|
||||
|
||||
const clean = toolRow([
|
||||
entry(ev('session/end-seed', 0)),
|
||||
callRow(1, 'bash', '{"command":"x"}'),
|
||||
resultRow(2, 'c1', 'ok'),
|
||||
])
|
||||
expect(clean.card).toEqual({ type: 'terminal', command: 'x', output: 'ok', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('skips the terminal card for background bash and drops it on failed results', () => {
|
||||
const background = toolRow([
|
||||
entry(ev('session/end-seed', 0)),
|
||||
callRow(1, 'bash', '{"command":"x","run_in_background":true}'),
|
||||
])
|
||||
expect(background.card).toBeUndefined()
|
||||
|
||||
const failed = toolRow([
|
||||
entry(ev('session/end-seed', 0)),
|
||||
callRow(1, 'bash', '{"command":"x"}'),
|
||||
resultRow(2, 'c1', 'denied', { error: { name: 'EACCES', code: 'EACCES' } }),
|
||||
])
|
||||
expect(failed.card).toBeUndefined()
|
||||
})
|
||||
|
||||
it('derives literal diff cards for edit/write calls and refines from the result meta hunks', () => {
|
||||
const edit = toolRow([
|
||||
entry(ev('session/end-seed', 0)),
|
||||
callRow(1, 'edit', '{"file_path":"/a.ts","old_string":"x","new_string":"y"}'),
|
||||
])
|
||||
expect(edit.card).toEqual({ type: 'diff', diffs: [{ path: '/a.ts', oldText: 'x', newText: 'y' }] })
|
||||
|
||||
const write = toolRow([
|
||||
entry(ev('session/end-seed', 0)),
|
||||
callRow(1, 'write', '{"file_path":"/new.ts","content":"body"}'),
|
||||
])
|
||||
expect(write.card).toEqual({ type: 'diff', diffs: [{ path: '/new.ts', oldText: null, newText: 'body' }] })
|
||||
|
||||
const refined = toolRow([
|
||||
entry(ev('session/end-seed', 0)),
|
||||
callRow(1, 'edit', '{"file_path":"/a.ts","old_string":"x","new_string":"y"}'),
|
||||
resultRow(2, 'c1', 'ok', { meta: { diffs: [{ path: '/a.ts', oldText: 'x\nctx', newText: 'y\nctx' }] } }),
|
||||
])
|
||||
expect(refined.card).toEqual({ type: 'diff', diffs: [{ path: '/a.ts', oldText: 'x\nctx', newText: 'y\nctx' }] })
|
||||
|
||||
const malformed = toolRow([
|
||||
entry(ev('session/end-seed', 0)),
|
||||
callRow(1, 'edit', '{"file_path":"/a.ts","old_string":"x","new_string":"y"}'),
|
||||
resultRow(2, 'c1', 'ok', { meta: { diffs: [{ path: 7, oldText: 'x', newText: 'y' }] } }),
|
||||
])
|
||||
// Malformed meta declines to the call-time literal card, never throws.
|
||||
expect(malformed.card).toEqual({ type: 'diff', diffs: [{ path: '/a.ts', oldText: 'x', newText: 'y' }] })
|
||||
})
|
||||
|
||||
it('builds the read card from the result line-window meta and declines malformed windows', () => {
|
||||
const read = toolRow([
|
||||
entry(ev('session/end-seed', 0)),
|
||||
callRow(1, 'read', '{"path":"/a.ts","offset":2,"limit":2}'),
|
||||
resultRow(2, 'c1', 'window text', {
|
||||
meta: { path: '/a.ts', offset: 2, lines: [{ number: 2, text: 'b' }, { number: 3, text: 'c' }], totalLines: 5, lang: 'ts' },
|
||||
}),
|
||||
])
|
||||
expect(read.card).toEqual({
|
||||
type: 'read',
|
||||
label: '/a.ts',
|
||||
lines: [{ number: 2, text: 'b' }, { number: 3, text: 'c' }],
|
||||
totalLines: 5,
|
||||
lang: 'ts',
|
||||
})
|
||||
|
||||
const nonMonotonic = toolRow([
|
||||
entry(ev('session/end-seed', 0)),
|
||||
callRow(1, 'read', '{"path":"/a.ts"}'),
|
||||
resultRow(2, 'c1', 'window text', {
|
||||
meta: { path: '/a.ts', offset: 1, lines: [{ number: 3, text: 'c' }, { number: 2, text: 'b' }], totalLines: 5 },
|
||||
}),
|
||||
])
|
||||
expect(nonMonotonic.card).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves unknown tools on the generic row', () => {
|
||||
const row = toolRow([
|
||||
entry(ev('session/end-seed', 0)),
|
||||
callRow(1, 'todo_write', '{"todos":[]}'),
|
||||
])
|
||||
expect(row.card).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('usage formatting', () => {
|
||||
it('formatTokens mirrors the main conversation K/M compaction', () => {
|
||||
expect(formatTokens(517)).toBe('517')
|
||||
expect(formatTokens(999)).toBe('999')
|
||||
expect(formatTokens(1_234)).toBe('1.2K')
|
||||
expect(formatTokens(12_234)).toBe('12.2K')
|
||||
expect(formatTokens(122_345)).toBe('122K')
|
||||
expect(formatTokens(1_234_567)).toBe('1.2M')
|
||||
})
|
||||
|
||||
it('formatDurationMs mirrors the main conversation s / m+s cadence', () => {
|
||||
expect(formatDurationMs(0)).toBe('0s')
|
||||
expect(formatDurationMs(500)).toBe('0.5s')
|
||||
expect(formatDurationMs(45_200)).toBe('45.2s')
|
||||
expect(formatDurationMs(61_000)).toBe('1m1s')
|
||||
expect(formatDurationMs(162_000)).toBe('2m42s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toolArgsSummary', () => {
|
||||
it('picks the most identifying string field', () => {
|
||||
expect(toolArgsSummary('{"command":"ls -la","timeout":1000}')).toBe('ls -la')
|
||||
|
||||
Reference in New Issue
Block a user