Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output

This commit is contained in:
Tianyi Cui
2026-07-22 17:05:38 +08:00
482 changed files with 37413 additions and 1133 deletions
@@ -0,0 +1,94 @@
/**
* Cold-session and degenerate-composition paths of the host ApiProxy:
* sessions.list merging persisted-but-unattached summaries (mtime source,
* createdAt fallbacks, lineage projection) and the resume error split when
* the composition has no persistence gate and no agent factory.
*/
import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
const sid = (id: string): SessionId => id as SessionId
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`cold-${String(nextRpc++)}`), payload }
}
function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {}): SessionHeader {
return { version: 0, id: sid(id), createdAt, cwd: '/proj', ...extra }
}
describe('sessions.list cold merge', () => {
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
const logPath = join(root, 'a.log')
writeFileSync(logPath, 'log-bytes')
utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below
const metas = [
header('session-a', 1000),
header('session-b', 2000, { parentSession: sid('session-parent') }),
header('session-c', 1500),
]
// Structural fake of the persistence face list() consumes: list + locate.
// locate: a real per-session file (mtime wins), a backend without one
// (SQLite shape → createdAt), and a path whose file vanished (stat ENOENT
// → createdAt).
ctx.provide('sessionPersistence', {
list: () => Promise.resolve(metas),
locate: (meta: SessionHeader) => {
if (meta.id === sid('session-a')) return { kind: 'jsonl', path: logPath }
if (meta.id === sid('session-c')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
return undefined
},
})
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const response = await api.sessions.list(request({}))
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
const items = response.result.value.items
expect(items.map(item => item.sessionId)).toEqual(['session-a', 'session-b', 'session-c'])
const [a, b, c] = items
expect(a?.updatedAt).toBeCloseTo(5_000_000, -3)
expect(a?.running).toBe(false)
expect(a?.cwd).toBe('/proj')
expect(a?.parentSessionId).toBeUndefined()
expect(b?.updatedAt).toBe(2000)
expect(b?.parentSessionId).toBe('session-parent')
expect(c?.updatedAt).toBe(1500)
})
})
describe('degenerate composition (no persistence, no factory)', () => {
it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const listed = await api.sessions.list(request({}))
expect(listed.result.ok).toBe(true)
if (listed.result.ok) expect(listed.result.value.items).toEqual([])
// No persistence → the servable gate passes silently; the factory-less
// registry then rejects resume, which is NOT a SessionNotFound.
const response = await api.sessions.history(request({ sessionId: sid('session-ghost') }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) {
expect(response.result.error.code).toBe('internal')
expect(response.result.error.message).toMatch(/resume failed for session "session-ghost"/)
}
})
})
@@ -0,0 +1,179 @@
/**
* Tool-card view computation over the mux live path: three standard card types
* arrive on the frame, a presenterless tool ships no view field, and a throwing
* presenter soft-falls to no view (the event still ships). Result pairing works
* both through the live open-call table and the backscan fallback after
* turn/end cleared it.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
const reply = (text: string): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])
function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'presentResult'>): ToolDefinition {
return defineContentToolFixture({
name,
description: `tool ${name}`,
parameters: {},
execute: () => reply(`ran:${name}`),
...presenters,
})
}
async function harness(): Promise<{ ctx: Context }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
ctx.tools.register(tool('gen', {
presentCall: () => ({ card: 'generic', title: 'gen call' }),
presentResult: (_args, result) => ({ card: 'generic', title: result.isError ? 'gen failed' : 'gen done' }),
}))
ctx.tools.register(tool('term', {
presentCall: args => ({ card: 'terminal', title: (args as { cmd?: string }).cmd ?? '' }),
presentResult: () => ({ card: 'terminal', output: 'done' }),
}))
ctx.tools.register(tool('diffy', {
presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }),
}))
ctx.tools.register(tool('plain', {}))
ctx.tools.register(tool('boom', {
presentCall: () => { throw new Error('presenter exploded') },
}))
return { ctx }
}
/** Drain frames from an open mux stream until `count` session/event frames arrived. */
async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: number, abort: AbortController): Promise<MuxFrame[]> {
const frames: MuxFrame[] = []
for await (const frame of iterable) {
frames.push(frame.payload)
if (frames.filter(f => f.type === 'session/event').length >= count) abort.abort()
}
return frames
}
describe('mux live view computation', () => {
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
const collected = collect(stream, 7, abort)
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-gen'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
const frames = await collected
const events = frames.filter(f => f.type === 'session/event')
const byCall = new Map(events
.filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
.map(f => [`${f.event.type}:${(f.event.data as { callId: string }).callId}`, f]))
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
// No presenter → the frame carries no view property at all.
expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false)
// Throwing presenter → soft-fall: event ships, no view.
expect(byCall.get('tool/call:c-boom')).toBeDefined()
expect('view' in (byCall.get('tool/call:c-boom') ?? {})).toBe(false)
// Result pairing through the live table: presentResult saw the call's args.
expect(byCall.get('tool/result:c-gen')?.view).toEqual({ for: 'result', view: { card: 'generic', title: 'gen done' } })
})
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const session = ctx.sessions.create()
// history resolves the agent first; a live structural stub is enough (only
// .session is read on this path).
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
// meta rides through to presentResult's ToolResult (the spread arm).
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-term'), content: [{ type: 'text', text: 'ok' }], isError: false, meta: { n: 1 } }, { surfaceOp: 'append' })
// Unpaired result: no tool/call with this id anywhere in the page.
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-orphan'), content: [{ type: 'text', text: 'x' }], isError: false }, { surfaceOp: 'append' })
// Paired, but the call's stored arguments do not parse: backscan soft-falls.
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-bad'), content: [{ type: 'text', text: 'y' }], isError: false }, { surfaceOp: 'append' })
// Presenterless tool: pairing succeeds but presentResult is absent.
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-plain'), content: [{ type: 'text', text: 'z' }], isError: false }, { surfaceOp: 'append' })
const response = await api.sessions.history({ rpcId: RpcId('t-hist'), payload: { sessionId: session.id } })
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
const entries = response.result.value.events
const byKey = new Map(entries
.filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
.map(entry => [`${entry.event.type}:${(entry.event.data as { callId: string }).callId}`, entry]))
expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false)
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
})
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
let session: Session | undefined
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create('session-doomed' as SessionId)
}, { inject: ['sessions'] }))
session?.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session?.append('tool/call', { turn: 1, step: 1, callId: CallId('c-doomed'), name: 'term', arguments: '{"cmd":"x"}' })
// Disposing the owning fiber detaches the session mid-stream; the
// session/disposed listener must clear its open-call table entry.
await fiber.dispose()
const frames = await collect(stream, 2, abort)
const call = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/call')
expect(call?.type === 'session/event' && call.view?.for).toBe('call')
})
it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
const collected = collect(stream, 4, abort)
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// The turn/end above cleared the live table; pairing must fall back to
// scanning the session's in-memory events.
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-late'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
const frames = await collected
const result = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/result')
expect(result?.type === 'session/event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
})
})
@@ -0,0 +1,367 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { HostFrame, MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { bootHost, startHost, type HostHandle, type RunningHost } from '../src/index.ts'
/** Scripted adapter: each model call consumes the next chunk list; 'hang' streams then waits for abort. */
class ScriptedAdapter extends LlmAdapter {
constructor(private script: (StreamChunk[] | 'hang')[]) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const entry = this.script.shift()
if (!entry) throw new Error('ScriptedAdapter: script exhausted')
if (entry === 'hang') {
yield { type: 'block-start', index: 0, blockType: 'text' }
await new Promise<void>((_resolve, reject) => {
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
})
return
}
yield * entry
}
}
function textResponse(text: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text },
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
{ type: 'finish', reason: { kind: 'stop' } },
]
}
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
}
let nextRpc = 1
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject: Agent, status: string) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
function expectOk<T>(response: RpcResponse<T>): T {
expect(response.result.ok).toBe(true)
if (!response.result.ok) throw new Error('unreachable')
return response.result.value
}
let host: RunningHost | undefined
beforeEach(() => {
vi.stubEnv('DEEPSEEK_API_KEY', 'spec-placeholder-key')
})
afterEach(async () => {
await host?.dispose()
host = undefined
vi.unstubAllEnvs()
})
async function boot(script: (StreamChunk[] | 'hang')[] = []): Promise<RunningHost> {
host = await startHost({
boot: { persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')), provider: 'scripted', model: 'test-model' },
})
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter(script))
return host
}
describe('bootHost / startHost', () => {
it('falls back to the deepseek defaults and disposes idempotently', async () => {
const handle: HostHandle = await bootHost({ persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-')) })
expect(handle.defaults).toMatchObject({ provider: 'deepseek', model: 'deepseek-v4-flash' })
expect(typeof handle.defaults.cwd).toBe('string')
await handle.dispose()
})
it('startHost assembles api + handler over the same defaults and dedupes dispose', async () => {
const running = await boot()
expect(running.defaults).toMatchObject({ provider: 'scripted', model: 'test-model' })
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-h', method: 'host.describe', payload: {} })
const response = await running.handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', body }))
const parsed = await response.json() as { result: { ok: boolean; value: { provider: string } } }
expect(parsed.result.value.provider).toBe('scripted')
const first = running.dispose()
expect(running.dispose()).toBe(first)
await first
host = undefined
})
})
describe('host.describe', () => {
it('reports version, cwd, defaults, and the attached count', async () => {
const { api } = await boot()
const value = expectOk(await api.host.describe(request({})))
expect(value).toMatchObject({ version: '0.0.1', cwd: process.cwd(), provider: 'scripted', model: 'test-model', attachedSessions: 0 })
})
})
describe('sessions.create / list', () => {
it('creates a session (echoing the request rpcId) and lists it newest-first', async () => {
const { api } = await boot()
const created = await api.sessions.create(request({ cwd: '/tmp' }))
const { sessionId } = expectOk(created)
expect(created.rpcId).toMatch(/^req-/)
const second = expectOk(await api.sessions.create(request({}))).sessionId
const { items } = expectOk(await api.sessions.list(request({})))
expect(items.map(item => item.sessionId)).toContain(sessionId)
expect(items.map(item => item.sessionId)).toContain(second)
const first = items.find(item => item.sessionId === sessionId)
expect(first?.cwd).toBe('/tmp')
expect(first?.running).toBe(false)
expect(first?.parentSessionId).toBeUndefined()
})
})
describe('sessions.prompt / cancel', () => {
it('queues a prompt whose rpcId rides into user/message, then the reply lands', async () => {
const running = await boot([textResponse('pong')])
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId)
expect(agent).toBeDefined()
const idle = waitForIdle(ctx, agent as Agent)
const promptRequest = request({ sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'ping' }] })
expectOk(await api.sessions.prompt(promptRequest))
await idle
const value = expectOk(await api.sessions.history(request({ sessionId })))
const events = value.events.map(entry => entry.event)
const userEvent = events.find(event => event.type === 'user/message') as
| { data: { source?: { rpcId?: string } } } | undefined
expect(userEvent?.data.source?.rpcId).toBe(promptRequest.rpcId)
const reply = events.find(event => event.type === 'assistant/message')
expect(reply).toBeDefined()
})
it('steer on an idle agent falls through to send', async () => {
const running = await boot([textResponse('steered')])
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const idle = waitForIdle(ctx, ctx.agents.get(sessionId) as Agent)
expectOk(await api.sessions.prompt(request({ sessionId, mode: 'steer' as const, content: [{ type: 'text' as const, text: 'now' }] })))
await idle
})
it('errors session-not-found on a ghost session', async () => {
const { api } = await boot()
const response = await api.sessions.prompt(request({ sessionId: 'session-void' as SessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
})
it('maps a synchronous send throw to agent-busy', async () => {
const { api } = await boot()
const { sessionId } = expectOk(await api.sessions.create(request({})))
const poisoned = [{ type: 'text', text: 'x', bad: () => 1 }] as never
const response = await api.sessions.prompt(request({ sessionId, mode: 'queue' as const, content: poisoned }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('agent-busy')
})
it('cancels an attached agent and rejects an unattached one', async () => {
const running = await boot(['hang'])
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
agent.send([{ type: 'text', text: 'run forever' }])
expectOk(await api.sessions.cancel(request({ sessionId })))
const missing = await api.sessions.cancel(request({ sessionId: 'session-none' as SessionId }))
expect(missing.result.ok).toBe(false)
if (!missing.result.ok) expect(missing.result.error.code).toBe('session-not-found')
})
})
describe('sessions.history', () => {
it('implicitly resumes a cold session, deduplicating concurrent calls to one attach', async () => {
const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-host-resume-'))
const first = await startHost({ boot: { persistenceRoot, provider: 'scripted', model: 'test-model' } })
first.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('persisted')]))
const { sessionId } = expectOk(await first.api.sessions.create(request({})))
const agent = first.ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(first.ctx, agent)
agent.send([{ type: 'text', text: 'save me' }])
await idle
await first.dispose()
host = await startHost({ boot: { persistenceRoot, provider: 'scripted', model: 'test-model' } })
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([]))
expect(host.ctx.agents.get(sessionId)).toBeUndefined()
const [a, b] = await Promise.all([
host.api.sessions.history(request({ sessionId })),
host.api.sessions.history(request({ sessionId })),
])
for (const response of [a, b]) {
const value = expectOk(response)
expect(value.events.some(entry => entry.event.type === 'assistant/message')).toBe(true)
}
expect(host.ctx.agents.get(sessionId)).toBeDefined()
expect(host.ctx.agents.list()).toHaveLength(1)
})
it('errors session-not-found when resume fails, deduplicating concurrent resumes', async () => {
const { api } = await boot()
const ghost = 'session-ghost' as SessionId
const [first, second] = await Promise.all([
api.sessions.history(request({ sessionId: ghost })),
api.sessions.history(request({ sessionId: ghost })),
])
for (const response of [first, second]) {
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
}
})
it('paginates backwards on message boundaries with hasMore', async () => {
const running = await boot([textResponse('a1'), textResponse('a2'), textResponse('a3')])
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const agent = ctx.agents.get(sessionId) as Agent
for (const text of ['q1', 'q2', 'q3']) {
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text }])
await idle
}
const all = expectOk(await api.sessions.history(request({ sessionId })))
expect(all.hasMore).toBe(false)
const messageCount = all.events.filter(entry => entry.event.type === 'user/message' || entry.event.type === 'assistant/message').length
expect(messageCount).toBe(6)
const lastPage = expectOk(await api.sessions.history(request({ sessionId, maxMessages: 1 })))
expect(lastPage.hasMore).toBe(true)
expect(lastPage.events.filter(entry => entry.event.type === 'assistant/message')).toHaveLength(1)
expect(lastPage.events.filter(entry => entry.event.type === 'user/message')).toHaveLength(0)
const firstSeq = lastPage.events[0]?.event.seq as number
const olderPage = expectOk(await api.sessions.history(request({ sessionId, beforeSeq: firstSeq, maxMessages: 2 })))
expect(olderPage.events.at(-1)?.event.seq).toBeLessThan(firstSeq)
expect(olderPage.hasMore).toBe(true)
expect(olderPage.events.filter(entry => entry.event.type === 'user/message' || entry.event.type === 'assistant/message').length).toBe(2)
})
})
describe('events streams', () => {
it('mux: a pending pull wakes when a frame arrives (waiter path)', async () => {
const running = await boot()
const { api } = running
const ac = new AbortController()
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
// no sessions yet: next() must pend on the queue's waiter, not the buffer
const pending = stream.next()
const { sessionId } = expectOk(await api.sessions.create(request({})))
const frame = (await pending).value as RpcRequest<MuxFrame>
expect(frame.payload).toMatchObject({ type: 'session/subscribed', sessionId })
ac.abort()
expect((await stream.next()).done).toBe(true)
})
it('lists fork lineage and announces it on the host stream', async () => {
const running = await boot()
const { api, ctx } = running
const { sessionId: parent } = expectOk(await api.sessions.create(request({})))
const ac = new AbortController()
const stream = api.events.host(request({}), ac.signal)[Symbol.asyncIterator]()
const child = `session-child-${String(Date.now())}` as SessionId
const handle = await ctx.agents.create({ sessionId: child, meta: { parentSession: parent }, agentOptions: { provider: 'scripted', model: 'test-model' } })
expect(handle.agent.id).toBe(child)
const added = (await stream.next()).value as RpcRequest<HostFrame>
expect(added.payload).toMatchObject({ type: 'host/session-added', sessionId: child, parentSessionId: parent })
const { items } = expectOk(await api.sessions.list(request({})))
expect(items.find(item => item.sessionId === child)?.parentSessionId).toBe(parent)
await handle.dispose()
let frame: RpcRequest<HostFrame>
do frame = (await stream.next()).value as RpcRequest<HostFrame>
while (frame.payload.type !== 'host/session-removed')
expect(frame.payload).toMatchObject({ type: 'host/session-removed', sessionId: child })
ac.abort()
})
it('mux: emits subscribed baselines, live session events, and new-session subscriptions until abort', async () => {
const running = await boot([textResponse('live')])
const { api, ctx } = running
const { sessionId } = expectOk(await api.sessions.create(request({})))
const ac = new AbortController()
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
const baseline = await stream.next()
expect((baseline.value as RpcRequest<MuxFrame>).payload).toMatchObject({ type: 'session/subscribed', sessionId })
const agent = ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'go' }])
await idle
const live = await stream.next()
expect((live.value as RpcRequest<MuxFrame>).payload.type).toBe('session/event')
const other = expectOk(await api.sessions.create(request({}))).sessionId
let frame: RpcRequest<MuxFrame>
do frame = (await stream.next()).value as RpcRequest<MuxFrame>
while (!(frame.payload.type === 'session/subscribed' && frame.payload.sessionId === other))
ac.abort()
expect((await stream.next()).done).toBe(true)
})
it('host: session lifecycle, status flips (disposed suppressed), and agent errors', async () => {
const running = await boot([textResponse('x')])
const { api, ctx } = running
const ac = new AbortController()
const stream = api.events.host(request({}), ac.signal)[Symbol.asyncIterator]()
const { sessionId } = expectOk(await api.sessions.create(request({})))
const added = await stream.next()
expect((added.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-added', sessionId })
const agent = ctx.agents.get(sessionId) as Agent
const idle = waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'run' }])
await idle
const runningFrame = await stream.next()
expect((runningFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: true })
const idleFrame = await stream.next()
expect((idleFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: false })
// Raw ctx.emit lacks the scope carrier the mounted invariants plugin now
// enforces; dispatch the way the loop does.
agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('boom'))
const errorFrame = await stream.next()
expect((errorFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/agent-error', message: 'Error: boom' })
ac.abort()
// Push-after-done: an event landing between abort and generator wind-down
// must be dropped silently, not crash the queue.
agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('late'))
expect((await stream.next()).done).toBe(true)
})
})
describe('respond stub', () => {
it('always reports not-pending (step2 registry pending)', async () => {
const { api } = await boot()
const receipt = await api.respond({ type: 'client-response', rpcId: RpcId('r'), result: { ok: true, value: null } })
expect(receipt).toEqual({ accepted: false, reason: 'not-pending' })
})
})
@@ -0,0 +1,71 @@
/**
* Web UI plugin assembly: the in-memory Loader tree mounts all eight UI
* packages (node halves), and the webserver registry built over it yields the
* full __DSH_BOOT__ manifest — the P-I config-source bar end to end.
*
* The Loader imports plugin packages through their exports maps (lib/), so
* this is a built-artifact e2e: it skips until the workspace build has run
* (`pnpm run build`), like the other built-* e2e suites.
*/
import { existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { createHostWebPluginRegistry } from '@deepseek-ai/dsh-host-webserver'
import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
const nodeRequire = createRequire(import.meta.url)
const built = WEB_UI_PLUGINS.every((name) => {
try {
return existsSync(nodeRequire.resolve(name))
} catch {
return false
}
})
let root: Context | undefined
afterEach(async () => {
await root?.fiber.dispose()
root = undefined
})
describe.skipIf(!built)('mountWebPlugins + registry', () => {
it('mounts the eight-package in-memory Loader tree and projects the boot manifest', async () => {
root = new Context()
const mounted = await mountWebPlugins(root)
const registry = createHostWebPluginRegistry({
ctx: root,
loader: mounted.loader,
resolvePkgJson: mounted.resolvePkgJson,
onError: (err) => { throw err },
})
const rows = registry.snapshot()
expect(rows.map(r => r.id)).toEqual([...WEB_UI_PLUGINS])
// The infra four are the early-load group; the UI four are not.
const immediate = rows.filter(r => r.immediately === true).map(r => r.id)
expect(immediate).toEqual([
'@deepseek-ai/dsh-client-connection',
'@deepseek-ai/dsh-client-runtime',
'@deepseek-ai/dsh-client-ui-theme',
'@deepseek-ai/dsh-client-i18n',
])
// Every row resolves a client path under its own package dist/.
for (const row of rows) {
expect(registry.clientPath(row.id)).toMatch(/dist[/\\]client\.js$/)
expect(row.url).toBe(`/plugins/${row.id}/client.js`)
}
registry.dispose()
})
it('is idempotent: a second mount reuses the loader and creates no duplicate entries', async () => {
root = new Context()
await mountWebPlugins(root)
const second = await mountWebPlugins(root)
// ctx.loader hands out a fresh traced proxy per access, so loader identity
// is not assertable; the observable contract is a single entry per package.
const names = [...second.loader.entries()].map(e => e.options.name)
.filter(n => (WEB_UI_PLUGINS as readonly string[]).includes(n))
expect(names.length).toBe(WEB_UI_PLUGINS.length)
})
})
@@ -0,0 +1,114 @@
/**
* mountWebPlugins unit coverage (keyless; the real eight-package walk is the
* built-artifact e2e). The Loader-facing behavior — baseUrl anchoring, entry
* creation with idempotent reuse, the fiber-less fail-loud sweep, and the
* resolver seam — is exercised against a stubbed loader service so it runs
* without built lib/ artifacts.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it } from 'vitest'
import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
interface FakeEntry {
options: { name: string }
fiber?: unknown
disabled: boolean
}
/** Loader stub provided under the real service name (mountWebPlugins skips ctx.plugin(Loader) when present). */
class FakeLoader {
readonly created: string[] = []
awaited = 0
constructor(private readonly entriesList: FakeEntry[], private readonly onCreate?: (name: string) => void) {}
entries(): Iterable<FakeEntry> {
return this.entriesList
}
async create(options: { name: string }): Promise<void> {
this.created.push(options.name)
this.onCreate?.(options.name)
}
async await(): Promise<void> {
this.awaited += 1
}
}
let root: Context | undefined
afterEach(async () => {
await root?.fiber.dispose()
root = undefined
})
function withLoader(entriesList: FakeEntry[], onCreate?: (name: string) => void): { ctx: Context; loader: FakeLoader } {
root = new Context()
const loader = new FakeLoader(entriesList, onCreate)
root.reflect.provide('loader', loader)
return { ctx: root, loader }
}
describe('mountWebPlugins (stubbed loader)', () => {
it('creates one entry per UI plugin, awaits the tree, and returns the loader view + resolver', async () => {
const entriesList: FakeEntry[] = []
const { ctx, loader } = withLoader(entriesList, (name) => {
entriesList.push({ options: { name }, fiber: {}, disabled: false })
})
const mounted = await mountWebPlugins(ctx)
expect(loader.created).toEqual([...WEB_UI_PLUGINS])
expect(loader.awaited).toBe(1)
expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...WEB_UI_PLUGINS])
// The resolver resolves this package's own manifest through real module resolution.
expect(mounted.resolvePkgJson('@deepseek-ai/dsh-host-runtime')).toMatch(/package\.json$/)
expect(ctx.baseUrl).toBeDefined()
})
it('reuses existing entries (idempotent mount creates no duplicates)', async () => {
const preexisting: FakeEntry[] = WEB_UI_PLUGINS.map(name => ({ options: { name }, fiber: {}, disabled: false }))
const { ctx, loader } = withLoader(preexisting)
await mountWebPlugins(ctx)
expect(loader.created).toEqual([])
})
it('throws listing every fiber-less entry (silent import failure must not drop a UI plugin)', async () => {
const entriesList: FakeEntry[] = []
const { ctx } = withLoader(entriesList, (name) => {
// First two load; the rest stay fiber-less (import failed silently).
entriesList.push({ options: { name }, fiber: entriesList.length < 2 ? {} : undefined, disabled: false })
})
await expect(mountWebPlugins(ctx)).rejects.toThrow(/UI plugin\(s\) failed to load: .*dsh-client-ui-theme/)
})
it('skips disabled entries in the fail-loud sweep (disabled is the one valid fiber-less state)', async () => {
const entriesList: FakeEntry[] = WEB_UI_PLUGINS.map(name => ({ options: { name }, fiber: undefined, disabled: true }))
const { ctx } = withLoader(entriesList)
await expect(mountWebPlugins(ctx)).resolves.toBeDefined()
})
it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
root = new Context()
// Environment-dependent outcome: with built lib/ the eight imports load
// and the mount resolves; without them every entry stays fiber-less and
// the sweep throws its loud list. Either way the branch under test is the
// Loader auto-mount. Manual try/catch keeps cordis-traced proxies out of
// expect()'s formatting path (pretty-format probes throw on them).
// Plain string: the success sentinel and error text share one channel.
let outcome: string
try {
await mountWebPlugins(root)
outcome = 'resolved'
} catch (error) {
outcome = error instanceof Error ? error.message : String(error)
}
expect(outcome === 'resolved' || /UI plugin\(s\) failed to load/.test(outcome)).toBe(true)
expect(root.get('loader') !== undefined).toBe(true)
}, 30_000) // built-env run imports eight real plugin packages through the Loader
it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
const entriesList: FakeEntry[] = []
const { ctx } = withLoader(entriesList, (name) => {
entriesList.push({ options: { name }, fiber: {}, disabled: false })
})
ctx.baseUrl = 'file:///caller/anchor/'
await mountWebPlugins(ctx)
expect(ctx.baseUrl).toBe('file:///caller/anchor/')
})
})