Files
apes-sidebar/tests/sidechat-routes.spec.ts
figmar f4c44d13d6
CI / ci (push) Canceled after 0s
CI / ci-windows (push) Canceled after 0s
CI / plugin-mount (push) Canceled after 0s
feat: align devDeps to 0.2.0-rc.2 (21 packages)
- Bump 21 @deepseek-ai devDeps from 0.1.5-rc.2 → 0.2.0-rc.2
  (dsh-code-runtime stays at 0.1.5-rc.2 — no 0.2.0-rc.2 published)
- Fix sidechat-routes.ts: source.kind 'plugin' → 'user'
  (0.2.0-rc.2 removed 'plugin' from MessageSourceMap)
- Remove unused SIDE_INJECTION_PLUGIN import
- Fix test fixtures for 0.2.0-rc.2 stricter validation:
  - tool/result message role: 'user' → 'tool'
  - add toolCallId at message top level
  - assertion kind: 'plugin' → 'user'
- Icon migration: Icon*14 → Icon*Regular, Icon*16 → Icon*Medium (19 files)
- SideChatView API migration to 0.2.0-rc.2
- Add 4 new locale keys (sideChatBlockNoExitCode, codeLabel, wrapLabel, unwrapLabel)
- intercept.tsx: guard against undefined matched in SidebarProducedFiles

Verification:
- pnpm run typecheck: PASS
- pnpm run build: PASS
- npx vitest run: 1015 passed | 0 failed | 9 skipped
2026-10-11 18:25:35 +08:00

495 lines
22 KiB
TypeScript

/**
* Host route tests for the Side Chat API ('sidechat.start' / 'sidechat.prompt'
* / 'sidechat.cancel' / 'sidechat.dispose' / 'sidechat.info' /
* 'sidechat.events'): 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, dispose, and the transcript reads (seed cut,
* afterSeq deltas, tail cap, live/persisted sources).
*/
import { describe, expect, it, vi } from 'vitest'
import { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
import { buildSidechatApi } from '../src/sidechat-routes.ts'
import { SidebarError } from '../src/wire.ts'
import { SIDE_BOUNDARY_PROMPT, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
import type { Context } from '../src/context-types.ts'
/** A fake live agent (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 },
snapshotEvents: () => 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 () => {})
/** The 0.1.5 persistence face: open(id, 'read') -> handle.read() -> close(). */
const open = vi.fn(async (_id?: string): Promise<{
header: { agentPreset?: string; cwd?: string }
inheritedEventCount: number
read: () => Promise<{ events: unknown[] }>
close: () => Promise<void>
}> => ({
header: { agentPreset: 'preset-a' },
inheritedEventCount: 0,
read: async () => ({ events: [] }),
close: async () => {},
}))
return {
agents: { get, create, resume },
agentPresets: { resolve, mount },
sessionTitle: { rename },
sessionPersistence: { open },
create,
resume,
get,
rename,
mount,
open,
}
}
/** 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> }[]
inheritedEventCount: number
agentOptions: { provider: string; model: string }
setup: unknown
}
expect(options.sessionId).toBe(result.childId)
expect(options.meta).toMatchObject({
parentSession: 'parent',
origin: 'subagent',
isSeeded: true,
delegationDepth: 1,
agentPreset: 'preset-a',
cwd: '/p',
})
// The fork-marker pair is load-bearing: without it the child's Inbox
// replays the parent's seed inbox splices and inherits whatever input sat
// unclaimed in the parent (see sidechat-seed-validation.spec.ts).
expect(options.inheritedEventCount).toBe(options.seed.length)
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({
// The descriptor version is stamped by the host package's
// snapshotSubagentDescriptor — follow it instead of pinning a literal
// (bumped 2 → 3 in DSH 0.1.2-alpha.2).
version: SUBAGENT_DESCRIPTOR_VERSION,
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: 'user' })
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 }),
],
})
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', '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: ' ' })
// The empty thread carries the same fork-marker pair (isSeeded +
// inheritedEventCount = seed length) as the prompted create.
const options = services.create.mock.calls[0]![0] as {
meta: Record<string, unknown>
seed: readonly { type: string }[]
inheritedEventCount: number
}
expect(options.meta.isSeeded).toBe(true)
expect(options.inheritedEventCount).toBe(options.seed.length)
// 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('user')
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.open).toHaveBeenCalledWith('child', 'read')
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.open).toHaveBeenCalledWith('child', 'read')
})
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.open = vi.fn(async () => { throw new Error('unknown session') })
services.sessionPersistence = { open: services.open }
const api = buildSidechatApi(ctxWith(services))
await expect(api['sidechat.info']({ childId: 'ghost' })).resolves.toEqual({ live: false })
})
})
/** A realistic thread log: the inherited seed, the end-seed marker, the
* descriptor the seed appends, and the thread's own conversation. */
function threadLog(): Array<ReturnType<typeof ev>> {
return [
ev('user/message', 0, { content: [{ type: 'text', text: 'inherited parent question' }], source: { kind: 'user' } }),
ev('turn/end', 1, { turn: 0, reason: { kind: 'completed' } }),
ev('session/end-seed', 2),
ev('subagent/descriptor', 3, { mode: 'continuable' }),
ev('user/message', 4, { content: [{ type: 'text', text: 'Side conversation boundary.' }], source: { kind: 'plugin', plugin: 'dsh-apes-sidebar' } }),
ev('user/message', 5, { content: [{ type: 'text', text: 'the side question' }], source: { kind: 'user' } }),
ev('assistant/attempt', 6, { turn: 1, step: 1, stream: [] }),
ev('assistant/attempt', 7, { turn: 1, step: 1, stream: [] }),
]
}
describe('sidechat.events', () => {
it('returns the thread-own slice from the LIVE agent log (seed cut host-side)', async () => {
const child = agent('child', { events: threadLog() })
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.events']({ childId: 'child' })
expect(result.events.map(event => event.seq)).toEqual([3, 4, 5, 6, 7])
expect(result.events[0]).toMatchObject({ type: 'subagent/descriptor', seq: 3 })
expect(services.open).not.toHaveBeenCalled()
})
it('narrows to the afterSeq delta on polls (and never resurrects the seed)', async () => {
const child = agent('child', { events: threadLog() })
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const delta = await api['sidechat.events']({ childId: 'child', afterSeq: 6 })
expect(delta.events.map(event => event.seq)).toEqual([7])
// An afterSeq below the boundary must still not return the inherited seed.
const fromZero = await api['sidechat.events']({ childId: 'child', afterSeq: 0 })
expect(fromZero.events.map(event => event.seq)).toEqual([3, 4, 5, 6, 7])
})
it('reads a COLD thread from persistence when no agent is live', async () => {
const child = agent('child')
const services = happyServices(undefined, child)
services.agents.get = vi.fn((_id: unknown) => undefined)
services.open.mockImplementation(async () => ({
header: { agentPreset: 'preset-a' },
inheritedEventCount: 0,
read: async () => ({ events: threadLog() }),
close: async () => {},
}))
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.events']({ childId: 'child' })
expect(result.events.map(event => event.seq)).toEqual([3, 4, 5, 6, 7])
expect(services.open).toHaveBeenCalledWith('child', 'read')
})
it('returns a marker-less legacy log whole', async () => {
const child = agent('child', {
events: [
ev('user/message', 0, { content: [{ type: 'text', text: 'legacy' }], source: { kind: 'user' } }),
ev('assistant/message', 1, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'a' }] } }),
],
})
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.events']({ childId: 'child' })
expect(result.events.map(event => event.seq)).toEqual([0, 1])
})
it('caps a pathological response at its tail (8000 events)', async () => {
const events = Array.from({ length: 8_003 }, (_, index) => ev('assistant/attempt', index, { turn: 1, step: 1, stream: [] }))
const child = agent('child', { events })
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.events']({ childId: 'child' })
expect(result.events).toHaveLength(8_000)
expect(result.events[0]).toMatchObject({ seq: 3 })
expect(result.events.at(-1)).toMatchObject({ seq: 8_002 })
})
it('rejects an invalid afterSeq and reports a missing thread as not-found', async () => {
const child = agent('child', { events: threadLog() })
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
await expect(api['sidechat.events']({ childId: 'child', afterSeq: -1 }))
.rejects.toMatchObject({ code: 'bad-request' })
await expect(api['sidechat.events']({ childId: 'child', afterSeq: 1.5 }))
.rejects.toMatchObject({ code: 'bad-request' })
services.agents.get = vi.fn((_id: unknown) => undefined)
services.open.mockImplementation(async () => { throw new Error('no such session') })
await expect(api['sidechat.events']({ childId: 'ghost' }))
.rejects.toMatchObject({ code: 'not-found', status: 404 })
})
it('fails loudly when a cold thread has no persistence service to read', async () => {
const services = happyServices(undefined, agent('child'))
const api = buildSidechatApi(ctxWith({
agents: { ...services.agents, get: vi.fn((_id: unknown) => undefined) },
}))
await expect(api['sidechat.events']({ childId: 'child' }))
.rejects.toMatchObject({ code: 'sidechat-error', status: 503 })
})
})