Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
#	docs/config-catalog.md
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/llm-streaming.md
#	docs/core-data-structures/llm-streaming.zh.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/subsystems/attachment.i18n.yaml
#	docs/subsystems/attachment.md
#	docs/subsystems/attachment.zh.md
#	docs/subsystems/core.i18n.yaml
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/README.i18n.yaml
#	packages/README.md
#	packages/README.zh.md
#	packages/client/runtime/package.json
#	packages/client/ui-conversation/package.json
#	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/MessageItem.tsx
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/README.zh.md
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/src/api/sessions.ts
#	packages/host/apiproxy/src/index.ts
#	packages/host/apiproxy/tests/api-proxy-models.spec.ts
#	packages/self-modification/tool-cordis/src/api-catalog.ts
#	pnpm-lock.yaml
#	scripts/type-equiv.manifest.json
This commit is contained in:
Yichen Jiang
2026-08-09 23:33:35 +08:00
3271 changed files with 69578 additions and 24953 deletions

View File

@@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(ApprovalService)
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
return { ctx, api }
}
@@ -217,7 +217,7 @@ describe('approval pending registry', () => {
await ctx.plugin(ApprovalService)
let api!: ApiProxy
const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
api = createApiProxy(fiberCtx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
}, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
await fiber.await()
const abort = new AbortController()

View File

@@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio
await ctx.plugin(AgentRegistry)
return {
ctx,
api: createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
attach: (session) => {
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
},

View File

@@ -64,7 +64,7 @@ describe('sessions.list cold merge', () => {
return undefined
},
})
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const response = await api.sessions.list(request({}))
expect(response.result.ok).toBe(true)
@@ -92,7 +92,7 @@ describe('attached updatedAt excludes end-seed', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
// Old work, resumed just now: the log tail would report the pickup.
const worked = 1_000_000
@@ -150,7 +150,7 @@ describe('cold history recovery view', () => {
inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
locate: () => undefined,
} as never)
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ 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')
@@ -206,7 +206,7 @@ describe('Remote Agent and Session lookup policy', () => {
})
const defaultAgentLookup = ctx.typert.lookups.get('agent')
const defaultSessionLookup = ctx.typert.lookups.get('session')
createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
await vi.waitFor(() => {
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
@@ -250,7 +250,7 @@ describe('Remote Agent and Session lookup policy', () => {
const resume = vi.spyOn(ctx.agents, 'resume')
const defaultAgentLookup = ctx.typert.lookups.get('agent')
const defaultSessionLookup = ctx.typert.lookups.get('session')
createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
await vi.waitFor(() => {
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
@@ -312,7 +312,7 @@ describe('subagent ownership fence', () => {
locate: () => undefined,
} as never)
const resume = vi.spyOn(ctx.agents, 'resume')
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const history = await api.sessions.history(request({ sessionId }))
expect(history.result.ok).toBe(true)
@@ -365,13 +365,13 @@ describe('subagent ownership fence', () => {
inspect: () => Promise.resolve({ meta, events }),
locate: () => undefined,
} as never)
// Pre-#1569 stores classify a child only through the descriptor event and
// carry no header `origin`; the pre-release decision stops recognizing
// them, so the ownership fence lets generic resume reach the registry
// instead of answering `agent-busy`.
// Stores whose headers predate `origin` classify a child only through the
// descriptor event; the pre-release decision stops recognizing them, so
// the ownership fence lets generic resume reach the registry instead of
// answering `agent-busy`.
const resume = vi.spyOn(ctx.agents, 'resume')
.mockRejectedValue(new Error('registry unavailable in this bench'))
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const prompt = await api.sessions.prompt(request({
sessionId,
@@ -412,7 +412,7 @@ describe('subagent ownership fence', () => {
})
const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
ctx.agents.enter(startingChild, parent)
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const stopped = await api.sessions.cancel(request({ sessionId: originChild.id }))
expect(stopped.result.ok).toBe(false)
@@ -458,7 +458,7 @@ describe('subagent ownership fence', () => {
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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const response = await api.sessions.prompt(request({
sessionId: agent.id,
@@ -476,7 +476,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const listed = await api.sessions.list(request({}))
expect(listed.result.ok).toBe(true)
@@ -501,7 +501,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
list: () => Promise.resolve([]),
inspect,
} as never)
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ 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)
@@ -518,7 +518,7 @@ describe('sessions.prompt synchronous rejection', () => {
await ctx.plugin(UserInteractionService)
const session = ctx.sessions.create(sid('session-throwing'))
// A live structural stub whose delivery verbs throw synchronously, the
// shape a disposed loop presents at this seam.
// shape a disposed loop presents at this gateway boundary.
ctx.agents.register({
id: session.id,
session,
@@ -527,7 +527,7 @@ describe('sessions.prompt synchronous rejection', () => {
followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
} as unknown as Agent)
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
for (const mode of ['queue', 'steer'] as const) {
const response = await api.sessions.prompt(request({
@@ -571,7 +571,7 @@ describe('sessions.prompt synchronous rejection', () => {
ctx.agents.register(child)
throw new Error('session id already published')
})
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const models = await api.sessions.models(request({ sessionId }))
expect(models.result.ok).toBe(false)

View File

@@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
import { RpcId } from '../src/api/rpc.ts'
import { createApiProxy } from '../src/api-proxy.ts'
const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
function request<P>(payload: P): RpcRequest<P> {
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }

View File

@@ -22,9 +22,10 @@ import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepsee
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 { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from '../src/api-proxy.ts'
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
import { createApiProxy } from '../src/api-proxy.ts'
const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
let nextRpc = 1
function request<P>(payload: P): RpcRequest<P> {
@@ -43,7 +44,7 @@ function expectErr<T>(response: RpcResponse<T>): { code: string; message: string
return response.result.error
}
/** In-memory settings provider: the seam base class owns all tested behavior. */
/** In-memory settings provider: the Service Definition base class owns all tested behavior. */
class MemorySettings extends Settings {
doc: Record<string, unknown>
@@ -366,9 +367,10 @@ describe('settings domain', () => {
it('invalidates the model catalog when a provider namespace changes, and broadcasts a raw-only change', async () => {
// Editing `models` changes no route, so llm/adapters-updated never fires
// and an open model picker kept serving the old catalog. And storing an
// override equal to the resolved value emits nothing on settings/updated,
// so another tab never learned the field became overridden.
// and an open model picker would keep serving the stale catalog. Storing
// an override equal to the resolved value emits nothing on
// settings/updated, so another tab would never learn the field became
// overridden.
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
const api = createApiProxy(ctx, DEFAULTS)
@@ -398,21 +400,21 @@ describe('settings domain', () => {
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }])
})
it('invalidates the model catalog when the gateway default route changes', async () => {
it('invalidates the model catalog when the Agent default selection changes', async () => {
const ctx = await harness()
const route = ctx.settings.register(API_GATEWAY_SETTINGS_NAMESPACE, z.object({
const defaultModel = ctx.settings.register(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, z.object({
provider: z.string().required(),
model: z.string().required(),
}), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } })
const api = createApiProxy(ctx, DEFAULTS)
// The gateway's own section names the route every session with no logged
// one resolves to, so an externally edited default — another tab, a
// The shared section names the selection every blank session resolves to,
// so an externally edited default — another tab, a
// hand-edited settings.yaml — has to reach an open selector as well.
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => {
await route.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
})
expect(frames).toEqual([
{ type: 'host/settings-changed', ns: 'api-gateway' },
{ type: 'host/settings-changed', ns: 'agent-default-model' },
{ type: 'host/models-changed' },
])
})

View File

@@ -1,108 +0,0 @@
/**
* The `api-gateway` settings section over a REAL settings provider: the
* composition entry as the base layer, the wholesale replace the gateway
* persists with, and the fallback when the provider detaches. The other model
* specs drive hand-rolled `defaultTarget`/`persistDefaultTarget` closures, so
* this is the only place the layering itself is exercised.
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { Settings, installSettingsSection } from '@deepseek-ai/dsh-settings'
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA } from '../src/index.ts'
import type { DefaultRouteSettings } from '../src/index.ts'
/** The smallest real provider: one in-memory document, always writable. */
class MemorySettings extends Settings {
doc: Record<string, unknown> = {}
get writable(): boolean {
return true
}
protected load(): Promise<Record<string, unknown>> {
return Promise.resolve(structuredClone(this.doc))
}
protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
this.doc = { ...this.doc, [ns]: structuredClone(section) }
return Promise.resolve()
}
}
/** Mount the gateway's own section wiring over a live provider. */
async function boot(entry: DefaultRouteSettings) {
const ctx = new Context()
const fiber = ctx.plugin(MemorySettings)
await fiber.await()
let route: () => DefaultRouteSettings = () => entry
const consumer = ctx.plugin(function section(child: Context) {
installSettingsSection(child, API_GATEWAY_SETTINGS_NAMESPACE, DEFAULT_ROUTE_SCHEMA, entry, {
setSource: (current) => { route = current },
onChange: () => {},
})
})
await consumer.await()
const settings = ctx.get('settings')
if (settings === undefined) throw new Error('settings provider did not mount')
return { ctx, fiber, consumer, settings, read: () => route() }
}
describe('the api-gateway default-route section', () => {
it('resolves the composition entry until the user layer overrides it', async () => {
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
})
expect(bench.read()).toEqual({
provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
})
await bench.ctx.fiber.dispose()
})
it('clears a stored effort when the next switch has none', async () => {
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
provider: 'acme-gateway', model: 'acme-large', reasoningEffort: 'high',
})
expect(bench.read().reasoningEffort).toBe('high')
// The whole reason the gateway persists with `replace` rather than a merge
// patch — and the reason `Config` carries no effort for the base layer to
// re-inherit here. A stranded effort would fail the next session's first
// request against a model that does not support it.
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
provider: 'acme-gateway', model: 'acme-plain',
})
expect(bench.read()).toEqual({ provider: 'acme-gateway', model: 'acme-plain' })
await bench.ctx.fiber.dispose()
})
it('layers a hand-written partial section over the entry', async () => {
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
// Someone editing settings.yaml by hand may name only the model. The
// entry supplies the provider, which is what makes this legal — and is
// exactly why an effort in the entry could never be cleared, so there
// is none to inherit.
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, { model: 'deepseek-reasoner' })
expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
await bench.ctx.fiber.dispose()
})
it('falls back to the composition entry when the provider detaches', async () => {
const bench = await boot({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
await bench.settings.replace(API_GATEWAY_SETTINGS_NAMESPACE, {
provider: 'acme-gateway', model: 'acme-large',
})
expect(bench.read().provider).toBe('acme-gateway')
// A deployment that loses its settings provider keeps serving the route it
// was composed with rather than the one it can no longer read.
await bench.fiber.dispose()
expect(bench.read()).toEqual({ provider: 'deepseek-official', model: 'deepseek-v4-flash' })
await bench.ctx.fiber.dispose()
})
})

View File

@@ -82,7 +82,7 @@ function liveAgent(
}
const api = (ctx: Context) => createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'default-provider', model: 'default-model' }),
defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
@@ -254,7 +254,7 @@ describe('sessions.fork', () => {
await ctx.fiber.dispose()
})
it('installs the latest logged model target before the child can run', async () => {
it('installs the latest logged model selection before the child can run', async () => {
const ctx = await composed()
const source = liveAgent(ctx, 'session-routed', 1)
source.append('request/header', {

View File

@@ -1,6 +1,6 @@
/**
* Web session model-directory and selection behavior: dynamic provider grouping,
* provider-local catalog failures, logged-target restoration without stale
* provider-local catalog failures, logged-selection restoration without stale
* catalog injection, advisory pass-through models, and the prompt-assembly
* boundary for a running selection change.
*/
@@ -154,7 +154,7 @@ describe('Web session model selection', () => {
const followup = vi.fn()
Object.assign(agent, { followup })
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
@@ -201,7 +201,7 @@ describe('Web session model selection', () => {
const { ctx, agent, sessionId } = await harness()
registerTextOnly(ctx)
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
@@ -244,7 +244,7 @@ describe('Web session model selection', () => {
const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) }))
ctx.provide('attachments', { readImage } as never)
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
@@ -271,13 +271,13 @@ describe('Web session model selection', () => {
expect(readImage).toHaveBeenCalledOnce()
await ctx.fiber.dispose()
})
it('groups successful providers and leaves an unlisted current target out of the catalog', async () => {
it('groups successful providers and leaves an unlisted current selection out of the catalog', async () => {
const { ctx, sessionId } = await harness({
provider: 'deepseek-official',
model: 'private-preview',
reasoningEffort: ReasoningEffortId('max'),
})
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
expect(catalog.current).toEqual({
@@ -312,7 +312,7 @@ describe('Web session model selection', () => {
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
const { ctx, agent, sessionId } = await harness()
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
@@ -378,11 +378,11 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
it('reads the host default live for a session whose log names no route', async () => {
it('reads the Agent default live for a session whose log names no selection', async () => {
const { ctx, sessionId } = await harness()
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
const api = createApiProxy(ctx, {
defaultTarget: () => stored,
defaultModelSelection: () => stored,
cwd: '/tmp',
workspaceRoot: '/tmp',
})
@@ -400,14 +400,14 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
it('keeps a session that logged a route on it when the host default moves', async () => {
it('keeps a session on its logged selection when the Agent default differs', async () => {
const { ctx, sessionId } = await harness({
provider: 'deepseek-official',
model: 'deepseek-chat',
})
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
const api = createApiProxy(ctx, {
defaultTarget: () => stored,
defaultModelSelection: () => stored,
cwd: '/tmp',
workspaceRoot: '/tmp',
})
@@ -423,9 +423,9 @@ describe('Web session model selection', () => {
const saved: unknown[] = []
let reject = false
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
persistDefaultTarget: (target) => {
saved.push(target)
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
saveDefaultModelSelection: (selection) => {
saved.push(selection)
return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve()
},
cwd: '/tmp',
@@ -458,7 +458,7 @@ describe('Web session model selection', () => {
it('refuses a prompt no adapter can route, and reports it on the directory', async () => {
const { ctx, sessionId } = await harness()
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})
@@ -491,7 +491,7 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
// What a Models-page removal leaves behind: the settings document still
// names the route the user last picked, and nothing serves it.
defaultTarget: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
workspaceRoot: '/tmp',
})

View File

@@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void {
}
}
const api = (ctx: Context) => createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
describe('session.history projections block', () => {
it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {

View File

@@ -14,7 +14,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
await ctx.plugin(UserInteractionService)
return {
ctx,
api: createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
}
}

View File

@@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session {
return session
}
const api = (ctx: Context) => createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
describe('sessions.rename', () => {
it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {

View File

@@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => {
})
const sid = (value: string): SessionId => value as SessionId
const defaults = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
function request(query: string): RpcRequest<{ query: string }> {
return { rpcId: RpcId(`search-${query}`), payload: { query } }
@@ -165,7 +165,7 @@ describe('session.search', () => {
expect(searchSessions).not.toHaveBeenCalled()
})
it('rejects snippets whose provider provenance violates the Host filters', async () => {
it('rejects snippets whose recorded provider violates the Host filters', async () => {
const ctx = await baseContext()
const visible = hit('visible')
ctx.sessions.create(visible.header.id, { meta: visible.header })

View File

@@ -19,6 +19,7 @@ function bench(options: {
childStatus?: 'idle' | 'running'
entries?: object[]
followupError?: Error
interruptError?: Error
listError?: Error
/** Persistence forgets the child entirely (the vanished-mid-read race). */
storedChild?: false
@@ -53,6 +54,12 @@ function bench(options: {
) => options.followupError === undefined
? Promise.resolve('message-1')
: Promise.reject(options.followupError))
const interrupt = vi.fn((
_targetSessionId: SessionId,
_authority: { kind: 'user'; parentSessionId: SessionId },
) => {
if (options.interruptError !== undefined) throw options.interruptError
})
const childHeader = {
version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT,
} satisfies SessionHeader
@@ -72,7 +79,7 @@ function bench(options: {
})
const ctx = new Context()
ctx.provide('agents', { get: getAgent })
ctx.provide('subagents', { listChildren, followup })
ctx.provide('subagents', { listChildren, followup, interrupt })
ctx.provide('sessions', {
get: (id: SessionId) => options.liveChild === true && id === CHILD
? { id: CHILD, header: childHeader, events: childEvents }
@@ -84,13 +91,13 @@ function bench(options: {
locate: () => undefined,
})
// The gateway's own projection push feed subscribes at construction; the
// no-op disposer keeps that seam quiet while these tests pin history reads.
// no-op disposer keeps that feed quiet while these tests pin history reads.
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
ctx.provide('userInteraction', { registerProvider: () => () => {} })
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp',
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp',
})
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent }
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent }
}
describe('subagent gateway', () => {
@@ -309,4 +316,48 @@ describe('subagent gateway', () => {
error: { code: 'internal', message: 'subagent prompt failed' },
})
})
it('interrupts through the core primitive alone while the parent Agent is offline', async () => {
const { api, interrupt, getAgent, listChildren, inspect } = bench({ parentLive: false })
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.rpcId).toBe('subagent-rpc')
expect(response.result).toEqual({ ok: true, value: { accepted: true } })
expect(interrupt).toHaveBeenCalledExactlyOnceWith(CHILD, { kind: 'user', parentSessionId: PARENT })
// No parent-registry, catalog, or history dependency: this is what keeps a
// live child interruptible after its parent Agent went offline.
expect(getAgent).not.toHaveBeenCalled()
expect(listChildren).not.toHaveBeenCalled()
expect(inspect).not.toHaveBeenCalled()
})
it('maps interrupt authorization rejection without touching other services', async () => {
const { api, listChildren } = bench({
interruptError: new SubagentError('secret lineage', 'UNAUTHORIZED'),
})
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.result).toEqual({
ok: false,
error: {
code: 'subagent-unauthorized',
message: 'subagent does not belong to this parent',
details: { childSessionId: CHILD },
},
})
expect(listChildren).not.toHaveBeenCalled()
})
it('hides unexpected interrupt failures behind the internal code', async () => {
const { api } = bench({ interruptError: new Error('secret activation state') })
const response = await api.subagents.interrupt(request({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const,
}))
expect(response.result).toEqual({
ok: false,
error: { code: 'internal', message: 'subagent interrupt failed', details: {} },
})
})
})

View File

@@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: num
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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
const collected = collect(stream, 9, abort)
@@ -170,7 +170,7 @@ describe('mux live view computation', () => {
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const session = ctx.sessions.create()
// history resolves the agent first; a live structural stub is enough (only
// .session is read on this path).
@@ -236,9 +236,9 @@ describe('mux live view computation', () => {
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
})
it('counts only append-origin messages toward maxMessages and keeps compaction provenance whole', async () => {
it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ 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 })
@@ -247,7 +247,7 @@ describe('mux live view computation', () => {
const third = appendUserText(session, 'second prompt')
appendAssistantText(session, 'second reply', 2)
const shadowed = [...session.surface.nodes]
// A compaction transaction: log-only provenance immediately followed by the
// A compaction transaction: a log-only summary record immediately followed by the
// replacement that shadows the range.
const summary = appendExtension(session, 'compact/summary', {
summary: [{ type: 'text', text: 'summary' }],
@@ -277,7 +277,7 @@ describe('mux live view computation', () => {
expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3])
expect(page.some(event => event.seq === first.seq)).toBe(false)
expect(response.result.value.hasMore).toBe(true)
// The range stays contiguous, so the checkpoint's provenance is readable on
// The range stays contiguous, so the checkpoint's summary record is readable on
// the same page as the checkpoint itself.
const summaryIndex = page.findIndex(event => event.seq === summary.seq)
expect(summaryIndex).toBeGreaterThan(-1)
@@ -287,7 +287,7 @@ describe('mux live view computation', () => {
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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
@@ -308,7 +308,7 @@ describe('mux live view computation', () => {
it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
const { ctx } = await harness()
const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
const collected = collect(stream, 4, abort)

View File

@@ -100,7 +100,7 @@ async function harness(
// object per harness mirrors the seam's stability contract.
ctx.provide('directoryPicker', { capability: () => picker } as never)
const api = createApiProxy(ctx, {
defaultTarget: () => ({ provider: 'test', model: 'test-model' }),
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd: workspaceRoot,
workspaceRoot,
...extras.openPath === undefined ? {} : { openPath: extras.openPath },

View File

@@ -41,7 +41,7 @@ function scriptedApi(overrides: {
history: r => ok(r, {
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
models: r => ok(r, {
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
@@ -67,6 +67,7 @@ function scriptedApi(overrides: {
list: r => ok(r, { entries: [], parentAvailable: false }),
history: r => ok(r, { events: [], hasMore: false }),
prompt: r => ok(r, { messageId: 'message-1' as never }),
interrupt: r => ok(r, { accepted: true as const }),
...overrides.subagents,
},
host: {
@@ -252,6 +253,32 @@ describe('unary round trip', () => {
}
})
it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => {
const interrupt = vi.fn((r: RpcRequest<unknown>) => ok(r, { accepted: true as const }))
const api = scriptedApi({ subagents: { interrupt } })
const c = client(api)
const accepted = await c.subagents.interrupt({
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable',
})
expect(accepted.result).toEqual({ ok: true, value: { accepted: true } })
expect(interrupt).toHaveBeenCalledTimes(1)
// The wire schema owns the mode fence: a one-shot address never reaches the impl.
const oneShot = await c.subagents.interrupt({
parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'one-shot',
} as never)
expect(oneShot.result.ok).toBe(false)
if (!oneShot.result.ok) expect(oneShot.result.error.code).toBe('bad-request')
const incomplete = await c.subagents.interrupt({
parentSessionId: sid('parent'), mode: 'continuable',
} as never)
expect(incomplete.result.ok).toBe(false)
if (!incomplete.result.ok) expect(incomplete.result.error.code).toBe('bad-request')
expect(interrupt).toHaveBeenCalledTimes(1)
})
it('rejects a method/path mismatch as bad-request', async () => {
const handler = toFetchHandler(scriptedApi())
const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }

View File

@@ -134,6 +134,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
result: { ok: true, value: { messageId: 'message-1' as never } },
}
},
async interrupt(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } }
},
},
host: {
async describe(request) {
@@ -440,6 +443,11 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
mode: 'continuable',
content: [],
})).result).toEqual({ ok: true, value: { messageId: 'message-1' } })
expect((await c.subagents.interrupt({
parentSessionId: 'parent' as never,
childSessionId: 'child' as never,
mode: 'continuable',
})).result).toEqual({ ok: true, value: { accepted: true } })
})
it('keeps caller and connection aborts on command.execute', async () => {

View File

@@ -192,7 +192,7 @@ describe('sessions domain schemas', () => {
expect(sessionHistoryValueSchema.parse({
events: [],
hasMore: false,
modelTarget: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}).hasMore).toBe(false)
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionModelsValueSchema.parse({