Merge branch 'main' into fix/markdown-preview-local-images
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 ?? []
|
||||
|
||||
@@ -536,82 +536,6 @@ test('a capture-lost drag with no usable coordinates keeps the last applied widt
|
||||
await page.mouse.up()
|
||||
})
|
||||
|
||||
/* ── issue #248: HMR center-column replacement must be re-located ────── */
|
||||
|
||||
test('bottom panel re-locates a center column replaced deep inside #root (issue #248)', async ({ page }) => {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
await dismissOnboarding(page)
|
||||
await expandSidebar(page, sidebar)
|
||||
|
||||
// Open the bottom panel; it must become visible with the center column
|
||||
// measured (its right edge tracking the column's right edge).
|
||||
const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' })
|
||||
await expect(bottomExpand, 'the toggle cluster must offer the bottom-panel expand button').toHaveCount(1)
|
||||
await bottomExpand.click()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-height'))
|
||||
return value !== '' && value !== '0px'
|
||||
}, { timeout: 90_000 })
|
||||
.toBe(true)
|
||||
|
||||
const probe = (): Promise<{ visible: boolean; drift: number } | null> => page.evaluate(() => {
|
||||
const el = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
|
||||
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
|
||||
if (el === null || col == null) return null
|
||||
const panel = el.getBoundingClientRect()
|
||||
const center = col.getBoundingClientRect()
|
||||
return {
|
||||
visible: getComputedStyle(el).visibility !== 'hidden',
|
||||
drift: Math.abs(panel.right - center.right),
|
||||
}
|
||||
})
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = await probe()
|
||||
return p !== null && p.visible && p.drift <= 8
|
||||
}, { timeout: 30_000 })
|
||||
.toBe(true)
|
||||
|
||||
// Simulate the HMR re-render: the shell swaps the center column node
|
||||
// DEEP inside #root (a mutation the old childList-only watcher never
|
||||
// saw — #root's own children don't change), first with a zero-size stub
|
||||
// (mid-teardown), then with a re-created column of different geometry.
|
||||
await page.evaluate(() => {
|
||||
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
|
||||
if (col == null || col.parentElement === null) throw new Error('center column not found')
|
||||
const stub = document.createElement('div')
|
||||
const slot = document.createElement('div')
|
||||
slot.setAttribute('data-slot', 'conversation')
|
||||
stub.appendChild(slot)
|
||||
col.parentElement.replaceChild(stub, col)
|
||||
})
|
||||
await page.evaluate(() => {
|
||||
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
|
||||
if (col == null || col.parentElement === null) throw new Error('center column stub not found')
|
||||
const fresh = document.createElement('div')
|
||||
fresh.style.width = '300px'
|
||||
fresh.style.height = '200px'
|
||||
const slot = document.createElement('div')
|
||||
slot.setAttribute('data-slot', 'conversation')
|
||||
fresh.appendChild(slot)
|
||||
col.parentElement.replaceChild(fresh, col)
|
||||
})
|
||||
|
||||
// The locate chain (subtree watcher / html-style watcher / 1.5s retry)
|
||||
// must converge on the new column within a couple of seconds; the old
|
||||
// code kept observing the detached column and never recovered.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const p = await probe()
|
||||
return p !== null && p.visible && p.drift <= 8
|
||||
}, { timeout: 15_000 })
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
/* ── issue #258: the bottom panel must not flash full-width after a width
|
||||
drag release ─────────────────────────────────────────────────────── */
|
||||
|
||||
|
||||
+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 (beta)', '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,365 @@
|
||||
/**
|
||||
* 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,
|
||||
isContextInjectionMessage,
|
||||
resolvePresetId,
|
||||
sideLabel,
|
||||
sideThreadRows,
|
||||
threadHasCompletedTurn,
|
||||
threadTrailingPending,
|
||||
SIDE_BOUNDARY_PROMPT,
|
||||
SIDE_INJECTION_PLUGIN,
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isContextInjectionMessage', () => {
|
||||
it('recognizes plugin-stamped sources structurally', () => {
|
||||
expect(isContextInjectionMessage({
|
||||
content: [{ type: 'text', text: 'runtime context' }],
|
||||
source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN },
|
||||
})).toBe(true)
|
||||
expect(isContextInjectionMessage({
|
||||
content: [{ type: 'text', text: 'q' }],
|
||||
source: { kind: 'user' },
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the boundary prefix for pre-split logs and sourceless rows', () => {
|
||||
expect(isContextInjectionMessage({
|
||||
content: [{ type: 'text', text: `${SIDE_BOUNDARY_PROMPT}\n\nlegacy question` }],
|
||||
source: { kind: 'user' },
|
||||
})).toBe(true)
|
||||
expect(isContextInjectionMessage({
|
||||
content: `${SIDE_BOUNDARY_PROMPT}\n\nlegacy`,
|
||||
})).toBe(true)
|
||||
expect(isContextInjectionMessage({
|
||||
content: [{ type: 'text', text: 'ordinary question' }],
|
||||
source: { kind: 'user' },
|
||||
})).toBe(false)
|
||||
expect(isContextInjectionMessage({ content: [{ type: 'text', text: 'no source row' }] })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 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_INJECTION_PLUGIN, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
|
||||
/** A fake live agent (inject/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 ?? [],
|
||||
},
|
||||
inject: vi.fn(),
|
||||
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',
|
||||
})
|
||||
// First contact is SPLIT: the boundary rides agent.inject (plugin-stamped
|
||||
// context, no wake), the question is the follow-up that wakes the driver.
|
||||
expect(child.inject).toHaveBeenCalledTimes(1)
|
||||
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string; plugin: string } }
|
||||
expect(injection.source).toEqual({ kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN })
|
||||
expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(injection.content[0]!.text).not.toContain('explain the event flow')
|
||||
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' })
|
||||
expect(message.content[0]!.text).toBe('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'])
|
||||
// The snapshot rides the injection, not the follow-up question.
|
||||
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(injection.content[0]!.text).toContain('`bash` (executing)')
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(message.content[0]!.text).toBe('what now?')
|
||||
})
|
||||
|
||||
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.inject).not.toHaveBeenCalled()
|
||||
expect(child.followup).not.toHaveBeenCalled()
|
||||
expect(services.rename).toHaveBeenCalledWith(child.session, SIDE_NEW_THREAD_TITLE)
|
||||
|
||||
// The first prompt injects the boundary + the parked snapshot (split
|
||||
// from the question) 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.inject).toHaveBeenCalledTimes(1)
|
||||
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ text: string }>; source: { kind: string } }
|
||||
expect(injection.source.kind).toBe('plugin')
|
||||
expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(injection.content[0]!.text).toContain('`bash` (executing)')
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(message.content[0]!.text).toBe('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.inject).not.toHaveBeenCalled()
|
||||
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,248 @@
|
||||
/**
|
||||
* Unit tests for the Side Chat transcript mapping (src/client/sidechat-
|
||||
* transcript.ts): the seed cut at session/end-seed, context-injection rows
|
||||
* (plugin-stamped sources and the legacy boundary-prefix blob), 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, SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN } from '../src/sidechat-core.ts'
|
||||
import { collectOwnEvents, toolArgsSummary, 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 renders the boundary as an injection 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: 'plugin', plugin: SIDE_INJECTION_PLUGIN } })),
|
||||
entry(ev('user/message', 3, { content: textBlocks('the side question'), source: { kind: 'user' } })),
|
||||
]
|
||||
const rows = transcriptRows(entries)
|
||||
expect(rows).toEqual([
|
||||
{ kind: 'injection', seq: 2, text: `${SIDE_BOUNDARY_PREFIX}\n\nmode` },
|
||||
{ kind: 'user', seq: 3, text: 'the side question' },
|
||||
])
|
||||
})
|
||||
|
||||
it('splits the LEGACY wrapped first message (boundary + question in one user row) at the boundary prompt', () => {
|
||||
const entries = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('user/message', 1, {
|
||||
content: textBlocks(`${SIDE_BOUNDARY_PROMPT}\n\nthe first question`),
|
||||
source: { kind: 'user' },
|
||||
})),
|
||||
entry(ev('user/message', 2, { content: textBlocks('follow-up'), source: { kind: 'user' } })),
|
||||
]
|
||||
const rows = transcriptRows(entries)
|
||||
expect(rows).toEqual([
|
||||
{ kind: 'injection', seq: 1, text: SIDE_BOUNDARY_PROMPT },
|
||||
{ kind: 'user', seq: 1, text: 'the first question' },
|
||||
{ kind: 'user', seq: 2, text: 'follow-up' },
|
||||
])
|
||||
})
|
||||
|
||||
it('renders any plugin-sourced context message as an injection row, boundary prefix or not', () => {
|
||||
const entries = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('user/message', 1, { content: textBlocks('runtime context'), source: { kind: 'plugin', plugin: 'other-plugin' } })),
|
||||
entry(ev('user/message', 2, { content: textBlocks('q'), source: { kind: 'user' } })),
|
||||
]
|
||||
const rows = transcriptRows(entries)
|
||||
expect(rows).toEqual([
|
||||
{ kind: 'injection', seq: 1, text: 'runtime context' },
|
||||
{ kind: 'user', seq: 2, text: 'q' },
|
||||
])
|
||||
})
|
||||
|
||||
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])
|
||||
})
|
||||
})
|
||||
|
||||
describe('toolArgsSummary', () => {
|
||||
it('picks the most identifying string field', () => {
|
||||
expect(toolArgsSummary('{"command":"ls -la","timeout":1000}')).toBe('ls -la')
|
||||
expect(toolArgsSummary('{"file_path":"/a/b.ts","old_string":"x"}')).toBe('/a/b.ts')
|
||||
expect(toolArgsSummary('{"pattern":"foo","path":"/repo"}')).toBe('/repo')
|
||||
})
|
||||
|
||||
it('flattens and truncates raw text when no known key parses', () => {
|
||||
expect(toolArgsSummary('{"custom":"v"}')).toBe('{"custom":"v"}')
|
||||
expect(toolArgsSummary('not json at all')).toBe('not json at all')
|
||||
expect(toolArgsSummary(`{"command":"${'x'.repeat(200)}"`)).toHaveLength(80)
|
||||
expect(toolArgsSummary(`{"command":"${'x'.repeat(200)}"`)).toMatch(/…$/)
|
||||
})
|
||||
|
||||
it('reads empty for missing or blank input', () => {
|
||||
expect(toolArgsSummary(undefined)).toBe('')
|
||||
expect(toolArgsSummary('{"command":" "}')).toBe('{"command":" "}')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { contentText, lastActivity } from '../src/client/subagent-activity.ts'
|
||||
import type { SidebarHistoryEntry } from '../src/context-types.ts'
|
||||
import { contentText, lastActivity } from '../src/subagent-activity.ts'
|
||||
import type { SidebarSessionEvent } from '../src/context-types.ts'
|
||||
|
||||
describe('subagent activity summary parser', () => {
|
||||
/** One history entry from raw event fields. */
|
||||
const entry = (type: string, data: Record<string, unknown>): SidebarHistoryEntry => ({
|
||||
event: { type, seq: 0, time: 0, data },
|
||||
/** One raw session event. */
|
||||
const entry = (type: string, data: Record<string, unknown>): SidebarSessionEvent => ({
|
||||
type, seq: 0, time: 0, data,
|
||||
})
|
||||
|
||||
it('extracts text blocks and skips non-text content', () => {
|
||||
@@ -37,7 +37,7 @@ describe('subagent activity summary parser', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('lastActivity keeps only the fields the tail actually has', () => {
|
||||
it('lastActivity keeps only the fields the log actually has', () => {
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }),
|
||||
])).toEqual({ tool: { name: 'bash', args: '{"command":"ls"}' } })
|
||||
@@ -64,4 +64,42 @@ describe('subagent activity summary parser', () => {
|
||||
])
|
||||
expect(live.tool).toEqual({ name: 'web', args: '' })
|
||||
})
|
||||
|
||||
it('lastActivity window excludes activity older than the last maxMessages messages', () => {
|
||||
// One stale tool call before the window: with maxMessages=1 only the
|
||||
// latest message (and the events around it) are considered.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'new text' }] } }),
|
||||
], 1)).toEqual({ text: 'new text' })
|
||||
|
||||
// The same log with a wider window surfaces the tool call again.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'new text' }] } }),
|
||||
], 2)).toEqual({
|
||||
text: 'new text',
|
||||
tool: { name: 'bash', args: '{"command":"old"}' },
|
||||
})
|
||||
|
||||
// Non-message events (tool calls) do not consume quota, but once the
|
||||
// window holds its messages, older events are dropped even without an
|
||||
// older message to stop at: the second message pushes the tool call out.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }),
|
||||
entry('user/message', { content: [{ type: 'text', text: '继续' }] }),
|
||||
], 1)).toEqual({})
|
||||
|
||||
// A tool call between the two in-window messages stays visible.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }),
|
||||
entry('tool/call', { callId: 'fresh', name: 'read', arguments: '{"path":"a.ts"}' }),
|
||||
entry('user/message', { content: [{ type: 'text', text: '继续' }] }),
|
||||
], 2)).toEqual({
|
||||
text: 'first',
|
||||
tool: { name: 'read', args: '{"path":"a.ts"}' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,6 +75,56 @@ 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('documents the title-frame race the Sidebar auto-open debounce absorbs', () => {
|
||||
// The host delivers a new child's origin and title in SEPARATE frames:
|
||||
// a Side Chat thread's FIRST visible frame still shows a fallback title
|
||||
// (the cwd basename — no 'Side: ' prefix), so an immediate 0→N check
|
||||
// misreads it as a genuine subagent. Sidebar.tsx therefore debounces
|
||||
// AUTO_OPEN_DEBOUNCE_MS and re-evaluates the ORIGINAL baseline against
|
||||
// the live snapshot; once the title frame has landed the same baseline
|
||||
// yields no trigger. These two assertions pin exactly that dependency.
|
||||
const baseline: SidebarSessionList = { current: 'p2', byId: { p2: { id: 'p2', displayTitle: 'P2' } } }
|
||||
const firstFrame: SidebarSessionList = {
|
||||
current: 'p2',
|
||||
byId: {
|
||||
p2: { id: 'p2', displayTitle: 'P2' },
|
||||
s2: { id: 's2', displayTitle: 'DSH-better-sidebar', origin: 'subagent', parentId: 'p2' },
|
||||
},
|
||||
}
|
||||
expect(detectNewDirectSubagent(baseline, firstFrame, 'p2')).toBe(true) // the race
|
||||
const settled: SidebarSessionList = {
|
||||
current: 'p2',
|
||||
byId: {
|
||||
p2: { id: 'p2', displayTitle: 'P2' },
|
||||
s2: { id: 's2', displayTitle: 'Side: New thread', origin: 'subagent', parentId: 'p2' },
|
||||
},
|
||||
}
|
||||
expect(detectNewDirectSubagent(baseline, settled, 'p2')).toBe(false) // after the debounce
|
||||
})
|
||||
|
||||
it('resolves the main-agent root of the current session tree', () => {
|
||||
const byId: SidebarSessionList['byId'] = {
|
||||
main: { id: 'main', displayTitle: 'Main' },
|
||||
|
||||
@@ -96,21 +96,24 @@ beforeEach(() => {
|
||||
killCalls.length = 0
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
const body = JSON.parse(String(init?.body)) as { sessionId: string; id: string }
|
||||
const body = JSON.parse(String(init?.body)) as { sessionId?: string; id?: string; rootSessionId?: string }
|
||||
if (method === 'subagents.live') {
|
||||
return jsonResponse({ ok: true, value: { live: {} } })
|
||||
}
|
||||
if (method === 'jobs.output') {
|
||||
outputCalls.push({ sessionId: body.sessionId, id: body.id })
|
||||
outputCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' })
|
||||
// bash-9 stands for a job the model never read (read:false).
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
value: {
|
||||
text: body.id === 'bash-9' ? '' : `output-of-${body.id}`,
|
||||
text: body.id === 'bash-9' ? '' : `output-of-${body.id ?? ''}`,
|
||||
truncated: false,
|
||||
read: body.id !== 'bash-9',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (method === 'jobs.kill') {
|
||||
killCalls.push({ sessionId: body.sessionId, id: body.id })
|
||||
killCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' })
|
||||
return jsonResponse({ ok: true, value: { ok: true, outcome: 'requested' } })
|
||||
}
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Component-level regression for the Subagent page live preview polling:
|
||||
* one shared `subagents.live` request per refresh, never a per-child
|
||||
* `subagents.history` call, and never a second in-flight request while a
|
||||
* slow host is still answering.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { SubagentView } from '../src/client/SubagentView.tsx'
|
||||
import type { Context, SidebarSessionList } from '../src/context-types.ts'
|
||||
|
||||
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
|
||||
function makeStore(initial: SidebarSessionList) {
|
||||
let snapshot = initial
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (fn: () => void) => {
|
||||
listeners.add(fn)
|
||||
return () => { listeners.delete(fn) }
|
||||
},
|
||||
set(next: SidebarSessionList) {
|
||||
snapshot = next
|
||||
for (const fn of [...listeners]) fn()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Store = ReturnType<typeof makeStore>
|
||||
|
||||
/** The client context face SubagentView touches; history is spied for the
|
||||
* regression assertion (the new page must never call it). */
|
||||
function makeCtx(store: Store, historySpy: ReturnType<typeof vi.fn>): Context {
|
||||
return {
|
||||
sessions: {
|
||||
list: store,
|
||||
setSubagentCatalogOpen: () => {},
|
||||
openSubagent: () => {},
|
||||
open: () => {},
|
||||
refreshSubagents: async () => {},
|
||||
},
|
||||
connection: {
|
||||
api: {
|
||||
sessions: { history: historySpy },
|
||||
subagents: { history: historySpy },
|
||||
},
|
||||
},
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
/** Render `node` into a detached body container under React's act(). */
|
||||
function mount(node: ReactNode): {
|
||||
container: HTMLDivElement
|
||||
rerender: (next: ReactNode) => void
|
||||
unmount: () => void
|
||||
} {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
const rerender = (next: ReactNode): void => { act(() => { root.render(next) }) }
|
||||
act(() => { root.render(node) })
|
||||
const unmount = (): void => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
}
|
||||
return { container, rerender, unmount }
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return { ok: true, status: 200, json: async () => value } as unknown as Response
|
||||
}
|
||||
|
||||
/** A topology snapshot with two running direct subagents and a ready catalog. */
|
||||
function runningSnapshot(): SidebarSessionList {
|
||||
return {
|
||||
current: 'root',
|
||||
byId: {
|
||||
root: { id: 'root', displayTitle: '主会话' },
|
||||
a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'root', running: true },
|
||||
b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'root', running: true },
|
||||
},
|
||||
subagentsByParent: {
|
||||
root: {
|
||||
entries: [
|
||||
{ kind: 'child', id: 'a', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'A' },
|
||||
{ kind: 'child', id: 'b', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'B' },
|
||||
],
|
||||
parentAvailable: true,
|
||||
state: 'ready',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
jobsBySession: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** The same tree re-rooted under a new ancestor ('grand' becomes the root). */
|
||||
function reRootedSnapshot(): SidebarSessionList {
|
||||
return {
|
||||
current: 'root',
|
||||
byId: {
|
||||
grand: { id: 'grand', displayTitle: '主会话' },
|
||||
root: { id: 'root', displayTitle: 'R', origin: 'subagent', parentId: 'grand', running: true },
|
||||
a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'root', running: true },
|
||||
b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'root', running: true },
|
||||
},
|
||||
subagentsByParent: {
|
||||
grand: {
|
||||
entries: [
|
||||
{ kind: 'child', id: 'root', activity: 'running', hasChildren: true, mode: 'continuable', label: 'R' },
|
||||
],
|
||||
parentAvailable: true,
|
||||
state: 'ready',
|
||||
error: null,
|
||||
},
|
||||
root: {
|
||||
entries: [
|
||||
{ kind: 'child', id: 'a', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'A' },
|
||||
{ kind: 'child', id: 'b', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'B' },
|
||||
],
|
||||
parentAvailable: true,
|
||||
state: 'ready',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
jobsBySession: {},
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
for (const el of document.querySelectorAll('body > div')) el.remove()
|
||||
})
|
||||
|
||||
describe('SubagentView live polling', () => {
|
||||
it('sends one subagents.live per poll and never calls per-child history', async () => {
|
||||
vi.useFakeTimers()
|
||||
const historySpy = vi.fn()
|
||||
const liveCalls: string[] = []
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
if (method === 'subagents.live') {
|
||||
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
return jsonResponse({ ok: true, value: { live: {} } })
|
||||
}
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
})
|
||||
|
||||
const store = makeStore(runningSnapshot())
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
|
||||
)
|
||||
// Initial load plus the first scheduled tick.
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
expect(historySpy).not.toHaveBeenCalled()
|
||||
expect(container.textContent).toContain('思考中…')
|
||||
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
|
||||
expect(liveCalls).toEqual(['root', 'root'])
|
||||
expect(historySpy).not.toHaveBeenCalled()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('never starts a second request while one is in flight', async () => {
|
||||
vi.useFakeTimers()
|
||||
const historySpy = vi.fn()
|
||||
const liveCalls: string[] = []
|
||||
let resolveFirst: ((response: Response) => void) | undefined
|
||||
vi.stubGlobal('fetch', (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
if (method === 'subagents.live') {
|
||||
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
return new Promise<Response>((resolve) => { resolveFirst = resolve })
|
||||
}
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
})
|
||||
|
||||
const store = makeStore(runningSnapshot())
|
||||
const { unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
expect(resolveFirst).toBeTypeOf('function')
|
||||
|
||||
// While the first request is pending, no timer can fire a second one.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
|
||||
// Settle the first request; only then does the next 3s tick fire.
|
||||
await act(async () => {
|
||||
resolveFirst?.(jsonResponse({ ok: true, value: { live: {} } }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
|
||||
expect(liveCalls).toEqual(['root', 'root'])
|
||||
expect(historySpy).not.toHaveBeenCalled()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('stops polling and drops the in-flight request when the page hides', async () => {
|
||||
vi.useFakeTimers()
|
||||
const historySpy = vi.fn()
|
||||
const liveCalls: string[] = []
|
||||
let resolveFirst: ((response: Response) => void) | undefined
|
||||
vi.stubGlobal('fetch', (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
if (method === 'subagents.live') {
|
||||
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
return new Promise<Response>((resolve) => { resolveFirst = resolve })
|
||||
}
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
})
|
||||
|
||||
const store = makeStore(runningSnapshot())
|
||||
const { rerender, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
|
||||
// Hide the page: the effect cleanup aborts the in-flight request and no
|
||||
// timer may fire afterwards.
|
||||
rerender(createElement(SubagentView, { sessionId: 'root', active: false, ctx: makeCtx(store, historySpy) }))
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
|
||||
// Settling the stale response must neither render nor schedule a poll.
|
||||
await act(async () => {
|
||||
resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: { text: 'stale' } } } }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
expect(historySpy).not.toHaveBeenCalled()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('clears the live map and re-polls when the topology root changes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const historySpy = vi.fn()
|
||||
const liveCalls: string[] = []
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
if (method === 'subagents.live') {
|
||||
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
const live = body.rootSessionId === 'root' ? { a: { text: 'hello' } } : {}
|
||||
return jsonResponse({ ok: true, value: { live } })
|
||||
}
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
})
|
||||
|
||||
const store = makeStore(runningSnapshot())
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
expect(container.textContent).toContain('hello')
|
||||
|
||||
// The tree is re-rooted under a new ancestor: old rows must not leak.
|
||||
store.set(reRootedSnapshot())
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(liveCalls).toEqual(['root', 'grand'])
|
||||
expect(container.textContent).not.toContain('hello')
|
||||
expect(historySpy).not.toHaveBeenCalled()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Host route tests for the Subagent live-preview batch API ('subagents.live').
|
||||
* The route must enumerate the tree ONCE, keep only catalog-running
|
||||
* non-Side-Chat children, fold only non-empty activity from their session
|
||||
* logs, and degrade to a 503 when the host subagent runtime is absent.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { buildSubagentLiveApi } from '../src/subagent-live-route.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
import type {
|
||||
Context,
|
||||
SidebarSessionEvent,
|
||||
SidebarSubagentDescendantEntry,
|
||||
SidebarSubagentsService,
|
||||
} from '../src/context-types.ts'
|
||||
|
||||
/** One child descendant row. */
|
||||
function child(
|
||||
id: string,
|
||||
over: Partial<Extract<SidebarSubagentDescendantEntry, { kind: 'child' }>> = {},
|
||||
): SidebarSubagentDescendantEntry {
|
||||
return {
|
||||
kind: 'child',
|
||||
id,
|
||||
activity: 'running',
|
||||
hasChildren: false,
|
||||
mode: 'one-shot',
|
||||
parentId: 'root',
|
||||
depth: 1,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
/** One diagnostic descendant row. */
|
||||
function diagnostic(id: string): SidebarSubagentDescendantEntry {
|
||||
return { kind: 'diagnostic', id, reason: 'corrupt', parentId: 'root', depth: 1 }
|
||||
}
|
||||
|
||||
/** A session with the given raw events. */
|
||||
function session(events: SidebarSessionEvent[]): { header: { cwd: string }; events: SidebarSessionEvent[] } {
|
||||
return { header: { cwd: '/p' }, events }
|
||||
}
|
||||
|
||||
/** A context whose `get` serves only the subagents face, with a session store. */
|
||||
function ctxWith(subagents: unknown, sessions: unknown): Context {
|
||||
return {
|
||||
sessions,
|
||||
get: (key: string) => (key === 'subagents' ? subagents : undefined),
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
describe('subagents.live route', () => {
|
||||
it('returns non-empty activity for running children only', async () => {
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [
|
||||
child('running-a', { label: 'A' }),
|
||||
child('running-b', { label: 'B' }),
|
||||
child('inactive', { activity: 'inactive', label: 'C' }),
|
||||
child('side-chat', { label: 'Side: chat' }),
|
||||
diagnostic('corrupt-row'),
|
||||
]),
|
||||
}
|
||||
const sessions = {
|
||||
get: (id: string) => {
|
||||
if (id === 'running-a') {
|
||||
return session([
|
||||
{ type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'hello' }] } } },
|
||||
])
|
||||
}
|
||||
if (id === 'running-b') {
|
||||
return session([
|
||||
{ type: 'tool/call', seq: 0, time: 0, data: { name: 'bash', arguments: '{"command":"ls"}' } },
|
||||
])
|
||||
}
|
||||
return session([])
|
||||
},
|
||||
}
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({
|
||||
live: {
|
||||
'running-a': { text: 'hello' },
|
||||
'running-b': { tool: { name: 'bash', args: '{"command":"ls"}' } },
|
||||
},
|
||||
})
|
||||
expect(subagents.listDescendants).toHaveBeenCalledWith('root')
|
||||
})
|
||||
|
||||
it('omits children with no text/tool yet', async () => {
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [child('empty', { label: 'Empty' })]),
|
||||
}
|
||||
const sessions = { get: () => session([]) }
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({ live: {} })
|
||||
})
|
||||
|
||||
it('folds only activity inside the recent 12-message window', async () => {
|
||||
// One stale tool call sits before 13 user messages; the recent window
|
||||
// (the tail's last 12 messages) must not surface it.
|
||||
const staleTool: SidebarSessionEvent = {
|
||||
type: 'tool/call', seq: 0, time: 0,
|
||||
data: { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' },
|
||||
}
|
||||
const oldMessages: SidebarSessionEvent[] = Array.from({ length: 13 }, (_, i) => ({
|
||||
type: 'user/message', seq: i + 1, time: 0,
|
||||
data: { content: [{ type: 'text', text: `m${i}` }] },
|
||||
}))
|
||||
const recentText: SidebarSessionEvent = {
|
||||
type: 'assistant/message', seq: 99, time: 0,
|
||||
data: { message: { content: [{ type: 'text', text: 'recent' }] } },
|
||||
}
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [child('windowed', { label: 'W' })]),
|
||||
}
|
||||
const sessions = { get: () => session([staleTool, ...oldMessages, recentText]) }
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({
|
||||
live: { windowed: { text: 'recent' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('skips a child whose session log is unavailable without failing the batch', async () => {
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [
|
||||
child('good', { label: 'Good' }),
|
||||
child('bad', { label: 'Bad' }),
|
||||
]),
|
||||
}
|
||||
const sessions = {
|
||||
get: (id: string) => {
|
||||
if (id === 'good') {
|
||||
return session([
|
||||
{ type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'ok' }] } } },
|
||||
])
|
||||
}
|
||||
throw new Error('missing')
|
||||
},
|
||||
}
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({
|
||||
live: { good: { text: 'ok' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('degrades to a 503 when the subagent runtime is absent', async () => {
|
||||
const api = buildSubagentLiveApi(ctxWith(undefined, { get: () => undefined }))
|
||||
await expect(api.live({ rootSessionId: 'root' })).rejects.toThrowError(
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'subagents-unavailable', status: 503 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('degrades to a 503 when listDescendants fails', async () => {
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => { throw new Error('projection unavailable') }),
|
||||
}
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, { get: () => undefined }))
|
||||
await expect(api.live({ rootSessionId: 'root' })).rejects.toThrowError(
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'subagents-unavailable', status: 503 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a missing rootSessionId as bad-request', async () => {
|
||||
const api = buildSubagentLiveApi(ctxWith(undefined, { get: () => undefined }))
|
||||
await expect(api.live({})).rejects.toThrowError(
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user