feat(sidechat): 侧边对话——每对话一 Tab(Codex 对齐)+ 完整上下文继承 + 保存为新会话 (#286)
三轮实测驱动迭代:种子信封透传(surfaceOp 校验)、每线程一 Tab 交互重做完、四连修(fork 丢 this / 自绘 history+save 图标 / collectOwnEvents 大页回源修重开丢工具行 / descriptor 入种子修目录 corrupt 行 + 前缀过滤保拓扑零噪音)。739 单测 + 挂载冒烟 12/12 全绿。
This commit is contained in:
+32
-3
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Built-in registration tests: the plugin registers 6 tabs and 6 file
|
||||
* Built-in registration tests: the plugin registers 7 tabs and 6 file
|
||||
* viewers through the same service external plugins use (dogfooding);
|
||||
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
|
||||
* and the html sandbox settings pin the registry's behavior. (Office
|
||||
@@ -16,6 +16,7 @@ import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { allLeaves } from '../src/client/state.ts'
|
||||
import { registerBuiltins } from '../src/client/builtins/index.ts'
|
||||
import type { BuiltinTabOptions } from '../src/client/builtins/tabs.tsx'
|
||||
import { parkSidechatReopen } from '../src/client/SideChatView.tsx'
|
||||
import { t } from '../src/client/locales.ts'
|
||||
|
||||
function setup(options: BuiltinTabOptions = {}): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
|
||||
@@ -26,10 +27,10 @@ function setup(options: BuiltinTabOptions = {}): { service: ReturnType<typeof cr
|
||||
}
|
||||
|
||||
describe('built-in tab registrations', () => {
|
||||
it('registers the 6 built-in tabs', () => {
|
||||
it('registers the 7 built-in tabs', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getTabs().map(t => t.id).sort()).toEqual(
|
||||
['browser', 'diff', 'editor', 'git', 'subagent', 'terminal'],
|
||||
['browser', 'diff', 'editor', 'git', 'sidechat', 'subagent', 'terminal'],
|
||||
)
|
||||
})
|
||||
|
||||
@@ -48,6 +49,34 @@ describe('built-in tab registrations', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('the side chat tab sits between tasks and terminal in the + menu', () => {
|
||||
const { service } = setup()
|
||||
const sidechat = service.getTab('sidechat')
|
||||
expect(sidechat?.order).toBe(35)
|
||||
expect(sidechat?.hidden).not.toBe(true)
|
||||
})
|
||||
|
||||
it('side chat mints one tab per thread (Codex-style multi-instance)', () => {
|
||||
const { service } = setup()
|
||||
const sidechat = service.getTab('sidechat')
|
||||
expect(sidechat?.single).not.toBe(true)
|
||||
// A plain open mints a fresh autoCreate tab (the view creates the
|
||||
// thread on mount); two opens never collide. (createTab ignores the
|
||||
// state argument for sidechat — the cast stands in for it.)
|
||||
const first = sidechat?.createTab?.(undefined as never)
|
||||
const second = sidechat?.createTab?.(undefined as never)
|
||||
expect(first?.tab.meta).toEqual({ autoCreate: true })
|
||||
expect(first?.tab.id).not.toBe(second?.tab.id)
|
||||
// A parked reopen target mints the deterministic reattach tab, and
|
||||
// dedupeKey focuses an already-open thread instead of duplicating it.
|
||||
parkSidechatReopen('session-t1')
|
||||
const reopen = sidechat?.createTab?.(undefined as never)
|
||||
expect(reopen?.tab.id).toBe('sidechat:session-t1')
|
||||
expect(reopen?.tab.meta).toEqual({ threadId: 'session-t1' })
|
||||
expect(sidechat?.dedupeKey?.(reopen!.tab)).toBe('session-t1')
|
||||
expect(sidechat?.dedupeKey?.(first!.tab)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('the subagent tab declares its auto-open related settings', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
|
||||
|
||||
+74
-1
@@ -56,9 +56,11 @@ const SEEDED_MD_FILE = 'diagram.md'
|
||||
const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/]
|
||||
|
||||
/** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */
|
||||
const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser']
|
||||
const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Side Chat', 'Terminal', 'Browser']
|
||||
|
||||
let api: APIRequestContext
|
||||
/** The seeded session id (captured by seedSession; the Side Chat smoke's parent). */
|
||||
let seededSessionId: string
|
||||
|
||||
/** Seed one workspace + one session (plus files for the editor/mermaid-chunk
|
||||
* probes) through the host's unary RPC surface. */
|
||||
@@ -99,6 +101,11 @@ async function seedSession(): Promise<void> {
|
||||
data: { type: 'client-request', rpcId: 'e2e-session', method: 'session.create', payload: { workspaceId } },
|
||||
})
|
||||
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
|
||||
const sessionBody = (await session.json()) as {
|
||||
result: { ok: true; value: { sessionId: string } } | { ok: false; error: unknown }
|
||||
}
|
||||
expect(sessionBody.result.ok).toBe(true)
|
||||
seededSessionId = (sessionBody.result as { value: { sessionId: string } }).value.sessionId
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
@@ -228,6 +235,72 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
await assertNoCrash()
|
||||
}
|
||||
|
||||
// Side Chat host-route smoke against the REAL host: create a thread child
|
||||
// under the seeded session (custom-seed creation through AgentRegistry),
|
||||
// deliver a follow-up, cancel, and release it. The turn itself cannot run
|
||||
// (keyless boot has no model route), but admission + creation + the wire
|
||||
// envelope must all succeed — this is the deepest functional proof the
|
||||
// mount lane can make without a provider.
|
||||
const start = await api.post(`${BASE_URL}/sidebar/api/sidechat.start`, {
|
||||
data: { sessionId: seededSessionId, question: 'mount lane smoke' },
|
||||
})
|
||||
expect(start.ok(), `sidechat.start: ${start.status()} ${await start.text()}`).toBe(true)
|
||||
const startBody = (await start.json()) as {
|
||||
ok: boolean
|
||||
value?: { childId?: string }
|
||||
error?: { code?: string; message?: string }
|
||||
}
|
||||
expect(startBody.ok, `sidechat.start envelope: ${JSON.stringify(startBody)}`).toBe(true)
|
||||
const childId = startBody.value?.childId
|
||||
expect(childId, 'sidechat.start must return a child session id').toMatch(/^session-/)
|
||||
// The child must be a REAL session in the host's store (provider-free
|
||||
// proof of the custom-seed creation; the boundary message itself only
|
||||
// becomes durable when a turn claims it, which needs a model route).
|
||||
const list = await api.post(`${BASE_URL}/api/session.list`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-sidechat-list', method: 'session.list', payload: {} },
|
||||
})
|
||||
expect(list.ok(), `session.list: ${list.status()} ${await list.text()}`).toBe(true)
|
||||
const listBody = (await list.json()) as {
|
||||
result: { ok: true; value: { items: Array<{ sessionId: string }> } } | { ok: false; error: unknown }
|
||||
}
|
||||
expect(listBody.result.ok, `session.list envelope: ${JSON.stringify(listBody)}`).toBe(true)
|
||||
const listedItems = (listBody.result as { value: { items: Array<{ sessionId: string }> } }).value.items
|
||||
expect(
|
||||
listedItems.some(item => item.sessionId === childId),
|
||||
'the thread child must appear in the host session list',
|
||||
).toBe(true)
|
||||
for (const method of ['sidechat.prompt', 'sidechat.cancel', 'sidechat.dispose']) {
|
||||
const response = await api.post(`${BASE_URL}/sidebar/api/${method}`, {
|
||||
data: method === 'sidechat.prompt' ? { childId, text: 'follow-up' } : { childId },
|
||||
})
|
||||
expect(response.ok(), `${method}: ${response.status()} ${await response.text()}`).toBe(true)
|
||||
}
|
||||
|
||||
// The Codex-style immediate-create flow: a blank question creates an
|
||||
// EMPTY thread (no prompt admitted), sidechat.info reports the live
|
||||
// agent, and the first prompt delivers the boundary host-side.
|
||||
const empty = await api.post(`${BASE_URL}/sidebar/api/sidechat.start`, {
|
||||
data: { sessionId: seededSessionId, question: '' },
|
||||
})
|
||||
expect(empty.ok(), `sidechat.start (empty): ${empty.status()} ${await empty.text()}`).toBe(true)
|
||||
const emptyBody = (await empty.json()) as { ok: boolean; value?: { childId?: string } }
|
||||
const emptyChildId = emptyBody.value?.childId
|
||||
expect(emptyChildId, 'immediate create must return a child session id').toMatch(/^session-/)
|
||||
const info = await api.post(`${BASE_URL}/sidebar/api/sidechat.info`, {
|
||||
data: { childId: emptyChildId },
|
||||
})
|
||||
expect(info.ok(), `sidechat.info: ${info.status()} ${await info.text()}`).toBe(true)
|
||||
const infoBody = (await info.json()) as { ok: boolean; value?: { live?: boolean; preset?: string } }
|
||||
expect(infoBody.ok, `sidechat.info envelope: ${JSON.stringify(infoBody)}`).toBe(true)
|
||||
expect(infoBody.value?.live, 'the fresh thread must have a live agent').toBe(true)
|
||||
for (const method of ['sidechat.prompt', 'sidechat.dispose']) {
|
||||
const response = await api.post(`${BASE_URL}/sidebar/api/${method}`, {
|
||||
data: method === 'sidechat.prompt' ? { childId: emptyChildId, text: 'first message' } : { childId: emptyChildId },
|
||||
})
|
||||
expect(response.ok(), `${method} (immediate thread): ${response.status()} ${await response.text()}`).toBe(true)
|
||||
}
|
||||
await assertNoCrash()
|
||||
|
||||
// The editor chunk (client-editor.js) only loads when a files-window tab
|
||||
// renders. Exercise the file-open path explicitly through the Files window's
|
||||
// own tree: the seeded home tab ("Files") is already open with its tree
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Unit tests for the pure Side Chat core (src/sidechat-core.ts): the seed
|
||||
* construction (full-log inheritance with honest synthetic closing of an
|
||||
* in-progress parent turn), the structured in-progress snapshot (with the
|
||||
* dangling-tool-call fallback), thread-row derivation, save eligibility, and
|
||||
* the preset resolution.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SidebarHistoryEntry, SidebarSessionEvent, SidebarSessionSummary } from '../src/context-types.ts'
|
||||
import {
|
||||
boundaryDelivered,
|
||||
buildOpenTurnSnapshot,
|
||||
buildSidechatInheritance,
|
||||
hasDanglingToolCall,
|
||||
resolvePresetId,
|
||||
sideLabel,
|
||||
sideThreadRows,
|
||||
threadHasCompletedTurn,
|
||||
threadTrailingPending,
|
||||
SIDE_BOUNDARY_PROMPT,
|
||||
SIDE_LABEL_PREFIX,
|
||||
type SeedEvent,
|
||||
type SidechatLogEvent,
|
||||
} from '../src/sidechat-core.ts'
|
||||
|
||||
/** One log event fixture (structural, seq === index like the live contract).
|
||||
* Surface-eligible events carry their required `surfaceOp: 'append'`
|
||||
* marker, exactly like the live pipeline appends them. */
|
||||
function ev(type: string, seq: number, data: Record<string, unknown> = {}): SidebarSessionEvent {
|
||||
const event: SidebarSessionEvent = { type, seq, time: seq * 1000, data }
|
||||
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
|
||||
return { ...event, surfaceOp: 'append' } as SidebarSessionEvent
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
/** A completed user→assistant turn block (turn T, step 1, optional tools). */
|
||||
function completedTurn(seq: number, turn: number, over: {
|
||||
text?: string
|
||||
tools?: Array<{ callId: string; name?: string; args?: string; result?: string }>
|
||||
} = {}): SidebarSessionEvent[] {
|
||||
const events: SidebarSessionEvent[] = [
|
||||
ev('user/message', seq, { content: [{ type: 'text', text: `q${turn}` }], source: { kind: 'user' } }),
|
||||
ev('turn/start', seq + 1, { turn }),
|
||||
ev('step/start', seq + 2, { turn, step: 1 }),
|
||||
]
|
||||
let next = seq + 3
|
||||
for (const tool of over.tools ?? []) {
|
||||
events.push(ev('tool/call', next++, { turn, step: 1, callId: tool.callId, name: tool.name ?? 'bash', arguments: tool.args ?? '{}' }))
|
||||
events.push(ev('tool/result', next++, {
|
||||
turn,
|
||||
step: 1,
|
||||
message: {
|
||||
source: { kind: 'tool', callId: tool.callId },
|
||||
content: [{ type: 'tool-result', toolCallId: tool.callId, isError: false, content: [{ type: 'text', text: tool.result ?? 'ok' }] }],
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (over.text !== undefined) {
|
||||
events.push(ev('assistant/message', next++, {
|
||||
turn,
|
||||
step: 1,
|
||||
message: { content: [{ type: 'text', text: over.text }] },
|
||||
}))
|
||||
}
|
||||
events.push(ev('step/end', next++, { turn, step: 1 }))
|
||||
events.push(ev('turn/end', next, { turn, reason: { kind: 'completed' } }))
|
||||
return events
|
||||
}
|
||||
|
||||
/** Assert the seed is contiguous from seq 0. */
|
||||
function expectContiguous(seed: readonly SeedEvent[]): void {
|
||||
seed.forEach((event, index) => expect(event.seq).toBe(index))
|
||||
}
|
||||
|
||||
describe('buildSidechatInheritance', () => {
|
||||
it('returns an empty seed for an empty log', () => {
|
||||
const { seed, snapshot } = buildSidechatInheritance([])
|
||||
expect(seed).toEqual([])
|
||||
expect(snapshot).toBeNull()
|
||||
})
|
||||
|
||||
it('copies a log that ends outside any turn verbatim (pending user message)', () => {
|
||||
const events = [
|
||||
...completedTurn(0, 1, { text: 'a' }),
|
||||
ev('user/message', 6, { content: [{ type: 'text', text: 'pending?' }], source: { kind: 'user' } }),
|
||||
]
|
||||
const { seed, snapshot } = buildSidechatInheritance(events)
|
||||
expectContiguous(seed)
|
||||
expect(seed).toHaveLength(events.length)
|
||||
expect(seed.at(-1)?.type).toBe('user/message')
|
||||
expect(seed.some(event => event.type === 'turn/end')).toBe(true)
|
||||
expect(snapshot).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves the surface envelope of message events in the seed', () => {
|
||||
// Regression: the seed validator rejects surface-eligible events
|
||||
// (user/message, assistant/message, tool/result) whose `surfaceOp`
|
||||
// marker was stripped — the copy must keep the FULL event envelope.
|
||||
const events = [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{}' }),
|
||||
ev('tool/result', 4, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
source: { kind: 'tool', callId: 'c1' },
|
||||
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }] }],
|
||||
},
|
||||
}),
|
||||
ev('assistant/message', 5, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'a' }] } }),
|
||||
ev('step/end', 6, { turn: 1, step: 1 }),
|
||||
ev('turn/end', 7, { turn: 1, reason: { kind: 'completed' } }),
|
||||
]
|
||||
const { seed } = buildSidechatInheritance(events)
|
||||
expect(seed.filter(event => event.type === 'user/message').every(event => event.surfaceOp === 'append')).toBe(true)
|
||||
expect(seed.filter(event => event.type === 'assistant/message').every(event => event.surfaceOp === 'append')).toBe(true)
|
||||
expect(seed.filter(event => event.type === 'tool/result').every(event => event.surfaceOp === 'append')).toBe(true)
|
||||
// Non-surface events never gain a marker.
|
||||
expect(seed.filter(event => event.type === 'turn/end').every(event => event.surfaceOp === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('copies a log with no turns at all', () => {
|
||||
const events = [ev('user/message', 0, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })]
|
||||
const { seed } = buildSidechatInheritance(events)
|
||||
expectContiguous(seed)
|
||||
expect(seed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('closes an open streaming turn with synthetic step/end + interrupted turn/end', () => {
|
||||
const events = [
|
||||
...completedTurn(0, 1, { text: 'done' }),
|
||||
ev('user/message', 6, { content: [{ type: 'text', text: 'next?' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 7, { turn: 2 }),
|
||||
ev('step/start', 8, { turn: 2, step: 1 }),
|
||||
ev('assistant/chunk', 9, { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'partial answer' } }),
|
||||
]
|
||||
const { seed, snapshot } = buildSidechatInheritance(events)
|
||||
expectContiguous(seed)
|
||||
expect(seed).toHaveLength(events.length + 2)
|
||||
const stepEnd = seed.at(-2)!
|
||||
const turnEnd = seed.at(-1)!
|
||||
expect(stepEnd).toMatchObject({ type: 'step/end', data: { turn: 2, step: 1 } })
|
||||
expect(turnEnd).toMatchObject({ type: 'turn/end', data: { turn: 2, reason: { kind: 'interrupted' } } })
|
||||
// The pending user message and the partial chunk survive as REAL events.
|
||||
expect(seed.some(event => event.type === 'user/message'
|
||||
&& (event.data.content as Array<{ text: string }>)[0]?.text === 'next?')).toBe(true)
|
||||
expect(seed.some(event => event.type === 'assistant/chunk')).toBe(true)
|
||||
expect(snapshot).toBeNull()
|
||||
})
|
||||
|
||||
it('closes an open turn whose tool calls already have results', () => {
|
||||
const events = [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'grep', arguments: '{"q":"x"}' }),
|
||||
ev('tool/result', 4, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
source: { kind: 'tool', callId: 'c1' },
|
||||
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'hit' }] }],
|
||||
},
|
||||
}),
|
||||
ev('assistant/chunk', 5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'found ' } }),
|
||||
]
|
||||
const { seed, snapshot } = buildSidechatInheritance(events)
|
||||
expectContiguous(seed)
|
||||
expect(seed.at(-1)?.type).toBe('turn/end')
|
||||
expect(hasDanglingToolCall(events, 1)).toBe(false)
|
||||
expect(snapshot).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to a snapshot cut when a tool call is still executing', () => {
|
||||
const events = [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'running ' } }),
|
||||
ev('tool/call', 4, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep 9"}' }),
|
||||
]
|
||||
expect(hasDanglingToolCall(events, 1)).toBe(true)
|
||||
const { seed, snapshot } = buildSidechatInheritance(events)
|
||||
expectContiguous(seed)
|
||||
// The seed stops BEFORE the open turn; the pending user message stays.
|
||||
expect(seed.at(-1)?.type).toBe('user/message')
|
||||
expect(seed.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(snapshot).not.toBeNull()
|
||||
expect(snapshot).toContain('running ')
|
||||
expect(snapshot).toContain('`bash` (executing)')
|
||||
expect(snapshot).toContain('sleep 9')
|
||||
})
|
||||
|
||||
it('closes only the current step when the open turn has completed steps', () => {
|
||||
const events = [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('assistant/message', 3, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }),
|
||||
ev('step/end', 4, { turn: 1, step: 1 }),
|
||||
ev('step/start', 5, { turn: 1, step: 2 }),
|
||||
ev('assistant/chunk', 6, { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'second' } }),
|
||||
]
|
||||
const { seed } = buildSidechatInheritance(events)
|
||||
expectContiguous(seed)
|
||||
expect(seed.at(-2)).toMatchObject({ type: 'step/end', data: { turn: 1, step: 2 } })
|
||||
expect(seed.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 1 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildOpenTurnSnapshot', () => {
|
||||
it('returns null without an open turn', () => {
|
||||
expect(buildOpenTurnSnapshot([ev('user/message', 0, { content: [], source: { kind: 'user' } })])).toBeNull()
|
||||
expect(buildOpenTurnSnapshot(completedTurn(0, 1, { text: 'x' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves streamed text and tool detail verbatim', () => {
|
||||
const events = [
|
||||
ev('turn/start', 0, { turn: 1 }),
|
||||
ev('step/start', 1, { turn: 1, step: 1 }),
|
||||
ev('assistant/chunk', 2, { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'think' } }),
|
||||
ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '```js\ncode' } }),
|
||||
ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '\n```' } }),
|
||||
ev('tool/call', 5, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{"path":"a.txt"}' }),
|
||||
ev('tool/result', 6, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
source: { kind: 'tool', callId: 'c1' },
|
||||
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'file body' }] }],
|
||||
},
|
||||
}),
|
||||
ev('tool/call', 7, { turn: 1, step: 1, callId: 'c2', name: 'bash', arguments: '{"cmd":"long"}' }),
|
||||
]
|
||||
const snapshot = buildOpenTurnSnapshot(events)
|
||||
expect(snapshot).not.toBeNull()
|
||||
expect(snapshot).toContain('```js\ncode\n```')
|
||||
expect(snapshot).toContain('Reasoning so far')
|
||||
expect(snapshot).toContain('`read` — arguments: `{"path":"a.txt"}`')
|
||||
expect(snapshot).toContain('Result: file body')
|
||||
expect(snapshot).toContain('`bash` (executing)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sideLabel', () => {
|
||||
it('prefixes and truncates', () => {
|
||||
expect(sideLabel('hello')).toBe('Side: hello')
|
||||
expect(sideLabel(' a b ')).toBe('Side: a b')
|
||||
const long = 'x'.repeat(100)
|
||||
const label = sideLabel(long)
|
||||
expect(label.startsWith(SIDE_LABEL_PREFIX)).toBe(true)
|
||||
expect(label.length).toBeLessThanOrEqual(48)
|
||||
expect(label.endsWith('…')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sideThreadRows', () => {
|
||||
const summary = (over: Partial<SidebarSessionSummary>): SidebarSessionSummary => ({
|
||||
id: 's1',
|
||||
displayTitle: 'x',
|
||||
...over,
|
||||
})
|
||||
|
||||
it('keeps only subagent-origin children of the session with the label prefix', () => {
|
||||
const byId: Record<string, SidebarSessionSummary> = {
|
||||
main: summary({ id: 'main', displayTitle: 'Main' }),
|
||||
thread: summary({ id: 'thread', origin: 'subagent', parentId: 'main', displayTitle: 'Side: q', running: true }),
|
||||
plainSubagent: summary({ id: 'plain', origin: 'subagent', parentId: 'main', displayTitle: 'Read files' }),
|
||||
foreignThread: summary({ id: 'foreign', origin: 'subagent', parentId: 'other', displayTitle: 'Side: other' }),
|
||||
}
|
||||
const rows = sideThreadRows(byId, 'main')
|
||||
expect(rows).toEqual([{ id: 'thread', title: 'Side: q', running: true }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('save eligibility', () => {
|
||||
const entry = (event: SidebarSessionEvent): SidebarHistoryEntry => ({ event })
|
||||
|
||||
it('requires a completed turn after the seed boundary', () => {
|
||||
const seed = [ev('session/end-seed', 0)]
|
||||
expect(threadHasCompletedTurn([entry(seed[0]!)])).toBe(false)
|
||||
expect(threadHasCompletedTurn([entry(seed[0]!), entry(ev('user/message', 1, { content: [], source: { kind: 'user' } }))])).toBe(false)
|
||||
const withTurn = [...completedTurn(2, 1, { text: 'a' })]
|
||||
const entries = [entry(seed[0]!), ...withTurn.map(entry)]
|
||||
expect(threadHasCompletedTurn(entries)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a trailing unanswered follow-up', () => {
|
||||
const entries = [
|
||||
...completedTurn(2, 1, { text: 'a' }).map(entry),
|
||||
entry(ev('user/message', 9, { content: [], source: { kind: 'user' } })),
|
||||
]
|
||||
expect(threadTrailingPending(entries)).toBe(true)
|
||||
expect(threadTrailingPending(completedTurn(2, 1, { text: 'a' }).map(entry))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePresetId', () => {
|
||||
it('prefers the newest selection event over the header', () => {
|
||||
const events = [ev('agent-preset/selected', 0, { agentPreset: 'later' })]
|
||||
expect(resolvePresetId({ agentPreset: 'early' }, events)).toBe('later')
|
||||
expect(resolvePresetId({ agentPreset: 'early' }, [])).toBe('early')
|
||||
expect(resolvePresetId({}, [])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SIDE_BOUNDARY_PROMPT contract', () => {
|
||||
it('opens with the boundary prefix the transcript drops', () => {
|
||||
expect(SIDE_BOUNDARY_PROMPT.startsWith('Side conversation boundary')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boundaryDelivered', () => {
|
||||
it('detects the boundary message and ignores everything else', () => {
|
||||
expect(boundaryDelivered([])).toBe(false)
|
||||
expect(boundaryDelivered([ev('user/message', 0, {
|
||||
content: [{ type: 'text', text: 'ordinary question' }],
|
||||
})])).toBe(false)
|
||||
// The inherited seed never contains a boundary: parent messages are
|
||||
// ordinary user rows.
|
||||
expect(boundaryDelivered([
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'parent q' }] }),
|
||||
ev('user/message', 1, {
|
||||
content: [{ type: 'text', text: `${SIDE_BOUNDARY_PROMPT}\n\nfirst` }],
|
||||
}),
|
||||
])).toBe(true)
|
||||
// Bare-string content is tolerated too.
|
||||
expect(boundaryDelivered([ev('user/message', 0, {
|
||||
content: `${SIDE_BOUNDARY_PROMPT}\n\nfirst`,
|
||||
})])).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Host route tests for the Side Chat API ('sidechat.start' / 'sidechat.prompt'
|
||||
* / 'sidechat.cancel' / 'sidechat.dispose'): the custom-seed thread creation
|
||||
* (with the in-progress-turn synthetic close and the dangling-tool-call
|
||||
* snapshot fallback), the boundary+question first prompt, follow-ups on live
|
||||
* and cold (resumed) agents, cancel, and dispose.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { buildSidechatApi } from '../src/sidechat-routes.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
import { SIDE_BOUNDARY_PROMPT, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
|
||||
/** A fake live agent (followup/cancel spied). */
|
||||
function agent(id: string, over: { events?: unknown[]; header?: Record<string, unknown>; provider?: string; model?: string } = {}) {
|
||||
return {
|
||||
id,
|
||||
status: 'idle' as const,
|
||||
options: { provider: over.provider ?? 'test', model: over.model ?? 'model-x' },
|
||||
session: {
|
||||
id,
|
||||
header: { cwd: '/p', delegationDepth: 0, agentPreset: 'preset-a', ...over.header },
|
||||
events: over.events ?? [],
|
||||
},
|
||||
followup: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
type AgentLike = ReturnType<typeof agent>
|
||||
type Handle = { agent: AgentLike; dispose: () => Promise<void> }
|
||||
|
||||
/** The standard services the happy paths need. */
|
||||
function happyServices(parent: AgentLike | undefined, child: AgentLike) {
|
||||
const create = vi.fn(async (_options: unknown): Promise<Handle> => ({
|
||||
agent: child,
|
||||
dispose: vi.fn(async () => {}),
|
||||
}))
|
||||
const resume = vi.fn(async (_options: unknown): Promise<Handle> => ({
|
||||
agent: child,
|
||||
dispose: vi.fn(async () => {}),
|
||||
}))
|
||||
const get = vi.fn((id: unknown) => (id === child.id ? child : parent))
|
||||
const rename = vi.fn((_session: unknown, _title: string) => ({ title: 'x', eventSeq: 1 }))
|
||||
const resolve = vi.fn(async (_id?: string) => ({ id: 'preset-a' }))
|
||||
const mount = vi.fn(async () => {})
|
||||
const inspect = vi.fn(async () => ({ meta: { agentPreset: 'preset-a' }, events: [] }))
|
||||
return {
|
||||
agents: { get, create, resume },
|
||||
agentPresets: { resolve, mount },
|
||||
sessionTitle: { rename },
|
||||
sessionPersistence: { inspect },
|
||||
create,
|
||||
resume,
|
||||
get,
|
||||
rename,
|
||||
mount,
|
||||
inspect,
|
||||
}
|
||||
}
|
||||
|
||||
/** A context serving the optional services the routes read via ctx.get. */
|
||||
function ctxWith(services: {
|
||||
agents?: unknown
|
||||
agentPresets?: unknown
|
||||
sessionTitle?: unknown
|
||||
sessionPersistence?: unknown
|
||||
}): Context {
|
||||
const table: Record<string, unknown> = {
|
||||
...(services.agents === undefined ? {} : { agents: services.agents }),
|
||||
...(services.agentPresets === undefined ? {} : { agentPresets: services.agentPresets }),
|
||||
...(services.sessionTitle === undefined ? {} : { sessionTitle: services.sessionTitle }),
|
||||
...(services.sessionPersistence === undefined ? {} : { sessionPersistence: services.sessionPersistence }),
|
||||
}
|
||||
return {
|
||||
get: (key: string) => table[key],
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
function ev(type: string, seq: number, data: Record<string, unknown> = {}): { type: string; seq: number; time: number; data: Record<string, unknown>; surfaceOp?: string } {
|
||||
const event = { type, seq, time: seq * 1000, data }
|
||||
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
|
||||
return { ...event, surfaceOp: 'append' }
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
describe('sidechat.start', () => {
|
||||
it('creates a seeded subagent-origin child and admits the boundary + question', async () => {
|
||||
const parent = agent('parent', {
|
||||
events: [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('assistant/message', 3, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'a' }] } }),
|
||||
ev('step/end', 4, { turn: 1, step: 1 }),
|
||||
ev('turn/end', 5, { turn: 1, reason: { kind: 'completed' } }),
|
||||
],
|
||||
})
|
||||
const child = agent('child')
|
||||
const services = happyServices(parent, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
|
||||
const result = await api['sidechat.start']({ sessionId: 'parent', question: 'explain the event flow' })
|
||||
|
||||
expect(result.childId).toMatch(/^session-/)
|
||||
expect(services.create).toHaveBeenCalledTimes(1)
|
||||
const options = services.create.mock.calls[0]![0] as {
|
||||
sessionId: string
|
||||
meta: Record<string, unknown>
|
||||
seed: readonly { type: string; data: Record<string, unknown> }[]
|
||||
agentOptions: { provider: string; model: string }
|
||||
setup: unknown
|
||||
}
|
||||
expect(options.sessionId).toBe(result.childId)
|
||||
expect(options.meta).toMatchObject({
|
||||
parentSession: 'parent',
|
||||
origin: 'subagent',
|
||||
seedLength: 7,
|
||||
delegationDepth: 1,
|
||||
agentPreset: 'preset-a',
|
||||
cwd: '/p',
|
||||
})
|
||||
expect(options.agentOptions).toEqual({ provider: 'test', model: 'model-x' })
|
||||
// The child carries the parent's completed turns as a verbatim seed,
|
||||
// closed by the durable subagent descriptor (honest catalog citizenship:
|
||||
// without it the host's subagents.list renders the cold thread as a
|
||||
// 'corrupt' diagnostic row).
|
||||
expect(options.seed.map(event => event.type)).toEqual([
|
||||
'user/message', 'turn/start', 'step/start', 'assistant/message', 'step/end', 'turn/end',
|
||||
'subagent/descriptor',
|
||||
])
|
||||
expect(options.seed.at(-1)?.data).toMatchObject({
|
||||
version: 2,
|
||||
mode: 'continuable',
|
||||
provider: 'sidechat',
|
||||
label: sideLabel('explain the event flow'),
|
||||
agentProvider: 'test',
|
||||
agentModel: 'model-x',
|
||||
})
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string } }
|
||||
expect(message.source).toEqual({ kind: 'user' })
|
||||
const promptText = message.content[0]!.text
|
||||
expect(promptText.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(promptText).toContain('explain the event flow')
|
||||
expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow'))
|
||||
})
|
||||
|
||||
it('synthetically closes an in-progress parent turn inside the seed', async () => {
|
||||
const parent = agent('parent', {
|
||||
events: [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'streaming' } }),
|
||||
],
|
||||
})
|
||||
const child = agent('child')
|
||||
const services = happyServices(parent, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
|
||||
await api['sidechat.start']({ sessionId: 'parent', question: 'what now?' })
|
||||
|
||||
const options = services.create.mock.calls[0]![0] as { seed: Array<{ type: string; data: Record<string, unknown> }> }
|
||||
expect(options.seed.map(event => event.type)).toEqual([
|
||||
'user/message', 'turn/start', 'step/start', 'assistant/chunk', 'step/end', 'turn/end',
|
||||
'subagent/descriptor',
|
||||
])
|
||||
expect(options.seed.at(-2)?.data).toEqual({ turn: 1, reason: { kind: 'interrupted' } })
|
||||
})
|
||||
|
||||
it('falls back to the snapshot when a tool call is still executing', async () => {
|
||||
const parent = agent('parent', {
|
||||
events: [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep"}' }),
|
||||
],
|
||||
})
|
||||
const child = agent('child')
|
||||
const services = happyServices(parent, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
|
||||
await api['sidechat.start']({ sessionId: 'parent', question: 'what now?' })
|
||||
|
||||
const options = services.create.mock.calls[0]![0] as { seed: Array<{ type: string }> }
|
||||
// Cut BEFORE the open turn: only the pending user message is inherited.
|
||||
expect(options.seed.map(event => event.type)).toEqual(['user/message', 'subagent/descriptor'])
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(message.content[0]!.text).toContain('`bash` (executing)')
|
||||
})
|
||||
|
||||
it('creates an EMPTY thread on a blank question (Codex-style immediate create)', async () => {
|
||||
const parent = agent('parent', {
|
||||
events: [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep"}' }),
|
||||
],
|
||||
})
|
||||
const child = agent('child')
|
||||
const services = happyServices(parent, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
|
||||
const { childId } = await api['sidechat.start']({ sessionId: 'parent', question: ' ' })
|
||||
|
||||
// No prompt yet: the composer owns the first message; the placeholder
|
||||
// label is pinned and the in-progress snapshot is parked for it.
|
||||
expect(child.followup).not.toHaveBeenCalled()
|
||||
expect(services.rename).toHaveBeenCalledWith(child.session, SIDE_NEW_THREAD_TITLE)
|
||||
|
||||
// The first prompt carries the boundary + the parked snapshot and earns
|
||||
// the thread its real label.
|
||||
services.agents.get = vi.fn((id: unknown) => (id === childId ? child : parent))
|
||||
await api['sidechat.prompt']({ childId, text: 'explain the event flow' })
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(message.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(message.content[0]!.text).toContain('`bash` (executing)')
|
||||
expect(message.content[0]!.text).toContain('explain the event flow')
|
||||
expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow'))
|
||||
})
|
||||
|
||||
it('rejects a non-running parent', async () => {
|
||||
const parent = agent('parent', { events: [] })
|
||||
const child = agent('child')
|
||||
const services = happyServices(parent, child)
|
||||
const idle = ctxWith({
|
||||
...services,
|
||||
agents: { ...services.agents, get: vi.fn((_id: unknown) => undefined) },
|
||||
})
|
||||
const idleApi = buildSidechatApi(idle)
|
||||
await expect(idleApi['sidechat.start']({ sessionId: 'parent', question: 'q' })).rejects.toThrow(/not running/)
|
||||
})
|
||||
|
||||
it('degrades when the agents service is absent', async () => {
|
||||
const api = buildSidechatApi(ctxWith({}))
|
||||
await expect(api['sidechat.start']({ sessionId: 'parent', question: 'q' })).rejects.toThrow(SidebarError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sidechat.prompt', () => {
|
||||
it('delivers follow-ups to a live agent (boundary already delivered)', async () => {
|
||||
const child = agent('child', {
|
||||
events: [
|
||||
ev('user/message', 0, {
|
||||
content: [{ type: 'text', text: `${SIDE_BOUNDARY_PROMPT}\n\nfirst question` }],
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
],
|
||||
})
|
||||
const services = happyServices(undefined, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
const result = await api['sidechat.prompt']({ childId: 'child', text: 'tell me more' })
|
||||
expect(result).toEqual({ accepted: true })
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(message.content[0]!.text).toBe('tell me more')
|
||||
})
|
||||
|
||||
it('cold-resumes the thread when the agent is gone', async () => {
|
||||
const child = agent('child')
|
||||
const services = happyServices(undefined, child)
|
||||
services.agents.get = vi.fn((_id: unknown) => undefined)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
await api['sidechat.prompt']({ childId: 'child', text: 'after restart' })
|
||||
expect(services.resume).toHaveBeenCalledTimes(1)
|
||||
const resumeOptions = services.resume.mock.calls[0]![0] as { resumeSessionId: string; setup: unknown }
|
||||
expect(resumeOptions.resumeSessionId).toBe('child')
|
||||
expect(typeof resumeOptions.setup).toBe('function')
|
||||
expect(services.inspect).toHaveBeenCalledWith('child')
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects blank text', async () => {
|
||||
const child = agent('child')
|
||||
const services = happyServices(undefined, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
await expect(api['sidechat.prompt']({ childId: 'child', text: '' })).rejects.toThrow(SidebarError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sidechat.cancel and sidechat.dispose', () => {
|
||||
it('cancels the running turn with the user authority', async () => {
|
||||
const child = agent('child')
|
||||
const services = happyServices(undefined, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
await api['sidechat.cancel']({ childId: 'child' })
|
||||
expect(child.cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
|
||||
})
|
||||
|
||||
it('disposes the created thread agent', async () => {
|
||||
const parent = agent('parent', { events: [] })
|
||||
const dispose = vi.fn(async () => {})
|
||||
const create = vi.fn(async (_options: unknown): Promise<Handle> => ({
|
||||
agent: agent('child'),
|
||||
dispose,
|
||||
}))
|
||||
const services = happyServices(parent, agent('child'))
|
||||
services.agents.create = create
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
const { childId } = await api['sidechat.start']({ sessionId: 'parent', question: 'q' })
|
||||
await api['sidechat.dispose']({ childId })
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
// A second dispose is a harmless no-op.
|
||||
await api['sidechat.dispose']({ childId })
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sidechat.info', () => {
|
||||
it('reports the live agent state and identity', async () => {
|
||||
const child = agent('child')
|
||||
const services = happyServices(undefined, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
const info = await api['sidechat.info']({ childId: 'child' })
|
||||
expect(info).toEqual({
|
||||
live: true,
|
||||
status: 'idle',
|
||||
provider: 'test',
|
||||
model: 'model-x',
|
||||
preset: 'preset-a',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the persisted preset of a cold thread', async () => {
|
||||
const child = agent('child')
|
||||
const services = happyServices(undefined, child)
|
||||
services.agents.get = vi.fn((_id: unknown) => undefined)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
const info = await api['sidechat.info']({ childId: 'child' })
|
||||
expect(info).toEqual({ live: false, preset: 'preset-a' })
|
||||
expect(services.inspect).toHaveBeenCalledWith('child')
|
||||
})
|
||||
|
||||
it('degrades to a bare cold info when the session is gone', async () => {
|
||||
const services = happyServices(undefined, agent('child'))
|
||||
services.agents.get = vi.fn((_id: unknown) => undefined)
|
||||
services.inspect = vi.fn(async () => { throw new Error('unknown session') })
|
||||
services.sessionPersistence = { inspect: services.inspect }
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
await expect(api['sidechat.info']({ childId: 'ghost' })).resolves.toEqual({ live: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Seed-validator integration test: the seed built by {@link buildSidechatInheritance}
|
||||
* must be accepted by the REAL dsh-session validator (`Session.create`).
|
||||
*
|
||||
* Regression: the seed copy originally stripped the event envelope's
|
||||
* `surfaceOp` marker, and the real validator rejects surface-eligible events
|
||||
* (user/message, assistant/message, tool/result) without it — the live host
|
||||
* surfaced it as "invalid seed event at index N: ... requires a surfaceOp
|
||||
* marker" during real thread creation. This test runs the ACTUAL validator,
|
||||
* not a mock, so the class of bug cannot silently return.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SidebarSessionEvent } from '../src/context-types.ts'
|
||||
import { buildSidechatInheritance } from '../src/sidechat-core.ts'
|
||||
|
||||
/** One live-style event with the surface marker message events carry, and
|
||||
* the REAL message shapes the validator demands (id/role/source/content;
|
||||
* tool/result messages carry role 'user' + one tool-result block). */
|
||||
function ev(type: string, seq: number, data: Record<string, unknown>): SidebarSessionEvent {
|
||||
const event: SidebarSessionEvent = { type, seq, time: seq * 1000, data }
|
||||
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
|
||||
return { ...event, surfaceOp: 'append' } as SidebarSessionEvent
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
function userMessage(text: string): Record<string, unknown> {
|
||||
return { id: `m-${text}`, role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } }
|
||||
}
|
||||
|
||||
function assistantMessage(text: string): Record<string, unknown> {
|
||||
return {
|
||||
id: `m-${text}`,
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'model', provider: 'test', model: 'model-x' },
|
||||
}
|
||||
}
|
||||
|
||||
function toolResultMessage(callId: string, text: string): Record<string, unknown> {
|
||||
return {
|
||||
id: `m-${callId}`,
|
||||
role: 'user',
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content: [{ type: 'text', text }] }],
|
||||
source: { kind: 'tool', callId },
|
||||
}
|
||||
}
|
||||
|
||||
/** A parent log with a completed turn, a pending question, and an open
|
||||
* in-progress turn (the exact shape a mid-stream thread creation sees). */
|
||||
function parentLog(): SidebarSessionEvent[] {
|
||||
return [
|
||||
ev('user/message', 0, userMessage('first question')),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('assistant/message', 3, { turn: 1, step: 1, message: assistantMessage('first answer') }),
|
||||
ev('step/end', 4, { turn: 1, step: 1 }),
|
||||
ev('turn/end', 5, { turn: 1, reason: { kind: 'completed' } }),
|
||||
ev('user/message', 6, userMessage('pending question')),
|
||||
ev('turn/start', 7, { turn: 2 }),
|
||||
ev('step/start', 8, { turn: 2, step: 1 }),
|
||||
ev('assistant/chunk', 9, { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'in-progress' } }),
|
||||
]
|
||||
}
|
||||
|
||||
describe('sidechat seed against the real dsh-session validator', () => {
|
||||
it('accepts a seed with a completed turn + pending user message + synthetically closed open turn', () => {
|
||||
const { seed, snapshot } = buildSidechatInheritance(parentLog())
|
||||
expect(snapshot).toBeNull()
|
||||
// The REAL validator: this throws when an envelope field was stripped
|
||||
// (the reported regression) or the turn balance is off.
|
||||
const child = Session.create('session-validator-test' as SessionId, seed as never)
|
||||
const types = child.events.map(event => event.type)
|
||||
expect(types).toEqual([
|
||||
'user/message', 'turn/start', 'step/start', 'assistant/message', 'step/end', 'turn/end',
|
||||
'user/message', 'turn/start', 'step/start', 'assistant/chunk', 'step/end', 'turn/end',
|
||||
'session/end-seed',
|
||||
])
|
||||
// The synthetic close is honest: the frozen turn ends interrupted.
|
||||
const turnEnd = child.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.data).toEqual({ turn: 2, reason: { kind: 'interrupted' } })
|
||||
})
|
||||
|
||||
it('accepts a dangling-tool-call fallback seed (cut before the open turn)', () => {
|
||||
const log = [
|
||||
...parentLog().slice(0, 7),
|
||||
ev('turn/start', 7, { turn: 2 }),
|
||||
ev('step/start', 8, { turn: 2, step: 1 }),
|
||||
ev('tool/call', 9, { turn: 2, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep"}' }),
|
||||
]
|
||||
const { seed, snapshot } = buildSidechatInheritance(log)
|
||||
expect(snapshot).not.toBeNull()
|
||||
const child = Session.create('session-validator-fallback' as SessionId, seed as never)
|
||||
expect(child.events.map(event => event.type).at(-1)).toBe('session/end-seed')
|
||||
})
|
||||
|
||||
it('accepts the durable subagent descriptor the routes append to the seed', () => {
|
||||
// Regression guard for the catalog-corrupt fix: a cold thread WITHOUT a
|
||||
// descriptor renders as a 'corrupt' diagnostic in the host subagents.list.
|
||||
// The descriptor is a log-only event appended INSIDE the seed (before the
|
||||
// end-seed marker); the real validator must accept the combined seed.
|
||||
const { seed } = buildSidechatInheritance(parentLog())
|
||||
const descriptor = snapshotSubagentDescriptor({
|
||||
mode: 'continuable',
|
||||
provider: 'sidechat',
|
||||
label: 'Side: test',
|
||||
agentProvider: 'test',
|
||||
agentModel: 'model-x',
|
||||
})
|
||||
const withDescriptor = [
|
||||
...seed,
|
||||
{ type: 'subagent/descriptor', seq: seed.length, time: Date.now(), data: descriptor },
|
||||
]
|
||||
const child = Session.create('session-validator-descriptor' as SessionId, withDescriptor as never)
|
||||
const types = child.events.map(event => event.type)
|
||||
expect(types.at(-2)).toBe('subagent/descriptor')
|
||||
expect(types.at(-1)).toBe('session/end-seed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Unit tests for the Side Chat transcript mapping (src/client/sidechat-
|
||||
* transcript.ts): the seed cut at session/end-seed, the boundary-row drop,
|
||||
* chunk streaming accumulation superseded by assembled messages, tool
|
||||
* call/result pairing, and orphan failed results.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SidebarHistoryEntry, SidebarSessionEvent } from '../src/context-types.ts'
|
||||
import { SIDE_BOUNDARY_PREFIX } from '../src/sidechat-core.ts'
|
||||
import { collectOwnEvents, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts'
|
||||
|
||||
/** One history entry (event + optional view). */
|
||||
function entry(event: SidebarSessionEvent): SidebarHistoryEntry {
|
||||
return { event }
|
||||
}
|
||||
|
||||
/** One log event fixture. Surface-eligible events carry their required
|
||||
* `surfaceOp: 'append'` marker, exactly like the live pipeline appends them. */
|
||||
function ev(type: string, seq: number, data: Record<string, unknown> = {}): SidebarSessionEvent {
|
||||
const event: SidebarSessionEvent = { type, seq, time: seq * 1000, data }
|
||||
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
|
||||
return { ...event, surfaceOp: 'append' } as SidebarSessionEvent
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
function textBlocks(...texts: string[]): unknown[] {
|
||||
return texts.map(text => ({ type: 'text', text }))
|
||||
}
|
||||
|
||||
describe('transcriptRows', () => {
|
||||
it('cuts the inherited seed at the last end-seed and drops the boundary row', () => {
|
||||
const entries = [
|
||||
entry(ev('user/message', 0, { content: textBlocks('inherited'), source: { kind: 'user' } })),
|
||||
entry(ev('session/end-seed', 1)),
|
||||
entry(ev('user/message', 2, { content: textBlocks(`${SIDE_BOUNDARY_PREFIX}\n\nmode`), source: { kind: 'user' } })),
|
||||
entry(ev('user/message', 3, { content: textBlocks('the side question'), source: { kind: 'user' } })),
|
||||
]
|
||||
const rows = transcriptRows(entries)
|
||||
expect(rows).toEqual([{ kind: 'user', seq: 3, text: 'the side question' }])
|
||||
})
|
||||
|
||||
it('accumulates chunk deltas per block and supersedes them on settle', () => {
|
||||
const entries = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })),
|
||||
entry(ev('turn/start', 2, { turn: 1 })),
|
||||
entry(ev('step/start', 3, { turn: 1, step: 1 })),
|
||||
entry(ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'Hel' } })),
|
||||
entry(ev('assistant/chunk', 5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'lo' } })),
|
||||
entry(ev('assistant/chunk', 6, { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'think' } })),
|
||||
]
|
||||
const rows = transcriptRows(entries)
|
||||
const assistant = rows.find(row => row.kind === 'assistant') as Extract<SidechatTranscriptRow, { kind: 'assistant' }>
|
||||
expect(assistant.text).toBe('Hello')
|
||||
expect(assistant.settled).toBe(false)
|
||||
const reasoning = rows.find(row => row.kind === 'reasoning') as Extract<SidechatTranscriptRow, { kind: 'reasoning' }>
|
||||
expect(reasoning.text).toBe('think')
|
||||
})
|
||||
|
||||
it('replaces streaming rows with the settled assistant message', () => {
|
||||
const entries = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })),
|
||||
entry(ev('turn/start', 2, { turn: 1 })),
|
||||
entry(ev('step/start', 3, { turn: 1, step: 1 })),
|
||||
entry(ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'par' } })),
|
||||
entry(ev('assistant/message', 5, { turn: 1, step: 1, message: { content: textBlocks('final answer') } })),
|
||||
]
|
||||
const rows = transcriptRows(entries)
|
||||
const assistants = rows.filter(row => row.kind === 'assistant')
|
||||
expect(assistants).toHaveLength(1)
|
||||
expect(assistants[0]).toMatchObject({ kind: 'assistant', text: 'final answer', settled: true })
|
||||
})
|
||||
|
||||
it('pairs tool calls with results and marks failures', () => {
|
||||
const entries = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('turn/start', 1, { turn: 1 })),
|
||||
entry(ev('step/start', 2, { turn: 1, step: 1 })),
|
||||
entry(ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{"path":"a"}' })),
|
||||
entry(ev('tool/result', 4, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
source: { kind: 'tool', callId: 'c1' },
|
||||
content: [{ type: 'tool-result', toolCallId: 'c1', isError: true, content: [{ type: 'text', text: 'denied' }] }],
|
||||
},
|
||||
error: { name: 'EACCES', code: 'EACCES' },
|
||||
})),
|
||||
]
|
||||
const rows = transcriptRows(entries)
|
||||
expect(rows).toHaveLength(1)
|
||||
const tool = rows[0]
|
||||
expect(tool).toMatchObject({
|
||||
kind: 'tool',
|
||||
name: 'read',
|
||||
args: '{"path":"a"}',
|
||||
resultText: 'denied',
|
||||
failed: true,
|
||||
executing: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a call executing until its result lands and surfaces orphan failures', () => {
|
||||
const entries = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('turn/start', 1, { turn: 1 })),
|
||||
entry(ev('step/start', 2, { turn: 1, step: 1 })),
|
||||
entry(ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{}' })),
|
||||
]
|
||||
let rows = transcriptRows(entries)
|
||||
expect(rows[0]).toMatchObject({ kind: 'tool', executing: true })
|
||||
|
||||
// Orphan failed result outside the fetched window still surfaces.
|
||||
const orphan = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('tool/result', 1, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
source: { kind: 'tool', callId: 'gone' },
|
||||
content: [{ type: 'tool-result', toolCallId: 'gone', isError: true, content: [{ type: 'text', text: 'boom' }] }],
|
||||
},
|
||||
error: { name: 'X', code: 'X' },
|
||||
})),
|
||||
]
|
||||
rows = transcriptRows(orphan)
|
||||
expect(rows[0]).toMatchObject({ kind: 'tool', failed: true, resultText: 'boom' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectOwnEvents', () => {
|
||||
/** A paginated fake log: fetchPage(beforeSeq) returns the ≤pageSize events
|
||||
* ENDING before beforeSeq (exclusive); the tail page when omitted. */
|
||||
function pagedLog(events: SidebarSessionEvent[], pageSize: number) {
|
||||
const calls: Array<number | undefined> = []
|
||||
const fetchPage = async (beforeSeq?: number): Promise<SidebarHistoryEntry[]> => {
|
||||
calls.push(beforeSeq)
|
||||
const window = beforeSeq === undefined ? events : events.filter(e => e.seq < beforeSeq)
|
||||
return window.slice(-pageSize).map(entry)
|
||||
}
|
||||
return { fetchPage, calls }
|
||||
}
|
||||
|
||||
it('finds the seed boundary on the tail page in one fetch', async () => {
|
||||
const { fetchPage, calls } = pagedLog([
|
||||
ev('user/message', 0, { content: textBlocks('inherited'), source: { kind: 'user' } }),
|
||||
ev('session/end-seed', 1),
|
||||
ev('user/message', 2, { content: textBlocks('own'), source: { kind: 'user' } }),
|
||||
ev('assistant/message', 3, { turn: 1, step: 1, message: { content: textBlocks('a') } }),
|
||||
], 200)
|
||||
const result = await collectOwnEvents(fetchPage)
|
||||
expect(calls).toEqual([undefined])
|
||||
expect(result.seedBoundary).toBe(1)
|
||||
expect(result.entries.map(e => e.event.seq)).toEqual([2, 3])
|
||||
})
|
||||
|
||||
it('walks back across pages until the boundary surfaces', async () => {
|
||||
// A dense chunk tail (cold reads re-expand chunk-rows): the boundary is
|
||||
// several pages back and earlier tool/call events must NOT fall out of
|
||||
// the loaded window (the re-entry tool-row regression).
|
||||
const events = [
|
||||
ev('session/end-seed', 0),
|
||||
ev('tool/call', 1, { turn: 1, step: 1, callId: 'c1', name: 'bash' }),
|
||||
...Array.from({ length: 12 }, (_, i) => ev('assistant/chunk', 2 + i, { turn: 1, step: 1 })),
|
||||
]
|
||||
const { fetchPage, calls } = pagedLog(events, 5)
|
||||
const result = await collectOwnEvents(fetchPage)
|
||||
expect(calls.length).toBeGreaterThan(1)
|
||||
expect(result.seedBoundary).toBe(0)
|
||||
// Everything after the marker, oldest-first, deduped.
|
||||
expect(result.entries.map(e => e.event.seq)).toEqual(events.slice(1).map(e => e.seq))
|
||||
})
|
||||
|
||||
it('stops at the log start when no marker exists and reports boundary 0', async () => {
|
||||
const { fetchPage } = pagedLog([
|
||||
ev('user/message', 0, { content: textBlocks('legacy'), source: { kind: 'user' } }),
|
||||
ev('assistant/message', 1, { turn: 1, step: 1, message: { content: textBlocks('a') } }),
|
||||
], 200)
|
||||
const result = await collectOwnEvents(fetchPage)
|
||||
expect(result.seedBoundary).toBe(0)
|
||||
expect(result.entries.map(e => e.event.seq)).toEqual([0, 1])
|
||||
})
|
||||
|
||||
it('honours the page cap instead of re-walking forever', async () => {
|
||||
const events = Array.from({ length: 30 }, (_, i) => ev('assistant/chunk', i, { turn: 1, step: 1 }))
|
||||
const { fetchPage, calls } = pagedLog(events, 5)
|
||||
const result = await collectOwnEvents(fetchPage, 2)
|
||||
expect(calls).toEqual([undefined, 25])
|
||||
expect(result.seedBoundary).toBe(0)
|
||||
expect(result.entries.map(e => e.event.seq)).toEqual([20, 21, 22, 23, 24, 25, 26, 27, 28, 29])
|
||||
})
|
||||
})
|
||||
@@ -75,6 +75,29 @@ describe('subagent detection over the sessions list feed', () => {
|
||||
expect(countSubagentDescendants(cyclic, 'a')).toEqual({ count: 2, runningCount: 0 })
|
||||
})
|
||||
|
||||
it('excludes Side Chat threads (subagent origin, "Side: " label) from counts', () => {
|
||||
// Side threads ride the subagent origin for list hiding + the RPC fence
|
||||
// but they are tab-strip conversations, never topology: they must not
|
||||
// fire the auto-open trigger nor inflate the Subagent page totals.
|
||||
const byId: SidebarSessionList['byId'] = {
|
||||
p1: { id: 'p1', displayTitle: 'P1' },
|
||||
c1: { id: 'c1', displayTitle: 'C1', origin: 'subagent', parentId: 'p1' },
|
||||
s1: { id: 's1', displayTitle: 'Side: refactor plan', origin: 'subagent', parentId: 'p1', running: true },
|
||||
}
|
||||
expect(directSubagentCount(byId, 'p1')).toBe(1)
|
||||
expect(countSubagentDescendants(byId, 'p1')).toEqual({ count: 1, runningCount: 0 })
|
||||
// A side thread appearing under an empty session never trips 0 → N.
|
||||
const before: SidebarSessionList = { current: 'p2', byId: { p2: { id: 'p2', displayTitle: 'P2' } } }
|
||||
const after: SidebarSessionList = {
|
||||
current: 'p2',
|
||||
byId: {
|
||||
p2: { id: 'p2', displayTitle: 'P2' },
|
||||
s2: { id: 's2', displayTitle: 'Side: New thread', origin: 'subagent', parentId: 'p2' },
|
||||
},
|
||||
}
|
||||
expect(detectNewDirectSubagent(before, after, 'p2')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves the main-agent root of the current session tree', () => {
|
||||
const byId: SidebarSessionList['byId'] = {
|
||||
main: { id: 'main', displayTitle: 'Main' },
|
||||
|
||||
Reference in New Issue
Block a user