Merge remote-tracking branch 'upstream/master' into feat/web-workspace-file-links

# Conflicts:
#	apps/web/tsconfig.json
#	packages/client/connection/README.i18n.yaml
#	packages/client/connection/README.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/ToolRow.module.css
#	packages/client/ui-conversation/src/client/chat/chat-flow.ts
#	packages/client/ui-conversation/tests/chat-view.spec.tsx
#	packages/host/apiproxy/src/native-path-opener.ts
#	tsconfig.host.json
This commit is contained in:
ZiyaZhang
2026-08-06 04:44:13 -07:00
3480 changed files with 101380 additions and 55152 deletions

View File

@@ -34,7 +34,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
/** A minimal agent stand-in inside an open turn (the service only reaches `.session`). */
function agentOf(ctx: Context): Agent {
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
return { session } as unknown as Agent
}
@@ -185,7 +185,7 @@ describe('approval pending registry', () => {
const abort = new AbortController()
const mux = openMux(api, abort)
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('approval/asked', { id: 'pre-aborted' as ApprovalRequestId, toolName: 'bash' })
const agent = { session } as unknown as Agent
const cancelled = new AbortController()
@@ -308,7 +308,7 @@ describe('approval pending registry', () => {
// Bypass ApprovalService: a log whose sole asked event already has its
// decided partner must not be re-claimed — the answerer delegates.
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
session.append('approval/asked', { id: 'stale-ask' as ApprovalRequestId, toolName: 'bash' })
session.append('approval/decided', { id: 'stale-ask' as ApprovalRequestId, outcome: 'rejected' })
const agent = { session } as unknown as Agent
@@ -322,7 +322,7 @@ describe('approval pending registry', () => {
// Bypass ApprovalService: dispatch the waterfall directly with a session
// that has no approval/asked event — the proxy answerer must call next().
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
const agent = { session } as unknown as Agent
const outcome = await ctx.waterfall('approval/request', { agent, toolName: 'x' }, () => Promise.resolve('unavailable' as const))
expect(outcome).toBe('unavailable')

View File

@@ -79,7 +79,7 @@ describe('summary blank = conversation not started', () => {
const session = ctx.sessions.create()
attach(session)
appendStandalone(session)
session.append('turn/start', { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 0 })
expect(await listBlank(api, session.id)).toBe(false)
})
})

View File

@@ -1,21 +1,26 @@
/**
* Cold-session and degenerate-composition paths of the host ApiProxy:
* sessions.list merging persisted-but-unattached summaries (mtime source,
* createdAt fallbacks, lineage projection), the resume error split when
* the composition has no persistence gate and no agent factory, and the
* agent-busy mapping of a synchronous prompt rejection.
* metadata-only listing, Agent-free history reads, subagent ownership
* isolation, and prompt failure mapping.
*/
import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { MessageId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import {
PersistenceCoordinator,
SessionPersistenceRevision,
type PersistenceBackend,
type StoredPrefix,
} from '@deepseek-ai/dsh-session-persistence'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
@@ -42,7 +47,7 @@ describe('sessions.list cold merge', () => {
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-b', 2000, { parentSession: sid('session-parent'), origin: 'subagent' }),
header('session-c', 1500),
]
// Structural fake of the persistence face list() consumes: list + locate.
@@ -74,6 +79,7 @@ describe('sessions.list cold merge', () => {
expect(a?.parentSessionId).toBeUndefined()
expect(b?.updatedAt).toBe(2000)
expect(b?.parentSessionId).toBe('session-parent')
expect(b?.origin).toBe('subagent')
expect(c?.updatedAt).toBe(1500)
})
})
@@ -90,7 +96,7 @@ describe('attached updatedAt excludes end-seed', () => {
const worked = 1_000_000
const resumed = ctx.sessions.create(sid('resumed-untouched'), {
seed: [
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: worked, data: { turn: 1, reason: { kind: 'completed' } } },
],
meta: { cwd: '/proj', createdAt: 500 },
@@ -106,7 +112,7 @@ describe('attached updatedAt excludes end-seed', () => {
expect(summary?.updatedAt).toBe(worked)
// Real work appended after end-seed does move it.
resumed.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
resumed.append('turn/start', { turn: 2 })
const after = await api.sessions.list(request({}))
if (!after.result.ok) throw new Error('list failed')
const moved = after.result.value.items.find(item => item.sessionId === 'resumed-untouched')
@@ -114,8 +120,220 @@ describe('attached updatedAt excludes end-seed', () => {
})
})
describe('cold history recovery view', () => {
it('shows in-memory interruption repair without activating the session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
const sessionId = sid('session-interrupted')
const meta = header(sessionId, 1000)
const stored: StoredPrefix<never> = {
meta,
events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }],
revision: SessionPersistenceRevision('history-recovery-test:1'),
}
const backend: PersistenceBackend<never> = {
name: 'history-recovery-test',
loadStored: id => Promise.resolve(id === sessionId ? structuredClone(stored) : undefined),
readStoredRevision: id => Promise.resolve(
id === sessionId ? SessionPersistenceRevision('history-recovery-test:1') : undefined,
),
appendBatch: () => Promise.resolve(),
commitRepair: () => Promise.resolve(),
list: () => Promise.resolve([structuredClone(meta)]),
}
const coordinator = new PersistenceCoordinator(ctx, backend)
ctx.provide('sessionPersistence', {
list: (signal?: AbortSignal) => backend.list(signal),
inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
locate: () => undefined,
} as never)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 }))
if (!history.result.ok) throw new Error('history failed')
expect(history.result.value.events.map(entry => entry.event)).toMatchInlineSnapshot(`
[
{
"data": {
"turn": 1,
},
"seq": 0,
"time": 1,
"type": "turn/start",
},
{
"data": {
"reason": {
"kind": "interrupted",
},
"turn": 1,
},
"seq": 1,
"time": 1,
"type": "turn/end",
},
]
`)
expect(ctx.sessions.get(sessionId)).toBeUndefined()
await ctx.fiber.dispose()
})
})
describe('subagent ownership fence', () => {
it('reads a cold child without an Agent and rejects generic resume or adoption', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const sessionId = sid('session-child')
const meta = header('session-child', 1000, {
parentSession: sid('session-parent'),
seedLength: 0,
})
const events = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
type: 'user/message',
seq: 1,
time: 2,
data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } },
surfaceOp: 'append',
},
{
type: 'subagent/descriptor',
seq: 2,
time: 3,
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' },
},
{ type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[]
const inspect = vi.fn(() => Promise.resolve({ meta, events }))
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect,
locate: () => undefined,
} as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const history = await api.sessions.history(request({ sessionId }))
expect(history.result.ok).toBe(true)
if (history.result.ok) {
expect(history.result.value.events.map(entry => entry.event.type)).toEqual(events.map(event => event.type))
}
expect(ctx.agents.get(sessionId)).toBeUndefined()
const prompt = await api.sessions.prompt(request({
sessionId,
mode: 'queue',
content: [{ type: 'text', text: 'follow up' }],
}))
expect(prompt.result.ok).toBe(false)
if (!prompt.result.ok) {
expect(prompt.result.error).toMatchObject({
code: 'agent-busy',
details: { reason: 'use subagent delivery for this child session' },
})
}
const create = await api.sessions.create(request({ sessionId, cwd: '/proj' }))
expect(create.result.ok).toBe(false)
if (!create.result.ok) expect(create.result.error.code).toBe('agent-busy')
expect(resume).not.toHaveBeenCalled()
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(inspect).toHaveBeenCalledTimes(3)
})
it('rejects origin-marked and runtime-owned live children from generic controls', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } })
const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
ctx.agents.register(parent)
const originSession = ctx.sessions.create(sid('session-origin-child'), {
meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
})
const cancel = vi.fn()
const updateInbox = vi.fn(() => 'applied' as const)
const originChild = {
id: originSession.id,
session: originSession,
status: 'idle',
ctx,
cancel,
updateInbox,
} as unknown as Agent
ctx.agents.register(originChild)
const startingSession = ctx.sessions.create(sid('session-starting-child'), {
meta: { cwd: '/proj', parentSession: parent.id },
})
const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
ctx.agents.enter(startingChild, parent)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const stopped = await api.sessions.cancel(request({ sessionId: originChild.id }))
expect(stopped.result.ok).toBe(false)
if (!stopped.result.ok) expect(stopped.result.error.code).toBe('agent-busy')
expect(cancel).not.toHaveBeenCalled()
const queued = await api.sessions.updateQueue(request({
sessionId: originChild.id,
itemId: MessageId('queued-item'),
action: { kind: 'remove' },
}))
expect(queued.result.ok).toBe(false)
if (!queued.result.ok) expect(queued.result.error.code).toBe('agent-busy')
expect(updateInbox).not.toHaveBeenCalled()
const models = await api.sessions.models(request({ sessionId: startingChild.id }))
expect(models.result.ok).toBe(false)
if (!models.result.ok) expect(models.result.error.code).toBe('agent-busy')
const create = await api.sessions.create(request({ sessionId: originChild.id, cwd: '/proj' }))
expect(create.result.ok).toBe(false)
if (!create.result.ok) expect(create.result.error.code).toBe('agent-busy')
const history = await api.sessions.history(request({ sessionId: originChild.id }))
expect(history.result.ok).toBe(true)
expect(ctx.agents.get(originChild.id)).toBe(originChild)
})
it('does not classify an ordinary fork from an inherited ancestor descriptor', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const session = ctx.sessions.create(sid('session-ordinary-fork'), {
seed: [{
type: 'subagent/descriptor',
seq: 0,
time: 1,
data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'ancestor' },
}],
meta: { cwd: '/proj', parentSession: sid('session-source'), seedLength: 1 },
})
const followup = vi.fn()
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
ctx.agents.register(agent)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const response = await api.sessions.prompt(request({
sessionId: agent.id,
mode: 'queue',
content: [{ type: 'text', text: 'ordinary work' }],
}))
expect(response.result.ok).toBe(true)
expect(followup).toHaveBeenCalledOnce()
})
})
describe('degenerate composition (no persistence, no factory)', () => {
it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => {
it('list skips the cold merge and history reports missing persistence as internal', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
@@ -126,15 +344,32 @@ describe('degenerate composition (no persistence, no factory)', () => {
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.
// No persistence means cold history cannot inspect a transcript.
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"/)
expect(response.result.error.message).toMatch(/history unavailable for session "session-ghost"/)
}
})
it('maps a persistence catalog miss to session-not-found without inspection', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const inspect = vi.fn()
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([]),
inspect,
} as never)
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const response = await api.sessions.history(request({ sessionId: sid('session-missing') }))
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
expect(inspect).not.toHaveBeenCalled()
})
})
describe('sessions.prompt synchronous rejection', () => {
@@ -170,4 +405,43 @@ describe('sessions.prompt synchronous rejection', () => {
}
}
})
it('classifies a raced cold-resume ID collision as agent-busy', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const sessionId = sid('race-resume')
const meta: SessionHeader = header('race-resume', 1000)
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }),
locate: () => undefined,
} as never)
// The raced winner: a live parent-owned subagent publishes the identity
// while the generic cold resume is in flight, so the resume collides.
const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } })
const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent
ctx.agents.register(parent)
const childSession = ctx.sessions.create(sessionId, {
meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' },
})
const child = { id: sessionId, session: childSession, status: 'idle', ctx } as unknown as Agent
vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => {
// The parent's `enter()` wins the identity between the pre-resume
// re-check and publication; the generic resume then collides.
ctx.agents.register(child)
throw new Error('session id already published')
})
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const models = await api.sessions.models(request({ sessionId }))
expect(models.result.ok).toBe(false)
if (!models.result.ok) {
expect(models.result.error).toMatchObject({
code: 'agent-busy',
details: { reason: 'use subagent delivery for this child session' },
})
}
})
})

View File

@@ -11,8 +11,8 @@ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent'
import type { Agent, InboxItem, InboxPlacement } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -20,7 +20,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import CommandService from '@deepseek-ai/dsh-commands'
import SkillService from '@deepseek-ai/dsh-skill'
import type { HostFrame, MuxFrame } from '../src/api/index.ts'
import type { HostFrame } from '../src/api/index.ts'
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
import { RpcId } from '../src/api/rpc.ts'
import { createApiProxy } from '../src/api-proxy.ts'
@@ -63,7 +63,14 @@ async function harness(options: { commands?: boolean; skills?: boolean } = {}):
/** Register a live structural agent stub (api-proxy-view precedent: only id/session/status/ctx are read). */
function stubAgent(ctx: Context, sessionId?: SessionId): Agent {
const session = ctx.sessions.create(sessionId)
const agent = { id: session.id, session, status: 'idle', ctx } as Agent
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
const agent = {
id: session.id,
session,
inbox,
status: 'idle',
ctx,
} as Agent
ctx.agents.register(agent)
return agent
}
@@ -78,6 +85,13 @@ async function collect<F>(iterable: AsyncIterable<RpcRequest<F>>, count: number,
return frames
}
/** Read the next payload from an open stream. */
async function nextFrame<F>(iterator: AsyncIterator<RpcRequest<F>>): Promise<F> {
const result = await iterator.next()
if (result.done) throw new Error('stream ended')
return result.value.payload
}
describe('command.list', () => {
it('serves the addressed agent\'s name-sorted catalog', async () => {
const ctx = await harness()
@@ -264,7 +278,7 @@ describe('host/commands-changed frame', () => {
})
})
/** Build one frozen inbox message for the live `agent/inbox/*` events. */
/** Build one frozen inbox message. */
function inboxMessage(id: string, text: string, rpcId?: string): UserMessage {
return freezeMessage({
id: MessageId(id),
@@ -274,27 +288,19 @@ function inboxMessage(id: string, text: string, rpcId?: string): UserMessage {
})
}
/** Build one addressable inbox occurrence around a frozen message. */
function inboxItem(id: string, message: UserMessage, placement: InboxPlacement): InboxItem {
return { id: InboxItemId(id), message, placement }
}
describe('session.updateQueue', () => {
it('routes an addressable action and reports a lost claim race', async () => {
it('splices a queued message and reports a lost claim race', async () => {
const ctx = await harness()
const agent = stubAgent(ctx)
const seen: unknown[] = []
agent.updateInbox = (id, action) => {
seen.push({ id, action })
return id === InboxItemId('present') ? 'applied' : 'not-found'
}
const present = inboxMessage('present', 'before')
agent.inbox.splice('next-turn', 0, 0, [present])
const api = createApiProxy(ctx, DEFAULTS)
const applied = await api.sessions.updateQueue({
rpcId: RpcId('q-apply'),
payload: {
sessionId: agent.id,
itemId: InboxItemId('present'),
itemId: MessageId('present'),
action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
},
})
@@ -303,15 +309,15 @@ describe('session.updateQueue', () => {
rpcId: RpcId('q-missing'),
payload: {
sessionId: agent.id,
itemId: InboxItemId('claimed'),
itemId: MessageId('claimed'),
action: { kind: 'remove' },
},
})
expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' })
expect(seen).toEqual([
{ id: 'present', action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] } },
{ id: 'claimed', action: { kind: 'remove' } },
])
expect(agent.inbox.nextTurn[0]).toMatchObject({
id: 'present',
content: [{ type: 'text', text: 'edited' }],
})
})
it('rejects a stale occurrence without resuming a cold agent', async () => {
@@ -322,7 +328,7 @@ describe('session.updateQueue', () => {
rpcId: RpcId('q-cold'),
payload: {
sessionId: 'cold-session' as SessionId,
itemId: InboxItemId('stale-item'),
itemId: MessageId('stale-item'),
action: { kind: 'remove' },
},
})
@@ -333,100 +339,64 @@ describe('session.updateQueue', () => {
})
describe('session/queue frames', () => {
it('folds nested mutations observed before their outer enqueue', async () => {
it('publishes authoritative inbox snapshots without duplicating message identity', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const original = inboxItem('i-edit', inboxMessage('m-edit', 'before'), 'queued')
const edited = inboxItem('i-edit', inboxMessage('m-edit', 'after'), 'queued')
const removed = inboxItem('i-remove', inboxMessage('m-remove', 'remove me'), 'queued')
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject !== agent) return
if (item.id === original.id) ctx.emit('agent/inbox/update', agent, edited)
if (item.id === removed.id) ctx.emit('agent/inbox/discard', agent, [removed])
const queued = inboxMessage('m-1', 'queued prompt')
const edited = inboxMessage('m-1', 'edited prompt')
const steering = inboxMessage('m-2', 'steering prompt')
agent.inbox.splice('next-turn', 0, 0, [queued])
agent.inbox.splice('next-step', 0, 0, [steering])
const abort = new AbortController()
const iterator = api.events.mux({
rpcId: RpcId('t-mux-baseline'),
payload: {},
}, abort.signal)[Symbol.asyncIterator]()
const frames = [
await nextFrame(iterator),
await nextFrame(iterator),
]
agent.inbox.splice('next-turn', 0, 1, [edited])
frames.push(await nextFrame(iterator), await nextFrame(iterator))
const injected = freezeMessage({
id: MessageId('m-3'),
role: 'user',
content: [{ type: 'text' as const, text: 'injected context' }],
source: { kind: 'plugin' as const, plugin: 'approval' },
})
const api = createApiProxy(ctx, DEFAULTS)
const live = new AbortController()
const collected = collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-reentrant'), payload: {} }, live.signal), 2, live)
agent.inbox.splice('next-step', 0, 0, [injected])
frames.push(await nextFrame(iterator), await nextFrame(iterator))
abort.abort()
await iterator.return?.()
ctx.emit('agent/inbox/enqueue', agent, original)
ctx.emit('agent/inbox/enqueue', agent, removed)
const liveFrames = (await collected).filter(frame => frame.type === 'session/queue')
expect(liveFrames.map(frame => frame.items)).toEqual([
[{ id: edited.id, message: edited.message }],
])
const replay = new AbortController()
const replayFrames = await collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-reentrant-replay'), payload: {} }, replay.signal), 2, replay)
expect(replayFrames.filter(frame => frame.type === 'session/queue')).toEqual(liveFrames)
})
it('publishes complete live snapshots and replays the latest snapshot on reconnect', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const live = new AbortController()
const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal)
// subscribed baseline + one queued snapshot; pending steering stays off this wire.
const liveCollected = collect<MuxFrame>(liveStream, 2, live)
const queued = inboxItem('i-1', inboxMessage('m-1', 'queued prompt'), 'queued')
const steering = inboxItem('i-2', inboxMessage('m-2', 'steering prompt'), 'steering')
ctx.emit('agent/inbox/enqueue', agent, queued)
ctx.emit('agent/inbox/enqueue', agent, steering)
const liveFrames = (await liveCollected).filter(f => f.type === 'session/queue')
expect(liveFrames).toEqual([
expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([
{
type: 'session/queue',
sessionId: agent.id,
items: [{ id: queued.id, message: queued.message }],
items: [
{ id: queued.id, placement: 'queued', message: queued },
{ id: steering.id, placement: 'steering', message: steering },
],
},
{
type: 'session/queue',
sessionId: agent.id,
items: [
{ id: edited.id, placement: 'queued', message: edited },
{ id: steering.id, placement: 'steering', message: steering },
],
},
{
type: 'session/queue',
sessionId: agent.id,
items: [
{ id: edited.id, placement: 'queued', message: edited },
{ id: injected.id, placement: 'context', message: injected },
{ id: steering.id, placement: 'steering', message: steering },
],
},
])
// A fresh mux connection replays only the current authoritative snapshot.
const replay = new AbortController()
const replayFrames = await collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 2, replay)
expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[0]])
})
it('publishes edits in place in the authoritative order', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const abort = new AbortController()
const collected = collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-updates'), payload: {} }, abort.signal), 5, abort)
const first = inboxItem('i-a', inboxMessage('m-a', 'a'), 'queued')
const second = inboxItem('i-b', inboxMessage('m-b', 'b'), 'queued')
const edited = inboxItem('i-b', inboxMessage('m-b', 'b edited'), 'queued')
ctx.emit('agent/inbox/enqueue', agent, first)
ctx.emit('agent/inbox/enqueue', agent, second)
ctx.emit('agent/inbox/update', agent, edited)
ctx.emit('agent/inbox/dequeue', agent, edited)
const frames = (await collected).filter(frame => frame.type === 'session/queue')
expect(frames.map(frame => frame.items)).toEqual([
[{ id: first.id, message: first.message }],
[{ id: first.id, message: first.message }, { id: second.id, message: second.message }],
[{ id: first.id, message: first.message }, { id: edited.id, message: edited.message }],
[{ id: first.id, message: first.message }],
])
})
it('publishes an empty snapshot after terminal discard', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const agent = stubAgent(ctx)
const doomed = inboxItem('i-doomed', inboxMessage('m-5', 'doomed'), 'queued')
ctx.emit('agent/inbox/enqueue', agent, doomed)
ctx.emit('agent/inbox/discard', agent, [doomed])
const abort = new AbortController()
const frames = await collect<MuxFrame>(
api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 1, abort)
expect(frames.filter(frame => frame.type === 'session/queue')).toHaveLength(0)
})
})

View File

@@ -5,7 +5,7 @@
* invalidation frames (settings/credentials/models changed).
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import z from 'schemastery'
import AgentRegistry from '@deepseek-ai/dsh-agent'
@@ -47,18 +47,35 @@ function expectErr<T>(response: RpcResponse<T>): { code: string; message: string
class MemorySettings extends Settings {
doc: Record<string, unknown>
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: { doc?: Record<string, unknown>; readOnly?: boolean }) {
constructor(ctx: ConstructorParameters<typeof Settings>[0], options?: {
doc?: Record<string, unknown>
readOnly?: boolean
documentPath?: string
preparedPath?: string
}) {
super(ctx)
this.doc = structuredClone(options?.doc ?? {})
this.readOnly = options?.readOnly ?? false
this.path = options?.documentPath
this.preparedPath = options?.preparedPath
}
private readonly readOnly: boolean
private readonly path: string | undefined
private readonly preparedPath: string | undefined
get writable(): boolean {
return !this.readOnly
}
override get documentPath(): string | undefined {
return this.path
}
override prepareDocument(): Promise<string | undefined> {
return Promise.resolve(this.preparedPath ?? this.documentPath)
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
@@ -146,7 +163,12 @@ const AdapterConfig = z.object({
})
async function harness(options?: {
settings?: false | { doc?: Record<string, unknown>; readOnly?: boolean }
settings?: false | {
doc?: Record<string, unknown>
readOnly?: boolean
documentPath?: string
preparedPath?: string
}
credentials?: false | { shadowed?: string[] }
/** Skip the directory registration to exercise a namespace the proxy does not expose. */
configurableProviders?: false
@@ -205,11 +227,15 @@ describe('settings domain', () => {
})
it('describes layered redacted namespaces with their secret slots', async () => {
const ctx = await harness({ settings: { doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } } } })
const ctx = await harness({ settings: {
doc: { 'llm-deepseek': { apiKey: 'user-secret', baseURL: 'https://user' } },
documentPath: '/tmp/custom-settings.yaml',
} })
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
const api = createApiProxy(ctx, DEFAULTS)
const value = expectOk(await api.settings.describe(request({})))
expect(value.writable).toBe(true)
expect(value.hasDocument).toBe(true)
expect(value.namespaces).toHaveLength(1)
const view = value.namespaces[0]!
expect(view.ns).toBe('llm-deepseek')
@@ -222,6 +248,62 @@ describe('settings domain', () => {
expect(JSON.stringify(value)).not.toContain('user-secret')
})
it('opens the provider-resolved document without accepting a browser path', async () => {
const ctx = await harness({ settings: {
documentPath: '/tmp/described-settings.yaml',
preparedPath: '/tmp/custom-settings.yaml',
} })
const opened: string[] = []
const api = createApiProxy(ctx, {
...DEFAULTS,
openTextFile: (path) => {
opened.push(path)
return Promise.resolve()
},
})
expect(expectOk(await api.settings.openDocument(request({}), new AbortController().signal)))
.toEqual({ opened: true })
expect(opened).toEqual(['/tmp/custom-settings.yaml'])
})
it('refuses to open settings when the provider has no local document', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
expect(expectOk(await api.settings.describe(request({}))).hasDocument).toBe(false)
const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal))
expect(error.code).toBe('internal')
expect(error.message).toContain('no local document')
})
it('does not prepare or open a settings document after cancellation', async () => {
const ctx = await harness({ settings: { documentPath: '/tmp/settings.yaml' } })
const opened: string[] = []
const api = createApiProxy(ctx, {
...DEFAULTS,
openTextFile: (path) => {
opened.push(path)
return Promise.resolve()
},
})
const prepare = vi.spyOn(ctx.settings, 'prepareDocument')
const cancelled = new AbortController()
cancelled.abort()
expect(expectErr(await api.settings.openDocument(request({}), cancelled.signal)).code)
.toBe('cancelled')
expect(prepare).not.toHaveBeenCalled()
const pending = Promise.withResolvers<string | undefined>()
prepare.mockReturnValueOnce(pending.promise)
const duringPrepare = new AbortController()
const opening = api.settings.openDocument(request({}), duringPrepare.signal)
await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() })
duringPrepare.abort()
pending.resolve('/tmp/settings.yaml')
expect(expectErr(await opening).code).toBe('cancelled')
expect(opened).toEqual([])
})
it('serves model-provider and explicitly allowlisted Web namespaces only', async () => {
// The settings seam is general: any plugin may register a namespace for
// its own configuration. The Web configuration plane remains opt-in, so a
@@ -441,11 +523,16 @@ describe('llm domain', () => {
])
ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash']))
ctx.llm.registerAdapter(['undeclared'], new CatalogAdapter('Undeclared', ['u-1']))
// Only one namespace can answer an interrogation, so the flag follows the
// entry's namespace rather than being assumed for every row.
ctx.llm.registerModelDiscovery('llm-pi-ai', () => Promise.resolve([]))
const api = createApiProxy(ctx, DEFAULTS)
const value = expectOk(await api.llm.providers(request({})))
expect(value.providers).toEqual([
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false },
// An undeclared live route has no settings address, so nothing can be
// interrogated on its behalf either.
{ provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true },
])
})
@@ -478,3 +565,108 @@ describe('llm domain', () => {
expect(frames).toEqual([{ type: 'host/models-changed' }, { type: 'host/models-changed' }])
})
})
describe('llm.discoverModels', () => {
it('carries a draft to its namespace and returns candidates without storing anything', async () => {
const ctx = await harness()
const seen: unknown[] = []
ctx.llm.registerModelDiscovery('llm-pi-ai', (probe) => {
seen.push({ baseURL: probe.baseURL, api: probe.api, apiKey: probe.apiKey })
return Promise.resolve([
{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 },
{ id: 'acme-small' },
])
})
const api = createApiProxy(ctx, DEFAULTS)
const value = expectOk(await api.llm.discoverModels(request({
settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1',
api: 'openai-completions',
apiKey: 'probe-key',
})))
expect(value.models).toEqual([
{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 },
{ id: 'acme-small' },
])
expect(seen).toEqual([{
baseURL: 'https://gateway.acme.example/v1',
api: 'openai-completions',
apiKey: 'probe-key',
}])
// Interrogating a draft is a read: no namespace gained a section, and no
// credential reference was written.
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
.not.toContain('llm-pi-ai')
})
it('carries the route being edited so an adapter can answer from its own registry', async () => {
const ctx = await harness()
let probe: unknown
ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => {
probe = request_
return Promise.resolve([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }])
})
const api = createApiProxy(ctx, DEFAULTS)
const value = expectOk(await api.llm.discoverModels(request({
settingsNs: 'llm-pi-ai',
provider: 'deepseek',
})))
// No endpoint at all: a route the adapter already describes needs none.
expect(probe).toEqual({ provider: 'deepseek' })
expect(value.models).toEqual([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }])
})
it('omits a credential and protocol the draft does not name', async () => {
const ctx = await harness()
let probe: unknown
ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => {
probe = request_
return Promise.resolve([])
})
const api = createApiProxy(ctx, DEFAULTS)
expectOk(await api.llm.discoverModels(request({
settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1',
})))
// Absent fields stay absent rather than crossing as explicit undefined:
// the adapter distinguishes "no protocol named" from "protocol undefined".
expect(probe).toEqual({ baseURL: 'https://gateway.acme.example/v1' })
})
it('reports a failed interrogation as the form\'s next move, naming no credential', async () => {
const ctx = await harness()
ctx.llm.registerModelDiscovery('llm-pi-ai', () =>
Promise.reject(new Error('https://gateway.acme.example/v1/models answered 401; check the API key')))
const api = createApiProxy(ctx, DEFAULTS)
const error = expectErr(await api.llm.discoverModels(request({
settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1',
apiKey: 'wrong',
})))
expect(error.code).toBe('model-discovery-failed')
expect(error.message).toContain('answered 401; check the API key')
expect(error.details).toEqual({ settingsNs: 'llm-pi-ai', baseURL: 'https://gateway.acme.example/v1' })
expect(JSON.stringify(error)).not.toContain('wrong')
})
it('reports a namespace no adapter family serves', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const error = expectErr(await api.llm.discoverModels(request({
settingsNs: 'llm-deepseek',
baseURL: 'https://api.deepseek.com',
})))
expect(error.code).toBe('model-discovery-failed')
expect(error.message).toContain('no model discovery is registered')
})
})

View File

@@ -1,15 +1,16 @@
/** Session-fork boundaries, lineage, and inherited model routing. */
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
@@ -21,13 +22,13 @@ function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`fork-${String(nextRpc++)}`), payload }
}
async function composed(): Promise<Context> {
async function composed(workspaces: readonly Workspace[] = []): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
ctx.provide('workspace', { list: () => [] } as never)
ctx.provide('workspace', { list: () => workspaces } as never)
ctx.agents.setFactory({
createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> => {
const session = ctx.sessions.create(options.sessionId, {
@@ -49,10 +50,16 @@ async function composed(): Promise<Context> {
/** Tail turn appended after the completed ones: left open, or closed as aborted (a stopped turn). */
type Tail = 'none' | 'open' | 'aborted'
function liveAgent(ctx: Context, id: string, turns: number, tail: Tail = 'none'): Session {
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
function liveAgent(
ctx: Context,
id: string,
turns: number,
tail: Tail = 'none',
lineage: { parentSession?: SessionId; origin?: 'subagent' } = {},
): Session {
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj', ...lineage } })
for (let turn = 1; turn <= turns; turn++) {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
source: { kind: 'user' },
@@ -60,12 +67,15 @@ function liveAgent(ctx: Context, id: string, turns: number, tail: Tail = 'none')
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
if (tail !== 'none') {
session.append('turn/start', { turn: turns + 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: turns + 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'open prompt' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
if (tail === 'aborted') session.append('turn/end', { turn: turns + 1, reason: { kind: 'aborted' } })
if (tail === 'aborted') session.append('turn/end', {
turn: turns + 1,
reason: { kind: 'aborted', reason: { kind: 'user' } },
})
}
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
return session
@@ -94,6 +104,103 @@ describe('sessions.fork', () => {
await ctx.fiber.dispose()
})
it('attaches a subagent fork to its nearest workspace-owning ancestor', async () => {
const accounted: SessionId[] = []
const attachSession = vi.fn<(sessionId: SessionId) => Promise<void>>()
.mockResolvedValue(undefined)
const workspace = {
sessionIds: accounted,
attachSession,
} as unknown as Workspace
const ctx = await composed([workspace])
const owner = liveAgent(ctx, 'session-owner', 1)
accounted.push(owner.id)
const child = liveAgent(ctx, 'session-child', 1, 'none', {
parentSession: owner.id,
origin: 'subagent',
})
const grandchild = liveAgent(ctx, 'session-grandchild', 1, 'none', {
parentSession: child.id,
origin: 'subagent',
})
ctx.provide('sessionQuery', {
traceSession: vi.fn(() => Promise.resolve({
target: { header: grandchild.header, live: true, persisted: false },
ancestors: [
{ header: child.header, live: true, persisted: false },
{ header: owner.header, live: true, persisted: false },
],
descendants: [],
complete: true,
root: { header: owner.header, live: true, persisted: false },
})),
} as never)
const response = await api(ctx).sessions.fork(request({ sessionId: grandchild.id }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) return
expect(attachSession).toHaveBeenCalledWith(response.result.value.sessionId)
expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({
parentSession: grandchild.id,
cwd: '/proj',
})
expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined()
await ctx.fiber.dispose()
})
it('forks a persisted subagent without resuming its Agent', async () => {
const ctx = await composed()
const sourceId = sid('session-cold-subagent')
const parentId = sid('session-cold-parent')
const header: SessionHeader = {
version: 0,
id: sourceId,
createdAt: 1,
cwd: '/proj',
parentSession: parentId,
origin: 'subagent',
}
const events = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{
type: 'user/message',
seq: 1,
time: 2,
data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }),
surfaceOp: 'append',
},
{ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[]
ctx.provide('sessionPersistence', {
list: () => Promise.resolve([header]),
inspect: () => Promise.resolve({ meta: header, events }),
} as never)
ctx.provide('sessionQuery', {
traceSession: () => Promise.resolve({
target: { header, live: false, persisted: true },
ancestors: [],
descendants: [],
complete: true,
root: { header, live: false, persisted: true },
}),
} as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const response = await api(ctx).sessions.fork(request({ sessionId: sourceId }))
expect(response.result.ok).toBe(true)
if (!response.result.ok) return
expect(resume).not.toHaveBeenCalled()
expect(ctx.agents.get(sourceId)).toBeUndefined()
expect(ctx.sessions.get(response.result.value.sessionId)?.header).toMatchObject({
parentSession: sourceId,
cwd: '/proj',
})
expect(ctx.sessions.get(response.result.value.sessionId)?.header.origin).toBeUndefined()
await ctx.fiber.dispose()
})
it('uses the last completed turn only for omitted and past-end anchors', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-tail', 2, 'open')
@@ -173,7 +280,7 @@ describe('sessions.fork', () => {
})
const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' }
await expect(agentEvents(child.ctx, child).waterfall(
'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(fallback),
'agent/request', { turn: 1, step: 0, signal: new AbortController().signal }, () => Promise.resolve(fallback),
)).resolves.toMatchObject({
provider: 'inherited-provider',
model: 'inherited-model',

View File

@@ -1,7 +1,8 @@
/**
* Web session model-directory and selection behavior: dynamic provider grouping,
* provider-local catalog failures, logged-target restoration, advisory unlisted
* models, and the prompt-assembly boundary for a running selection change.
* provider-local catalog failures, logged-target restoration without stale
* catalog injection, advisory pass-through models, and the prompt-assembly
* boundary for a running selection change.
*/
import { describe, expect, it } from 'vitest'
@@ -118,7 +119,7 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false
}
describe('Web session model selection', () => {
it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
it('groups successful providers and leaves an unlisted current target out of the catalog', async () => {
const { ctx, sessionId } = await harness({
provider: 'deepseek-official',
model: 'private-preview',
@@ -143,12 +144,6 @@ describe('Web session model selection', () => {
description: 'Reasoning model',
reasoning: REASONING,
},
{
id: 'private-preview',
name: 'private-preview',
unlisted: true,
reasoning: REASONING,
},
],
}])
expect(catalog.failures).toEqual([
@@ -186,13 +181,13 @@ describe('Web session model selection', () => {
reasoningEffort: 'max',
})
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'deepseek-official', model: 'private-preview' })
await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 1, signal, () => Promise.resolve(seed),
'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed),
)).resolves.toMatchObject({
provider: 'deepseek-official',
model: 'private-preview',

View File

@@ -10,7 +10,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { z } from 'zod'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -53,9 +53,8 @@ async function harness(withRegistry: boolean): Promise<{ ctx: Context; session:
await ctx.plugin(AgentRegistry)
if (withRegistry) await ctx.plugin(SessionProjectionRegistry)
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)
// The gateway reads both the session and durable inbox baseline.
ctx.agents.register({ id: session.id, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx } as Agent)
return { ctx, session }
}
@@ -225,7 +224,7 @@ describe('session/projection push frame', () => {
seedMessages(session, 1)
// Same-reference apply: turn/start does not concern the unit — no frame.
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
seedMessages(session, 1)
const frames = await collected

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { ApiProxy, 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'
async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
return {
ctx,
api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }),
}
}
function agent(id: string): Agent {
return { id } as unknown as Agent
}
function openMux(api: ApiProxy, abort: AbortController): {
envelopes: RpcRequest<MuxFrame>[]
waitForQuestion(): Promise<RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>>
} {
const envelopes: RpcRequest<MuxFrame>[] = []
let resolveQuestion!: (value: RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>) => void
const question = new Promise<RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>>((resolve) => {
resolveQuestion = resolve
})
void (async () => {
for await (const envelope of api.events.mux({ rpcId: RpcId('question-mux'), payload: {} }, abort.signal)) {
envelopes.push(envelope)
if (envelope.payload.type === 'question/requested') {
resolveQuestion(envelope as RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>)
}
}
})()
return { envelopes, waitForQuestion: () => question }
}
function answer(
envelope: RpcRequest<Extract<MuxFrame, { type: 'question/requested' }>>,
selected: string[],
custom?: string,
): Parameters<ApiProxy['respond']>[0] {
return {
type: 'client-response',
rpcId: envelope.rpcId,
result: {
ok: true,
value: {
sessionId: envelope.payload.sessionId,
answer: {
answers: [{
id: envelope.payload.questions[0]?.id,
selected,
...custom === undefined ? {} : { custom },
}],
},
},
},
}
}
describe('question response validation', () => {
it('accepts selected options with custom text for multi-select questions', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const asked = ctx.userInteraction.ask({
agent: agent('session-multi'),
questions: [{
id: 'targets',
question: 'Choose targets and add another',
multiSelect: true,
options: [{ label: 'Code' }, { label: 'Docs' }],
}],
})
const envelope = await mux.waitForQuestion()
expect(await api.respond(answer(envelope, ['Code', 'Docs'], 'Release notes')))
.toEqual({ accepted: true })
await expect(asked).resolves.toEqual({
answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Release notes' }],
})
expect(mux.envelopes.some(item => item.payload.type === 'question/resolved')).toBe(true)
abort.abort()
})
it('keeps selected options and custom text mutually exclusive for single-select questions', async () => {
const { ctx, api } = await harness()
const abort = new AbortController()
const mux = openMux(api, abort)
const asked = ctx.userInteraction.ask({
agent: agent('session-single'),
questions: [{
id: 'target',
question: 'Choose one target',
options: [{ label: 'Code' }, { label: 'Docs' }],
}],
})
const envelope = await mux.waitForQuestion()
expect(await api.respond(answer(envelope, ['Code'], 'Release notes')))
.toEqual({ accepted: false, reason: 'bad-response' })
expect(await api.respond(answer(envelope, [], 'Release notes')))
.toEqual({ accepted: true })
await expect(asked).resolves.toEqual({
answers: [{ id: 'target', selected: [], custom: 'Release notes' }],
})
abort.abort()
})
})

View File

@@ -57,7 +57,7 @@ async function composed(withTitles = true): Promise<Context> {
function liveAgent(ctx: Context, id: string, turns: number): Session {
const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } })
for (let turn = 1; turn <= turns; turn++) {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `prompt ${String(turn)}` }],
source: { kind: 'user' },

View File

@@ -138,7 +138,7 @@ describe('session.search', () => {
eventFilters: [
{
kind: 'type',
values: ['user/message', 'assistant/message', 'steering/message'],
values: ['user/message', 'assistant/message'],
},
{ kind: 'surface', values: ['current'] },
],
@@ -182,7 +182,7 @@ describe('session.search', () => {
withBestMatch(0, { sessionId: sid('hidden') }),
withBestMatch(1, { surface: 'shadowed' }),
withBestMatch(2, { type: 'tool/result' }),
withBestMatch(3, { type: 'steering/message', snippet: 'allowed snippet' }),
withBestMatch(3, { type: 'user/message', snippet: 'allowed snippet' }),
],
}),
} as never)

View File

@@ -0,0 +1,226 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
import { SubagentError } from '@deepseek-ai/dsh-subagent'
import { RpcId } from '../src/api/rpc.ts'
import type { RpcRequest } from '../src/api/rpc.ts'
import { createApiProxy } from '../src/api-proxy.ts'
const sid = (value: string): SessionId => value as SessionId
const PARENT = sid('parent')
const CHILD = sid('child')
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId('subagent-rpc'), payload }
}
function bench(options: {
parentLive?: boolean
childStatus?: 'idle' | 'running'
entries?: object[]
followupError?: Error
listError?: Error
readError?: Error
historyParent?: SessionId
} = {}) {
const parent = { id: PARENT }
const child = options.childStatus === undefined
? undefined
: { id: CHILD, status: options.childStatus }
const getAgent = vi.fn((id: SessionId) => {
if (options.parentLive !== false && id === PARENT) return parent
if (id === CHILD) return child
return undefined
})
const listChildren = vi.fn(() => options.listError === undefined
? Promise.resolve(options.entries ?? [
{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'inactive', hasChildren: false,
},
])
: Promise.reject(options.listError))
const followup = vi.fn((
_parent: unknown,
_childId: SessionId,
_content: unknown,
_delivery: { source: { kind: string; rpcId: RpcId }; signal: AbortSignal },
) => options.followupError === undefined
? Promise.resolve('message-1')
: Promise.reject(options.followupError))
const readSession = vi.fn(() => options.readError === undefined
? Promise.resolve({
session: {
version: 0, id: CHILD, createdAt: 1, parentSession: options.historyParent ?? PARENT,
} satisfies SessionHeader,
events: [
{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } } },
] as unknown as SessionEvent[],
})
: Promise.reject(options.readError))
const ctx = new Context()
ctx.provide('agents', { get: getAgent })
ctx.provide('subagents', { listChildren, followup })
ctx.provide('sessionQuery', { readSession })
ctx.provide('userInteraction', { registerProvider: () => () => {} })
const api = createApiProxy(ctx, {
provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp',
})
return { api, getAgent, listChildren, readSession, followup, parent }
}
describe('subagent gateway', () => {
it('lists the complete catalog and reports exact live-parent availability', async () => {
const { api, listChildren } = bench({ parentLive: false, entries: [
{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'inactive', hasChildren: true,
},
{
kind: 'child', id: sid('one-shot'), mode: 'one-shot',
activity: 'inactive', hasChildren: false,
},
{ kind: 'diagnostic', id: sid('bad'), reason: 'corrupt' },
] })
const response = await api.subagents.list(request({ parentSessionId: PARENT }))
expect(response.rpcId).toBe('subagent-rpc')
expect(response.result).toMatchObject({
ok: true,
value: {
parentAvailable: false,
entries: [
{ kind: 'child', mode: 'continuable' },
{ kind: 'child', mode: 'one-shot' },
{ kind: 'diagnostic' },
],
},
})
expect(listChildren).toHaveBeenCalledWith(PARENT, undefined)
})
it('derives catalog activity from the live child Agent rather than Session residency', async () => {
const residentIdle = bench({ childStatus: 'idle', entries: [{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
}] })
expect((await residentIdle.api.subagents.list(request({ parentSessionId: PARENT }))).result)
.toMatchObject({ ok: true, value: { entries: [{ activity: 'inactive' }] } })
const running = bench({ childStatus: 'running' })
expect((await running.api.subagents.list(request({ parentSessionId: PARENT }))).result)
.toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } })
})
it('reads a healthy direct child without looking up or activating any Agent', async () => {
const { api, getAgent, readSession } = bench()
const response = await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', maxMessages: 10,
}))
expect(response.result).toMatchObject({
ok: true,
value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] },
})
expect(readSession).toHaveBeenCalledWith(CHILD)
expect(getAgent).not.toHaveBeenCalled()
})
it('reads one-shot history and rejects an address with the wrong mode', async () => {
const oneShot = {
kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch',
activity: 'inactive', hasChildren: false,
}
const { api, readSession } = bench({ entries: [oneShot] })
expect((await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'one-shot',
}))).result).toMatchObject({ ok: true })
expect((await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))).result).toMatchObject({ ok: false, error: { code: 'subagent-not-found' } })
expect(readSession).toHaveBeenCalledTimes(1)
})
it('rejects a diagnostic address before reading history', async () => {
const { api, readSession } = bench({ entries: [
{ kind: 'diagnostic', id: CHILD, reason: 'unsupported' },
] })
const response = await api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))
expect(response.result).toMatchObject({
ok: false,
error: {
code: 'subagent-catalog-diagnostic',
details: { parentSessionId: PARENT, childSessionId: CHILD, reason: 'unsupported' },
},
})
expect(readSession).not.toHaveBeenCalled()
})
it('routes human content through the exact live parent with rpc attribution', async () => {
const { api, parent, followup } = bench()
const content = [{ type: 'text' as const, text: '继续' }]
const signal = new AbortController().signal
const response = await api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content,
}), signal)
expect(response.result).toMatchObject({
ok: true, value: { messageId: 'message-1' },
})
expect(followup).toHaveBeenCalledWith(
parent,
CHILD,
content,
{ source: { kind: 'user', rpcId: RpcId('subagent-rpc') }, signal },
)
})
it('fails before delivery when the parent is absent and maps continuation failures', async () => {
const absent = bench({ parentLive: false })
expect((await absent.api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
}), new AbortController().signal)).result).toMatchObject({
ok: false, error: { code: 'subagent-parent-unavailable' },
})
expect(absent.listChildren).not.toHaveBeenCalled()
const failed = bench({ followupError: new SubagentError('draining', 'DRAINING') })
expect((await failed.api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
}), new AbortController().signal)).result).toMatchObject({
ok: false, error: { code: 'subagent-delivery-unavailable' },
})
})
it('maps history disappearance and hides unexpected backend details', async () => {
const disappeared = bench({
readError: new SessionQueryError('secret path', 'SESSION_QUERY_SESSION_NOT_FOUND'),
})
expect((await disappeared.api.subagents.history(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
}))).result).toMatchObject({
ok: false,
error: {
code: 'subagent-not-found',
message: 'subagent disappeared during history read',
details: { parentSessionId: PARENT, childSessionId: CHILD },
},
})
const catalog = bench({ listError: new Error('secret descriptor') })
expect((await catalog.api.subagents.list(request({
parentSessionId: PARENT,
}))).result).toMatchObject({
ok: false,
error: { code: 'internal', message: 'subagent catalog read failed' },
})
const prompt = bench({ followupError: new Error('secret provider') })
expect((await prompt.api.subagents.prompt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
}), new AbortController().signal)).result).toMatchObject({
ok: false,
error: { code: 'internal', message: 'subagent prompt failed' },
})
})
})

View File

@@ -112,7 +112,7 @@ describe('mux live view computation', () => {
const rawResult = `RAW_RESULT:${'x'.repeat(64 * 1024)}`
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/start', { turn: 1 })
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: '{}' })
@@ -175,7 +175,7 @@ describe('mux live view computation', () => {
// 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('turn/start', { turn: 1 })
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', {
@@ -241,7 +241,7 @@ describe('mux live view computation', () => {
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' })
const session = ctx.sessions.create()
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('turn/start', { turn: 1 })
const first = appendUserText(session, 'first prompt')
appendAssistantText(session, 'first reply', 1)
const third = appendUserText(session, 'second prompt')
@@ -295,7 +295,7 @@ describe('mux live view computation', () => {
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('turn/start', { turn: 1 })
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.
@@ -314,7 +314,7 @@ describe('mux live view computation', () => {
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('turn/start', { turn: 1 })
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

View File

@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
@@ -44,16 +44,15 @@ function stubAgent(session: Session): Agent {
id: session.id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
acceptsNextStep: false,
ctx: new Context(),
followup: () => {},
steer: () => {},
inject: () => {},
send: () => {},
updateInbox: () => 'not-found',
reserveTurnAdmission: () => undefined,
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
}
@@ -364,6 +363,36 @@ describe('session creation and Workspace membership', () => {
})
describe('Host Workspace increments', () => {
it('projects subagent origin in attached summaries and creation increments', async () => {
const { api, ctx } = await harness()
const abort = new AbortController()
const stream: AsyncIterator<RpcRequest<HostFrame>> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const pending = nextHostFrame(stream)
const childId = SessionId('session-subagent-child')
ctx.sessions.create(childId, {
meta: {
cwd: '/tmp',
parentSession: SessionId('session-parent'),
origin: 'subagent',
},
})
expect(await pending).toMatchObject({
payload: {
type: 'host/session-added',
sessionId: childId,
parentSessionId: 'session-parent',
origin: 'subagent',
},
})
expect(expectOk(await api.sessions.list(request({}))).items).toContainEqual(
expect.objectContaining({ sessionId: childId, origin: 'subagent' }),
)
abort.abort()
})
it('streams committed Workspace and Session increments after empty baselines', async () => {
const { api } = await harness()
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])

View File

@@ -19,6 +19,7 @@ function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>>
/** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */
function scriptedApi(overrides: {
sessions?: Partial<ApiProxy['sessions']>
subagents?: Partial<ApiProxy['subagents']>
host?: Partial<ApiProxy['host']>
commands?: Partial<ApiProxy['commands']>
skills?: Partial<ApiProxy['skills']>
@@ -57,6 +58,12 @@ function scriptedApi(overrides: {
cancel: r => ok(r, { accepted: true as const }),
...overrides.sessions,
},
subagents: {
list: r => ok(r, { entries: [], parentAvailable: false }),
history: r => ok(r, { events: [], hasMore: false }),
prompt: r => ok(r, { messageId: 'message-1' as never }),
...overrides.subagents,
},
host: {
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
pickDirectory: r => ok(r, { path: null }),
@@ -89,7 +96,8 @@ function scriptedApi(overrides: {
...overrides.goals,
},
settings: {
describe: r => ok(r, { writable: true, namespaces: [] }),
describe: r => ok(r, { writable: true, hasDocument: false, namespaces: [] }),
openDocument: r => ok(r, { opened: true as const }),
update: err,
replace: err,
mutate: err,
@@ -104,6 +112,7 @@ function scriptedApi(overrides: {
llm: {
providers: r => ok(r, { providers: [] }),
models: r => ok(r, { groups: [], failures: [] }),
discoverModels: err,
...overrides.llm,
},
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
@@ -672,7 +681,8 @@ describe('config unary surface', () => {
const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] }
const api = scriptedApi({
settings: {
describe: record('settings.describe', r => ok(r, { writable: true, namespaces: [view] })),
describe: record('settings.describe', r => ok(r, { writable: true, hasDocument: false, namespaces: [view] })),
openDocument: record('settings.openDocument', r => ok(r, { opened: true as const })),
update: record('settings.update', r => ok(r, view)),
replace: record('settings.replace', r => ok(r, view)),
mutate: record('settings.mutate', r => ok(r, view)),
@@ -685,12 +695,14 @@ describe('config unary surface', () => {
llm: {
providers: record('llm.providers', r => ok(r, { providers: [providerRow] })),
models: record('llm.models', r => ok(r, { groups: [group], failures: [] })),
discoverModels: record('llm.discoverModels', r => ok(r, { models: [{ id: 'acme-large', contextWindow: 65536 }] })),
},
})
const c = client(api)
const described = await c.settings.describe({})
expect(described.result).toEqual({ ok: true, value: { writable: true, namespaces: [view] } })
expect(described.result).toEqual({ ok: true, value: { writable: true, hasDocument: false, namespaces: [view] } })
expect((await c.settings.openDocument({})).result).toEqual({ ok: true, value: { opened: true } })
const updated = await c.settings.update({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
expect(updated.result).toEqual({ ok: true, value: view })
const replaced = await c.settings.replace({ ns: 'llm-deepseek', section: {} })
@@ -709,16 +721,31 @@ describe('config unary surface', () => {
expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } })
const models = await c.llm.models({})
expect(models.result).toEqual({ ok: true, value: { groups: [group], failures: [] } })
const discovered = await c.llm.discoverModels({
settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1',
api: 'openai-completions',
apiKey: 'probe-key',
})
expect(discovered.result).toEqual({ ok: true, value: { models: [{ id: 'acme-large', contextWindow: 65536 }] } })
expect(seen.map(call => call.method)).toEqual([
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'llm.providers', 'llm.models',
'llm.providers', 'llm.models', 'llm.discoverModels',
])
expect(seen[1]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
expect(seen[3]?.payload)
expect(seen[2]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
expect(seen[4]?.payload)
.toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0 })
expect(seen[5]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' })
expect(seen[6]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' })
// The draft crosses whole, credential included: the host needs it for this
// one interrogation and stores none of it.
expect(seen[10]?.payload).toEqual({
settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1',
api: 'openai-completions',
apiKey: 'probe-key',
})
})
it('rejects an invalid credential reference name at the carrier boundary', async () => {

View File

@@ -103,6 +103,31 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
},
subagents: {
async list(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { entries: [], parentAvailable: false } } }
},
async history(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { events: [], hasMore: false } } }
},
async prompt(request, signal) {
if (request.payload.content.some(block => block.type === 'text' && block.text === 'hang')) {
if (!signal.aborted) {
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
return {
rpcId: request.rpcId,
result: { ok: false, error: { code: 'cancelled' as const, message: 'aborted', details: {} } },
}
}
return {
rpcId: request.rpcId,
result: { ok: true, value: { messageId: 'message-1' as never } },
}
},
},
host: {
async describe(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
@@ -195,7 +220,10 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
},
settings: {
async describe(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, namespaces: [] } } }
return { rpcId: request.rpcId, result: { ok: true, value: { writable: true, hasDocument: false, namespaces: [] } } }
},
async openDocument(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }
},
async update(request) {
return { rpcId: request.rpcId, result: { ok: false, error: { code: 'settings-rejected', message: 'stub', details: { ns: request.payload.ns } } } }
@@ -225,6 +253,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async models(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { groups: [], failures: [] } } }
},
async discoverModels(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { models: [] } } }
},
},
events: {
mux: (_request, signal) => stream(muxFrames, signal),
@@ -386,6 +417,23 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
}
})
it('round-trips the subagent domain through the wire form', async () => {
const c = client()
expect((await c.subagents.list({ parentSessionId: 'parent' as never })).result)
.toEqual({ ok: true, value: { entries: [], parentAvailable: false } })
expect((await c.subagents.history({
parentSessionId: 'parent' as never,
childSessionId: 'child' as never,
mode: 'one-shot',
})).result).toEqual({ ok: true, value: { events: [], hasMore: false } })
expect((await c.subagents.prompt({
parentSessionId: 'parent' as never,
childSessionId: 'child' as never,
mode: 'continuable',
content: [],
})).result).toEqual({ ok: true, value: { messageId: 'message-1' } })
})
it('keeps caller and connection aborts on command.execute', async () => {
const api = fakeApi()
const started = Promise.withResolvers<AbortSignal>()
@@ -451,6 +499,34 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect(parsed.result.error?.code).toBe('cancelled')
})
it('propagates the carrier Request signal into subagent.prompt', async () => {
const handler = toFetchHandler(fakeApi())
const controller = new AbortController()
const body = JSON.stringify({
type: 'client-request',
rpcId: 'r-subagent-sig',
method: 'subagent.prompt',
payload: {
parentSessionId: 'parent',
childSessionId: 'child',
mode: 'continuable',
content: [{ type: 'text', text: 'hang' }],
},
})
const pending = handler.fetch(new Request(
'http://x/api/subagent.prompt',
{ method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal },
))
controller.abort()
const response = await pending
const parsed = await response.json() as {
rpcId: string
result: { error?: { code: string } }
}
expect(parsed.rpcId).toBe('r-subagent-sig')
expect(parsed.result.error?.code).toBe('cancelled')
})
it('propagates the carrier Request signal into host.pickDirectory', async () => {
const api = fakeApi()
api.host.pickDirectory = async (request, signal) => {

View File

@@ -15,7 +15,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>()
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import { describe, expect, it, vi } from 'vitest'
import { openNativePath, type PathOpenerRunner } from '../src/native-path-opener.ts'
import { openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts'
const signal = () => new AbortController().signal
@@ -26,6 +26,18 @@ describe('native path opener', () => {
expect(run).toHaveBeenCalledWith('open', ['/Users/test/file.txt'], expect.any(AbortSignal))
})
it('bypasses macOS file associations for text documents', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativeTextFile('/Users/test/settings.yaml', signal(), { platform: 'darwin', run })
expect(run).toHaveBeenCalledWith('open', ['-t', '/Users/test/settings.yaml'], expect.any(AbortSignal))
})
it('uses the Linux desktop association for text documents', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativeTextFile('/tmp/settings.yaml', signal(), { platform: 'linux', run })
expect(run).toHaveBeenCalledWith('xdg-open', ['/tmp/settings.yaml'], expect.any(AbortSignal))
})
it('opens with Windows Invoke-Item and escapes single quotes', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run })
@@ -36,6 +48,16 @@ describe('native path opener', () => {
)
})
it('uses the Windows desktop association for text documents', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativeTextFile('C:\\work\\settings.yaml', signal(), { platform: 'win32', run })
expect(run).toHaveBeenCalledWith(
'powershell.exe',
['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\settings.yaml'"],
expect.any(AbortSignal),
)
})
it('opens with Linux xdg-open', async () => {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: '', stderr: '' }))
await openNativePath('/tmp/a.txt', signal(), { platform: 'linux', run })

View File

@@ -204,7 +204,6 @@ describe('sessions domain schemas', () => {
id: 'deepseek-v4-flash',
name: 'DeepSeek V4 Flash',
description: 'fast',
unlisted: true,
reasoning: {
efforts: [
{ id: 'off', name: 'Off' },
@@ -421,7 +420,11 @@ describe('events frame schemas', () => {
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
{ type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' },
{ type: 'session/queue', sessionId: 's', items: [
{ id: 'i1', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } } },
{
id: 'm1',
placement: 'queued',
message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } },
},
] },
{ type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 },
{ type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } },
@@ -451,10 +454,21 @@ describe('events frame schemas', () => {
}
})
it('accepts every queue placement and rejects unknown placements', () => {
const item = (placement: string) => ({ type: 'session/queue', sessionId: 's', items: [{
id: 'm', placement,
message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } },
}] })
for (const placement of ['queued', 'steering', 'context']) {
expect(() => muxFrameSchema.parse(item(placement))).not.toThrow()
}
expect(() => muxFrameSchema.parse(item('bogus'))).toThrow()
})
it('rejects a queue snapshot with malformed items', () => {
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {} }] })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'i', message: { id: 'm', role: 'user', content: [], source: {} } }] })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', role: 'user', content: [], source: { kind: 'user' } }] })).toThrow()
expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'm', role: 'assistant', content: [], source: { kind: 'user' } }] })).toThrow()
})
it('accepts every host frame branch', () => {