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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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', {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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: {} },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: {} }
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user