/** * 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_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; 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 type Handle = { agent: AgentLike; dispose: () => Promise } /** The standard services the happy paths need. */ function happyServices(parent: AgentLike | undefined, child: AgentLike) { const create = vi.fn(async (_options: unknown): Promise => ({ agent: child, dispose: vi.fn(async () => {}), })) const resume = vi.fn(async (_options: unknown): Promise => ({ 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 }> => ({ 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 = { ...(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 = {}): { type: string; seq: number; time: number; data: Record; 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 seed: readonly { type: string; data: Record }[] 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: '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 }), ], }) 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 }> } 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 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('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.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 => ({ 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> { 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-better-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 }) }) })