Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id
# Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/coding-agent/tests/code-mode.e2e.ts # examples/coding-agent/tests/coding-task.e2e.ts # examples/coding-agent/tests/compaction.e2e.ts # examples/coding-agent/tests/full-loop.e2e.ts # examples/coding-agent/tests/todo-write.e2e.ts # examples/cordis-agent/tests/cordis-tools.e2e.ts # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/cordis/tool-cordis/tests/integration.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/src/index.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/config-session-id.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/coverage-edges.spec.ts # packages/core/agent-loop/tests/interception.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent-loop/tests/request-cache.e2e.ts # packages/core/agent-loop/tests/request-reconstruction.spec.ts # packages/core/agent-loop/tests/resume.spec.ts # packages/core/agent-loop/tests/scope-lifecycle.spec.ts # packages/core/agent-loop/tests/tool-order.spec.ts # packages/core/agent-loop/tests/turn-stop.spec.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/agent-spine-demo/tests/agent-core.spec.ts # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/examples/stdio-demo/tests/stdio-agent.spec.ts # packages/fs/tool-fs/tests/fs-tools.e2e.ts # packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/hooks/hooks-codex/tests/bridge.spec.ts # packages/hooks/hooks-codex/tests/coverage.spec.ts # packages/subagent/subagent-fork/tests/multi-subagent.spec.ts # packages/subagent/subagent-fork/tests/subagent-fork.spec.ts # packages/subagent/subagent-inprocess/tests/structured.spec.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/subagent/subagent-spawn/tests/spawn.e2e.ts # packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts # packages/todo/tool-todo/tests/integration.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/workflow/workflow-workerthread/tests/integration.spec.ts
This commit is contained in:
@@ -40,6 +40,26 @@ function permissionOption(currentValue: string): object {
|
||||
}
|
||||
}
|
||||
|
||||
function modelValue(provider = 'mock', model = 'mock'): string {
|
||||
return JSON.stringify([provider, model])
|
||||
}
|
||||
|
||||
function modelOption(currentValue = modelValue()): object {
|
||||
return {
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'Sets this session\'s provider and model.',
|
||||
category: 'model',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [{ value: modelValue(), name: 'Mock' }],
|
||||
}
|
||||
}
|
||||
|
||||
function optionsWithPermission(currentValue: string): object[] {
|
||||
return [modelOption(), permissionOption(currentValue)]
|
||||
}
|
||||
|
||||
describe('acp bridge — session config options', () => {
|
||||
let storageDir: string
|
||||
let h: BridgeHarness | undefined
|
||||
@@ -64,19 +84,111 @@ describe('acp bridge — session config options', () => {
|
||||
return harness
|
||||
}
|
||||
|
||||
it('advertises no configOptions without the permission service — even with both knobs composed', async () => {
|
||||
it('advertises the model selector without requiring the permission service', async () => {
|
||||
h = await makeBridgeHarness({ storageDir })
|
||||
await h.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
|
||||
await h.ctx.plugin(ApprovalService)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toBeUndefined()
|
||||
expect(res.configOptions).toEqual([modelOption()])
|
||||
})
|
||||
|
||||
it('groups models by provider and switches routing plus prompt variables as one session target', async () => {
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('ok')],
|
||||
config: { provider: 'alpha', model: 'a1' },
|
||||
persona: 'Route {{provider}} / {{model}}',
|
||||
catalog: {
|
||||
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }],
|
||||
models: [
|
||||
{ provider: 'alpha', id: 'a1', name: 'Alpha One', description: 'Fast' },
|
||||
{ provider: 'beta', id: 'b1', name: 'Beta One' },
|
||||
],
|
||||
},
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const created = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(created.configOptions).toEqual([{
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'Sets this session\'s provider and model.',
|
||||
category: 'model',
|
||||
type: 'select',
|
||||
currentValue: modelValue('alpha', 'a1'),
|
||||
options: [
|
||||
{ group: 'alpha', name: 'Alpha', options: [{ value: modelValue('alpha', 'a1'), name: 'Alpha One', description: 'Fast' }] },
|
||||
{ group: 'beta', name: 'Beta', options: [{ value: modelValue('beta', 'b1'), name: 'Beta One' }] },
|
||||
],
|
||||
}])
|
||||
|
||||
const switched = await h.client.setSessionConfigOption({
|
||||
sessionId: created.sessionId,
|
||||
configId: 'model',
|
||||
value: modelValue('beta', 'b1'),
|
||||
})
|
||||
expect(switched.configOptions?.[0]).toMatchObject({ currentValue: modelValue('beta', 'b1') })
|
||||
await h.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'use beta' }] })
|
||||
expect(h.adapter.requests[0]).toMatchObject({
|
||||
provider: 'beta',
|
||||
model: 'b1',
|
||||
})
|
||||
expect(h.adapter.requests[0]?.system).toContain('Route beta / b1')
|
||||
expect(h.ctx.agents.list()[0]?.session.requestHeader()?.config).toMatchObject({ provider: 'beta', model: 'b1' })
|
||||
})
|
||||
|
||||
it('adds the configured private model to an advisory catalog and ignores empty non-current groups', async () => {
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
config: { provider: 'alpha', model: 'private-model' },
|
||||
catalog: {
|
||||
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'empty', name: 'Empty' }],
|
||||
models: [{ provider: 'alpha', id: 'public-model', name: 'Public Model' }],
|
||||
},
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions?.[0]).toMatchObject({
|
||||
currentValue: modelValue('alpha', 'private-model'),
|
||||
options: [
|
||||
{ value: modelValue('alpha', 'public-model'), name: 'Public Model' },
|
||||
{ value: modelValue('alpha', 'private-model'), name: 'private-model' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits model selection without a complete or registered current target', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, config: { model: undefined } })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const missing = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(missing.configOptions).toBeUndefined()
|
||||
await h.dispose()
|
||||
|
||||
h = await makeBridgeHarness({ storageDir, config: { provider: 'unregistered', model: 'm' } })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const unknown = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(unknown.configOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves model-less agents available to another agent/request supplier', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, config: { model: undefined }, script: [textResponse('ok')] })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = h.ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('expected an agent')
|
||||
agent.ctx.on('agent/request', async (_agent, _turn, _step, callConfig, _next) => ({
|
||||
...callConfig,
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
}))
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'supplied elsewhere' }] })
|
||||
expect(h.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' })
|
||||
})
|
||||
|
||||
it('advertises the Permissions select with the default preset current', async () => {
|
||||
h = await presetStack()
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
expect(res.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
})
|
||||
|
||||
it('an idle switch is pending (overlaid, not yet logged), then anchors inside the next prompt\'s turn', async () => {
|
||||
@@ -84,7 +196,7 @@ describe('acp bridge — session config options', () => {
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const after = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(after.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
expect(after.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
|
||||
const session = h.ctx.agents.list()[0]?.session
|
||||
expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
|
||||
@@ -105,7 +217,7 @@ describe('acp bridge — session config options', () => {
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const again = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(again.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
expect(again.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
|
||||
@@ -119,7 +231,7 @@ describe('acp bridge — session config options', () => {
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const back = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(back.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
expect(back.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
|
||||
@@ -129,10 +241,10 @@ describe('acp bridge — session config options', () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(echo.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
expect(echo.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(repeat.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
expect(repeat.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }])
|
||||
@@ -167,6 +279,8 @@ describe('acp bridge — session config options', () => {
|
||||
// This composition never advertised `permission`.
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }))
|
||||
.rejects.toThrow(/unknown permission value/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'model', value: modelValue('mock', 'missing') }))
|
||||
.rejects.toThrow(/unknown model value/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', type: 'boolean', value: true }))
|
||||
.rejects.toThrow(/select; boolean values are not accepted/)
|
||||
})
|
||||
@@ -184,9 +298,31 @@ describe('acp bridge — session config options', () => {
|
||||
const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(bAfter.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
expect(bAfter.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(aAfter.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
expect(aAfter.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
})
|
||||
|
||||
it('keeps model targets isolated across concurrent sessions', async () => {
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('a'), textResponse('b')],
|
||||
config: { provider: 'mock', model: 'one' },
|
||||
catalog: {
|
||||
providers: [{ id: 'mock', name: 'Mock' }],
|
||||
models: [
|
||||
{ provider: 'mock', id: 'one', name: 'One' },
|
||||
{ provider: 'mock', id: 'two', name: 'Two' },
|
||||
],
|
||||
},
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'model', value: modelValue('mock', 'two') })
|
||||
await h.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: 'a' }] })
|
||||
await h.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: 'b' }] })
|
||||
expect(h.adapter.requests.map(request => request.model)).toEqual(['two', 'one'])
|
||||
})
|
||||
|
||||
it('a knob drifted outside the table derives a visible-but-untargetable custom current', async () => {
|
||||
@@ -199,12 +335,12 @@ describe('acp bridge — session config options', () => {
|
||||
agent.session.append('bash/sandbox-mode', { mode: 'read-only' })
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' })
|
||||
const option = echo.configOptions?.[0]
|
||||
const option = echo.configOptions?.find(entry => entry.id === 'permission')
|
||||
expect(option).toMatchObject({ currentValue: 'custom' })
|
||||
if (option === undefined || !('options' in option)) throw new Error('expected a select option')
|
||||
expect(option.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access', 'custom'])
|
||||
const away = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const afterOption = away.configOptions?.[0]
|
||||
const afterOption = away.configOptions?.find(entry => entry.id === 'permission')
|
||||
expect(afterOption).toMatchObject({ currentValue: 'danger-full-access' })
|
||||
if (afterOption === undefined || !('options' in afterOption)) throw new Error('expected a select option')
|
||||
expect(afterOption.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access'])
|
||||
@@ -223,6 +359,52 @@ describe('acp bridge — session config options', () => {
|
||||
|
||||
loader = await presetStack()
|
||||
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
expect(res.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
})
|
||||
|
||||
it('session/load restores the last requested provider/model from the request header', async () => {
|
||||
const catalog = {
|
||||
providers: [{ id: 'mock', name: 'Mock' }],
|
||||
models: [
|
||||
{ provider: 'mock', id: 'one', name: 'One' },
|
||||
{ provider: 'mock', id: 'two', name: 'Two' },
|
||||
],
|
||||
}
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('ok')],
|
||||
config: { provider: 'mock', model: 'one' },
|
||||
catalog,
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'model', value: modelValue('mock', 'two') })
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist target' }] })
|
||||
await h.dispose()
|
||||
h = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, config: { provider: 'mock', model: 'one' }, catalog })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const loaded = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(loaded.configOptions?.find(option => option.id === 'model')).toMatchObject({
|
||||
currentValue: modelValue('mock', 'two'),
|
||||
})
|
||||
})
|
||||
|
||||
it('session/load omits config options when the persisted session has no target or permission service', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, config: { model: undefined } })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = h.ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('expected an agent')
|
||||
agent.inject([{ type: 'text', text: 'checkpoint' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
await agent.whenIdle()
|
||||
await h.dispose()
|
||||
h = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, config: { model: undefined } })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const loaded = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(loaded.configOptions).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -229,10 +229,10 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
// queryable, with its session still in the store.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
const handleA = await harness.ctx.agents.create({
|
||||
sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' },
|
||||
sessionId: SessionId('sib-a'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const handleB = await harness.ctx.agents.create({
|
||||
sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' },
|
||||
sessionId: SessionId('sib-b'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
expect(harness.ctx.agents.get(SessionId('sib-a'))).toBe(handleA.agent)
|
||||
expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
|
||||
@@ -261,7 +261,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
||||
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
|
||||
const handle = await harness.ctx.agents.create({
|
||||
sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' },
|
||||
sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await handle.agent.whenIdle()
|
||||
@@ -282,7 +282,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
// observe the same quiescence boundary.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
const handle = await harness.ctx.agents.create({
|
||||
sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' },
|
||||
sessionId: SessionId('conc-a'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
// Drive a turn that hangs in the model stream, so the loop is mid-turn when
|
||||
// disposed — its exit runs a final session/flush we can gate to hold the
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('acp bridge — demux & config edges', () => {
|
||||
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const before = harness.updates.length
|
||||
|
||||
const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } })
|
||||
const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
foreign.send([{ type: 'text', text: 'hi' }])
|
||||
await foreign.whenIdle()
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
@@ -5,13 +5,10 @@
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
@@ -39,10 +36,24 @@ import { type AcpConfig } from '../src/index.ts'
|
||||
/** A scripted mock adapter (mirrors the agent-loop test adapter). */
|
||||
class MockAdapter extends LlmAdapter {
|
||||
requests: GenerateOptions[] = []
|
||||
constructor(private script: (StreamChunk[] | 'hang')[]) {
|
||||
constructor(
|
||||
private script: (StreamChunk[] | 'hang')[],
|
||||
private readonly providers: readonly LlmProviderInfo[],
|
||||
private readonly models: readonly LlmModelInfo[],
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
const info = this.providers.find(entry => entry.id === provider)
|
||||
if (info === undefined) throw new Error(`MockAdapter: unknown provider ${provider}`)
|
||||
return info
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve(this.models.filter(model => model.provider === provider))
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const entry = this.script.shift()
|
||||
@@ -142,6 +153,9 @@ export interface BridgeHarness {
|
||||
storageDir: string
|
||||
}
|
||||
|
||||
/** Test-only overrides preserve explicit undefined to suppress harness defaults. */
|
||||
type AcpConfigOverrides = { [K in keyof AcpConfig]?: AcpConfig[K] | undefined }
|
||||
|
||||
/**
|
||||
* Build the bridge + a connected client over an in-memory transport pair.
|
||||
*
|
||||
@@ -150,12 +164,13 @@ export interface BridgeHarness {
|
||||
* The bridge's `apply` receives the agent-side `Stream` via `config.stream`;
|
||||
* the test holds the `ClientSideConnection`.
|
||||
*
|
||||
* Pass `config: { model: undefined }` to override the default `model: 'mock'`
|
||||
* (the model key is dropped entirely when explicitly undefined).
|
||||
* Pass an explicit undefined route field to suppress its mock default.
|
||||
*/
|
||||
export async function makeBridgeHarness(options: {
|
||||
script?: (StreamChunk[] | 'hang')[]
|
||||
config?: Partial<AcpConfig>
|
||||
config?: AcpConfigOverrides
|
||||
/** Provider-neutral directory exposed to ACP model-selection tests. */
|
||||
catalog?: { providers: LlmProviderInfo[]; models: LlmModelInfo[] }
|
||||
/** Deployment persona for the tree (the system-prompt plugin's config). */
|
||||
persona?: string
|
||||
storageDir: string
|
||||
@@ -185,14 +200,16 @@ export async function makeBridgeHarness(options: {
|
||||
withFs?: boolean
|
||||
fsCwd?: string
|
||||
} = { storageDir: '' }): Promise<BridgeHarness> {
|
||||
const adapter = new MockAdapter(options.script ?? [])
|
||||
const catalog = options.catalog ?? {
|
||||
providers: [{ id: 'mock', name: 'Mock' }],
|
||||
models: [{ provider: 'mock', id: 'mock', name: 'Mock' }],
|
||||
}
|
||||
const adapter = new MockAdapter(options.script ?? [], catalog.providers, catalog.models)
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: options.persona ?? '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await mountAgentLoopTestDependencies(ctx, {
|
||||
systemPrompt: { persona: options.persona ?? '' },
|
||||
})
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
|
||||
await ctx.plugin(UserInteractionService)
|
||||
@@ -211,7 +228,7 @@ export async function makeBridgeHarness(options: {
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs)
|
||||
}
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.llm.registerAdapter(catalog.providers.map(provider => provider.id), adapter)
|
||||
|
||||
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the agent writes flow
|
||||
// to the client's reader and vice versa. (ndJsonStream takes (output, input): the agent
|
||||
@@ -271,9 +288,9 @@ export async function makeBridgeHarness(options: {
|
||||
},
|
||||
})
|
||||
|
||||
// Default to `mock` only when the caller omitted the key; explicit `model: undefined` means no
|
||||
// model and must survive the object spread.
|
||||
const cfg: AcpConfig = { stream: agentStream, ...options.config }
|
||||
// Default route fields only when the caller omitted them; explicit undefined values must survive.
|
||||
const cfg = { stream: agentStream, ...options.config } as AcpConfig
|
||||
if (!(options.config && 'provider' in options.config)) cfg.provider = 'mock'
|
||||
if (!(options.config && 'model' in options.config)) cfg.model = 'mock'
|
||||
// Mount the bridge the way production does: as a cordis plugin (via `ctx.plugin` with the
|
||||
// real `inject`), not `AcpPlugin.apply(ctx, cfg)` on the ungated root. Later JSON-RPC callbacks run
|
||||
|
||||
@@ -794,5 +794,6 @@ describe('agentOptions', () => {
|
||||
it('includes only the fields present in config', () => {
|
||||
expect(agentOptions({})).toEqual({})
|
||||
expect(agentOptions({ model: 'm' })).toEqual({ model: 'm' })
|
||||
expect(agentOptions({ provider: 'p', model: 'm' })).toEqual({ provider: 'p', model: 'm' })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user