Files
apes-sidebar/tests/sidechat-routes.spec.ts
T
Atlas Zeng b7aab48deb feat(sidechat): Codex 风格转录重构 — 首问显示 / 注入拆分 / 折叠行 / 弹窗去抖 (#314)
* fix(sidechat): split context injection from the first user message

The first composer message rode the same user/message event as the side
boundary prompt (+ parked snapshot), and the transcript dropped that whole
row by its boundary prefix — swallowing the user's first question.

Deliver first contact as TWO messages: the boundary+snapshot rides
agent.inject (queued next-step context, claimed first at the opening step,
no wake) stamped with source kind 'plugin', and the question is the
follow-up that wakes the driver. The log records injection and question as
separate events; the transcript maps injections (plugin-sourced, or the
legacy boundary-prefix blob of pre-split threads) onto a collapsible
injection row and renders genuine user messages — the first one included —
as user rows. boundaryDelivered still recognizes the injection by its
unchanged boundary-prefix opening.

* feat(sidechat): codex-style collapsible rows and creating shimmer

Tool calls, thinking and context injections now share one quiet
single-line chrome (chevron + label + a one-line argument summary) that
expands into an indented body on a hairline thread — no cards or fills.
Streaming labels and the creating hero shimmer (shimmer = generating);
failed tools go danger; reduced-motion stills every loop.

* fix(sidebar): debounce subagent auto-open past the title frame

The host delivers a new child's origin and title in separate frames, so a
Side Chat thread's first visible frame lacks its 'Side: ' label and
tripped the 0→N auto-open — every thread creation popped the task page.
The trigger now rearms for 500ms and re-evaluates the original baseline
against the live snapshot, by which time the title filter recognizes the
thread. Genuine subagents still auto-open, half a second later.

* fix(sidechat): render legacy threads' first message as its own bubble

Threads created before the host split still log the boundary (+snapshot)
and the question as ONE 'user' message; the transcript mapped that row
wholly onto the injection row, so the user's first question only appeared
inside the expansion. The boundary prompt is a known constant, so the
legacy row now splits THERE: the injection row keeps the prompt, the
remainder renders as the user's real message (a pre-split snapshot, when
present, rides along — the common case is a pure question). Row React keys
become kind:seq so the split's two rows never collide.
2026-08-22 15:34:05 +08:00

362 lines
16 KiB
TypeScript

/**
* 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 })
})
})