|
|
|
|
@@ -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, _signal, _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()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|