feat(acp): advertise and switch llm models

This commit is contained in:
Yichen Jiang
2026-07-15 13:33:42 +08:00
parent 75331f03a6
commit f1d39921c9
94 changed files with 1657 additions and 191 deletions

View File

@@ -130,7 +130,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
summary: 'The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.',
methods: [
'registerAdapter(providers: string[], adapter: LlmAdapter): () => void',
'providers(): string[]',
'listProviders(): LlmProviderInfo[]',
'async listModels(provider: string): Promise<LlmModelInfo[]>',
'stream(options: GenerateOptions): AsyncIterable<StreamChunk>',
],
},
@@ -741,6 +742,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'HookContext',
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n}',
},
{
name: 'LlmModelInfo',
declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}',
},
{
name: 'LlmProviderInfo',
declaration: 'export interface LlmProviderInfo {\n id: string;\n name: string;\n}',
},
{
name: 'Message',
declaration: 'export interface Message {\n role: \'system\' | \'user\' | \'assistant\';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n}',

View File

@@ -381,7 +381,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
expect(() => ctx.llm.registerAdapter(['m1'], new MockAdapter([])))
.toThrow('already registered')
// the original registration survives the failed attempt
expect(ctx.llm.providers()).toEqual(['m1'])
expect(ctx.llm.listProviders()).toEqual([{ id: 'm1', name: 'm1' }])
})
it('an agent without a model fails the step with a clear error (not NO_ADAPTER for "default")', async () => {

View File

@@ -14,9 +14,14 @@ A second, library-backed implementation of the same seam exists in `@deepseek-ai
baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com
thinking: enabled # optional; provider default is enabled
reasoningEffort: high # optional; high | max — omitted ⇒ not sent
models: # optional; defaults to V4 Flash and V4 Pro
- id: deepseek-v4-flash
name: DeepSeek V4 Flash
- id: private-reasoner
description: Company-hosted reasoning model
```
The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.
The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.
`reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default).

View File

@@ -6,13 +6,23 @@
*/
import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { serializeRequest } from './serialize.ts'
import type { RequestDefaults } from './serialize.ts'
import { parseSse } from './sse.ts'
import { translate } from './translate.ts'
import type { WireError } from './types.ts'
/** One optional model entry advertised by the hand-written adapter. */
export interface DeepSeekCatalogModel {
/** Wire model id accepted by the configured endpoint. */
id: string
/** Selector label; defaults to {@link id}. */
name?: string
/** Optional selector detail for deployments with similar model variants. */
description?: string
}
/** Constructor options for {@link DeepSeekAdapter}; the plugin's `apply` resolves them from Config + environment. */
export interface DeepSeekAdapterOptions {
/** Bearer token sent in the `authorization` header on every request. */
@@ -21,6 +31,8 @@ export interface DeepSeekAdapterOptions {
baseURL: string
/** Request defaults applied to every call (thinking mode, effort). */
defaults?: RequestDefaults
/** Advisory models exposed to discovery consumers; requests remain unrestricted. */
models?: readonly DeepSeekCatalogModel[]
}
/**
@@ -49,6 +61,19 @@ export class DeepSeekAdapter extends LlmAdapter {
super()
}
override providerInfo(provider: string): LlmProviderInfo {
return { id: provider, name: 'DeepSeek' }
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve((this.options.models ?? []).map(model => ({
provider,
id: model.id,
name: model.name ?? model.id,
...model.description === undefined ? {} : { description: model.description },
})))
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const body = serializeRequest(options, this.options.defaults ?? {})

View File

@@ -9,9 +9,10 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-llm'
import { DeepSeekAdapter } from './adapter.ts'
import type { DeepSeekCatalogModel } from './adapter.ts'
export { DeepSeekAdapter, httpErrorCode } from './adapter.ts'
export type { DeepSeekAdapterOptions } from './adapter.ts'
export type { DeepSeekAdapterOptions, DeepSeekCatalogModel } from './adapter.ts'
export { serializeMessages, serializeRequest } from './serialize.ts'
export type { RequestDefaults } from './serialize.ts'
export { DONE, parseSse } from './sse.ts'
@@ -21,6 +22,11 @@ export type * from './types.ts'
export const name = 'llm-deepseek'
export const inject = ['llm']
const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
{ id: 'deepseek-v4-flash' },
{ id: 'deepseek-v4-pro' },
]
/**
* Plugin config, validated by the same-named schemastery schema. Every field
* is optional in yml: credentials/endpoint fall back to the environment (a
@@ -36,18 +42,45 @@ export interface Config {
thinking?: 'enabled' | 'disabled'
/** Thinking effort (only meaningful with thinking enabled). */
reasoningEffort?: 'high' | 'max'
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
models?: DeepSeekCatalogModel[]
}
const catalogModel: z<DeepSeekCatalogModel> = z.object({
id: z.string().required(),
name: z.string(),
description: z.string(),
})
export const Config: z<Config> = z.object({
apiKey: z.string(),
baseURL: z.string(),
thinking: z.union(['enabled', 'disabled']),
reasoningEffort: z.union(['high', 'max']),
models: z.array(catalogModel).default(DEFAULT_MODELS),
})
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
/** Resolve, validate, and detach the advisory model catalog. */
function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] {
const seen = new Set<string>()
return (models ?? DEFAULT_MODELS).map((model) => {
if (model.id.length === 0) throw new Error('llm-deepseek: catalog model ids must be non-empty')
if (model.name !== undefined && model.name.length === 0) {
throw new Error(`llm-deepseek: catalog model "${model.id}" has an empty name`)
}
if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`)
seen.add(model.id)
return {
id: model.id,
...model.name === undefined ? {} : { name: model.name },
...model.description === undefined ? {} : { description: model.description },
}
})
}
export function apply(ctx: Context, config: Config): void {
const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY
if (apiKey === undefined || apiKey.length === 0) {
@@ -61,5 +94,6 @@ export function apply(ctx: Context, config: Config): void {
thinking: config.thinking,
reasoningEffort: config.reasoningEffort,
},
models: resolveModels(config.models),
}))
}

View File

@@ -249,16 +249,73 @@ describe('plugin registration and config', () => {
apiKey: 'k',
baseURL: server.url,
})
expect(ctx.llm.providers()).toEqual(['deepseek'])
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
await fiber.dispose()
expect(ctx.llm.providers()).toEqual([])
expect(ctx.llm.listProviders()).toEqual([])
})
it('always owns the deepseek provider', async () => {
it('owns the deepseek provider and advertises the default models', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
expect(ctx.llm.providers()).toEqual(['deepseek'])
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
])
})
it('uses the default model catalog when apply is called directly', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
])
})
it('advertises configured models without restricting arbitrary request ids', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
models: [
{ id: 'private-fast' },
{ id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' },
],
})
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
{ provider: 'deepseek', id: 'private-fast', name: 'private-fast' },
{ provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' },
])
})
it('allows an explicit empty model catalog', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
models: [],
})
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([])
})
it.each([
[[{ id: '' }], /ids must be non-empty/],
[[{ id: 'm', name: '' }], /empty name/],
[[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/],
] as const)('rejects invalid advisory model config', async (models, message) => {
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
models: [...models],
})).rejects.toThrow(message)
expect(ctx.llm.listProviders()).toEqual([])
})
it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => {
@@ -267,7 +324,7 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, {})
expect(ctx.llm.providers()).toEqual(['deepseek'])
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
})
it('throws a clear error when no API key is available', async () => {
@@ -276,7 +333,7 @@ describe('plugin registration and config', () => {
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {}))
.rejects.toThrow(/an API key is required/)
expect(ctx.llm.providers()).toEqual([])
expect(ctx.llm.listProviders()).toEqual([])
})
it('prefers explicit config over env for key and base URL', async () => {
@@ -305,11 +362,12 @@ describe('plugin registration and config', () => {
await ctx.plugin(LlmService)
// Registration succeeds; no call is made (would hit api.deepseek.com).
await ctx.plugin(LlmDeepSeek, {})
expect(ctx.llm.providers()).toEqual(['deepseek'])
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
})
it('adapter is constructible directly for embedding', () => {
it('adapter is constructible directly for embedding', async () => {
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
expect(adapter).toBeInstanceOf(DeepSeekAdapter)
await expect(adapter.listModels('deepseek')).resolves.toEqual([])
})
})

View File

@@ -26,6 +26,8 @@ Configure credentials and deployment-specific transport settings per provider. O
Each provider name must exist in pi-ai's installed catalog and may appear only once in this plugin instance. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry.
Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `maxRetries`, and `maxRetryDelayMs`. They map to pi-ai's common stream options. Harness app attribution wins a conflicting configured header name.
## Provider/model routing and replay

View File

@@ -15,7 +15,7 @@ import type {
SimpleStreamOptions,
} from '@earendil-works/pi-ai'
import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { PiAiProviderProfile } from './config.ts'
import { toPiContext } from './context.ts'
import { toStreamChunks } from './stream.ts'
@@ -65,6 +65,18 @@ export class PiAiAdapter extends LlmAdapter {
this.profiles = new Map(options.profiles.map(profile => [profile.provider, profile]))
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
const profile = this.profiles.get(provider)
if (profile === undefined) {
return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER'))
}
return Promise.resolve(getModels(profile.provider as KnownProvider).map(model => ({
provider,
id: model.id,
name: model.name,
})))
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
if (options.stop !== undefined) {
throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION')

View File

@@ -186,9 +186,23 @@ describe('provider profile lifecycle', () => {
const fiber = await ctx.plugin(LlmPiAi, {
providers: [{ provider: 'openai' }, { provider: 'anthropic' }],
})
expect(ctx.llm.providers()).toEqual(['openai', 'anthropic'])
expect(ctx.llm.listProviders()).toEqual([
{ id: 'openai', name: 'openai' },
{ id: 'anthropic', name: 'anthropic' },
])
await fiber.dispose()
expect(ctx.llm.providers()).toEqual([])
expect(ctx.llm.listProviders()).toEqual([])
})
it('exposes the installed pi-ai model catalog through provider-neutral metadata', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai' }] })
const models = await ctx.llm.listModels('openai')
expect(models.find(model => model.id === 'gpt-4.1')).toEqual({
provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1',
})
expect(models.every(model => model.provider === 'openai')).toBe(true)
})
it('accepts absent credentials for pi-ai ambient authentication', async () => {
@@ -210,6 +224,7 @@ describe('provider profile lifecycle', () => {
it('constructs the adapter directly and rejects routes it does not own', async () => {
const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] })
await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
await expect((async () => {
for await (const _chunk of adapter.stream({ provider: 'anthropic', model: 'claude-sonnet-4', messages: [] })) { /* drain */ }
})()).rejects.toMatchObject({ code: 'NO_ADAPTER' })

View File

@@ -9,9 +9,12 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
### Public API
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber.
- `ctx.llm.providers(): string[]` provider routes with a registered adapter.
- `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order.
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`.
Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
### Events
| Event | Mode | Purpose |
@@ -20,7 +23,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
### Extension points
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically.
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata; their defaults use the route id as its name and advertise no models.
- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc.
### Content-block vocabulary (`types.ts`)

View File

@@ -7,7 +7,7 @@
*/
import { Context, Service } from 'cordis'
import type { GenerateOptions, Message, StreamChunk } from './types.ts'
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts'
import { HarnessError } from './error.ts'
import { deepFreeze } from './call-config.ts'
@@ -61,6 +61,26 @@ export class LlmError extends HarnessError {
* DeepSeek and pi-ai adapters intentionally exercise this contract through different internals.
*/
export abstract class LlmAdapter {
/**
* Describe one provider route owned by this adapter.
* @param provider - a route passed to `registerAdapter()` for this instance.
* @returns detached display metadata whose id must equal `provider`.
*/
providerInfo(provider: string): LlmProviderInfo {
return { id: provider, name: provider }
}
/**
* List models this adapter can currently advertise for one owned provider.
* The result is advisory: an adapter may accept unlisted model ids, and
* consumers must not turn absence into request rejection.
* @param _provider - one provider route owned by this adapter.
* @returns discoverable models in adapter-preferred order.
*/
listModels(_provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve([])
}
/**
* Stream one model call as raw chunks. The only required method.
* @param options - the fully-assembled request; implementations must honor `options.signal`.
@@ -74,7 +94,7 @@ export abstract class LlmAdapter {
* surface, interceptable via the `llm/stream` waterfall.
*/
export class LlmService extends Service {
private adapters = new Map<string, LlmAdapter>()
private adapters = new Map<string, { adapter: LlmAdapter; provider: LlmProviderInfo }>()
constructor(ctx: Context) {
super(ctx, 'llm')
@@ -92,14 +112,20 @@ export class LlmService extends Service {
const dispose = this.ctx.effect(function* (this: LlmService) {
if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER')
const unique = new Set<string>()
const registrations: { adapter: LlmAdapter; provider: LlmProviderInfo }[] = []
for (const provider of providers) {
if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER')
if (unique.has(provider) || this.adapters.has(provider)) {
throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER')
}
const info = adapter.providerInfo(provider)
if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) {
throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER')
}
unique.add(provider)
registrations.push({ adapter, provider: { id: info.id, name: info.name } })
}
for (const provider of providers) this.adapters.set(provider, adapter)
for (const registration of registrations) this.adapters.set(registration.provider.id, registration)
yield () => {
for (const provider of providers) this.adapters.delete(provider)
}
@@ -110,17 +136,50 @@ export class LlmService extends Service {
}
/**
* Provider routes with a registered adapter.
* @returns the registered provider names, in registration order.
* Describe provider routes with a registered adapter.
* @returns detached provider metadata in registration order.
*/
providers(): string[] {
return [...this.adapters.keys()]
listProviders(): LlmProviderInfo[] {
return [...this.adapters.values()].map(({ provider }) => ({ ...provider }))
}
private adapter(provider: string): LlmAdapter {
const adapter = this.adapters.get(provider)
if (!adapter) throw new LlmError(`no adapter registered for provider "${provider}"`, 'NO_ADAPTER')
return adapter
/**
* Discover models advertised by one registered provider. Catalog membership
* is advisory and never changes routing or request validation.
* @param provider - registered provider route to inspect.
* @returns detached model metadata in adapter-preferred order.
*/
async listModels(provider: string): Promise<LlmModelInfo[]> {
const adapter = this.registration(provider).adapter
const models = await adapter.listModels(provider)
const seen = new Set<string>()
return models.map((model) => {
if (
typeof model.provider !== 'string'
|| model.provider !== provider
|| typeof model.id !== 'string'
|| model.id.length === 0
|| typeof model.name !== 'string'
|| model.name.length === 0
|| (model.description !== undefined && typeof model.description !== 'string')
|| seen.has(model.id)
) {
throw new LlmError(`adapter returned invalid or duplicate model metadata for provider "${provider}"`, 'INVALID_CATALOG')
}
seen.add(model.id)
return {
provider: model.provider,
id: model.id,
name: model.name,
...model.description === undefined ? {} : { description: model.description },
}
})
}
private registration(provider: string): { adapter: LlmAdapter; provider: LlmProviderInfo } {
const registration = this.adapters.get(provider)
if (!registration) throw new LlmError(`no adapter registered for provider "${provider}"`, 'NO_ADAPTER')
return registration
}
/** Remove replay state whose historical route is owned by another adapter. */
@@ -128,7 +187,7 @@ export class LlmService extends Service {
const messages: Message[] = options.messages.map((message) => {
const provenance = message.provenance
if (message.role !== 'assistant' || provenance?.replayState === undefined) return message
if (this.adapters.get(provenance.provider) === adapter) return message
if (this.adapters.get(provenance.provider)?.adapter === adapter) return message
return {
...message,
provenance: { provider: provenance.provider, model: provenance.model },
@@ -150,7 +209,7 @@ export class LlmService extends Service {
*/
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
return this.ctx.waterfall(this, 'llm/stream', options, () => {
const adapter = this.adapter(options.provider)
const adapter = this.registration(options.provider).adapter
return adapter.stream(this.forAdapter(options, adapter))
})
}

View File

@@ -121,6 +121,26 @@ export interface TokenUsage {
reasoningTokens?: number
}
/** Display metadata for one registered provider route. */
export interface LlmProviderInfo {
/** Provider route key used by {@link GenerateOptions.provider}. */
id: string
/** Human-readable provider name for selectors and diagnostics. */
name: string
}
/** One adapter-discovered model; catalog membership is advisory, not request validation. */
export interface LlmModelInfo {
/** Provider route that owns this model entry. */
provider: string
/** Model id passed to {@link GenerateOptions.model}. */
id: string
/** Human-readable model name for selectors. */
name: string
/** Optional user-facing distinction from otherwise similar models. */
description?: string
}
/**
* Raw streaming protocol emitted by adapters.
* Block indexes correlate interleaved deltas, and `block-end` carries the

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { GenerateOptions, LlmAdapter, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
class ScriptedAdapter extends LlmAdapter {
constructor(private script: StreamChunk[]) {
@@ -21,6 +22,23 @@ class RecordingAdapter extends ScriptedAdapter {
}
}
class CatalogAdapter extends ScriptedAdapter {
constructor(
private readonly provider: LlmProviderInfo,
private readonly models: readonly LlmModelInfo[],
) {
super(SCRIPT)
}
override providerInfo(_provider: string): LlmProviderInfo {
return this.provider
}
override listModels(_provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve(this.models)
}
}
const SCRIPT: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: 'hi' },
@@ -53,10 +71,80 @@ describe('LlmService', () => {
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.llm.registerAdapter(['scoped-model'], new ScriptedAdapter(SCRIPT))
}, { inject: ['llm'] }))
expect(ctx.llm.providers()).toEqual(['scoped-model'])
expect(ctx.llm.listProviders()).toEqual([{ id: 'scoped-model', name: 'scoped-model' }])
await fiber.dispose()
expect(ctx.llm.providers()).toEqual([])
expect(ctx.llm.listProviders()).toEqual([])
})
it('discovers detached provider and advisory model metadata', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const provider = { id: 'catalog', name: 'Catalog Provider' }
const model = { provider: 'catalog', id: 'fast', name: 'Fast', description: 'Low latency' }
ctx.llm.registerAdapter(['catalog'], new CatalogAdapter(provider, [model]))
const providers = ctx.llm.listProviders()
const models = await ctx.llm.listModels('catalog')
expect(providers).toEqual([provider])
expect(models).toEqual([model])
providers[0]!.name = 'mutated'
models[0]!.name = 'mutated'
provider.name = 'source mutated'
model.name = 'source mutated'
expect(ctx.llm.listProviders()).toEqual([{ id: 'catalog', name: 'Catalog Provider' }])
await expect(ctx.llm.listModels('catalog')).resolves.toEqual([{
provider: 'catalog', id: 'fast', name: 'source mutated', description: 'Low latency',
}])
})
it('defaults adapters to their route name and an empty advisory model list', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['plain'], new ScriptedAdapter(SCRIPT))
expect(ctx.llm.listProviders()).toEqual([{ id: 'plain', name: 'plain' }])
await expect(ctx.llm.listModels('plain')).resolves.toEqual([])
await expect(ctx.llm.listModels('missing')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
})
it.each([
[{ id: 1, name: 'Name' }, 'non-string id'],
[{ id: 'other', name: 'Name' }, 'mismatched id'],
[{ id: 'route', name: 1 }, 'non-string name'],
[{ id: 'route', name: '' }, 'empty name'],
] as const)('rejects invalid provider metadata atomically (%s: %s)', async (metadata, _label) => {
const ctx = new Context()
await ctx.plugin(LlmService)
const adapter = new CatalogAdapter(metadata as unknown as LlmProviderInfo, [])
expect(() => ctx.llm.registerAdapter(['route'], adapter)).toThrow(expect.objectContaining({ code: 'INVALID_ADAPTER' }))
expect(ctx.llm.listProviders()).toEqual([])
})
it.each([
[{ provider: 1, id: 'm', name: 'M' }, 'non-string provider'],
[{ provider: 'other', id: 'm', name: 'M' }, 'mismatched provider'],
[{ provider: 'route', id: 1, name: 'M' }, 'non-string id'],
[{ provider: 'route', id: '', name: 'M' }, 'empty id'],
[{ provider: 'route', id: 'm', name: 1 }, 'non-string name'],
[{ provider: 'route', id: 'm', name: '' }, 'empty name'],
[{ provider: 'route', id: 'm', name: 'M', description: 1 }, 'non-string description'],
] as const)('rejects invalid model metadata (%s: %s)', async (metadata, _label) => {
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['route'], new CatalogAdapter(
{ id: 'route', name: 'Route' },
[metadata as unknown as LlmModelInfo],
))
await expect(ctx.llm.listModels('route')).rejects.toMatchObject({ code: 'INVALID_CATALOG' })
})
it('rejects duplicate model ids in one provider catalog', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const model = { provider: 'route', id: 'same', name: 'Same' }
ctx.llm.registerAdapter(['route'], new CatalogAdapter({ id: 'route', name: 'Route' }, [model, model]))
await expect(ctx.llm.listModels('route')).rejects.toMatchObject({ code: 'INVALID_CATALOG' })
})
it('lets llm/stream waterfall listeners wrap the underlying stream', async () => {
@@ -192,9 +280,9 @@ describe('LlmService', () => {
await ctx.plugin(LlmService)
const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
expect(ctx.llm.providers()).toEqual(['m1'])
expect(ctx.llm.listProviders()).toEqual([{ id: 'm1', name: 'm1' }])
dispose()
expect(ctx.llm.providers()).toEqual([])
expect(ctx.llm.listProviders()).toEqual([])
})
it('rejects duplicate adapter registration with DUPLICATE_ADAPTER code', async () => {
@@ -219,7 +307,7 @@ describe('LlmService', () => {
expect(() => ctx.llm.registerAdapter([], adapter)).toThrow(expect.objectContaining({ code: 'INVALID_ADAPTER' }))
expect(() => ctx.llm.registerAdapter([''], adapter)).toThrow(expect.objectContaining({ code: 'INVALID_ADAPTER' }))
expect(() => ctx.llm.registerAdapter(['first', 'first'], adapter)).toThrow(expect.objectContaining({ code: 'DUPLICATE_ADAPTER' }))
expect(ctx.llm.providers()).toEqual([])
expect(ctx.llm.listProviders()).toEqual([])
})
it('re-registers a model after its prior registration is disposed', async () => {
@@ -227,14 +315,14 @@ describe('LlmService', () => {
await ctx.plugin(LlmService)
const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
expect(ctx.llm.providers()).toEqual(['m1'])
expect(ctx.llm.listProviders()).toEqual([{ id: 'm1', name: 'm1' }])
dispose()
expect(ctx.llm.providers()).toEqual([])
expect(ctx.llm.listProviders()).toEqual([])
// The duplicate check is not wedged: the same model registers cleanly again.
const disposeAgain = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
expect(ctx.llm.providers()).toEqual(['m1'])
expect(ctx.llm.listProviders()).toEqual([{ id: 'm1', name: 'm1' }])
disposeAgain()
expect(ctx.llm.providers()).toEqual([])
expect(ctx.llm.listProviders()).toEqual([])
})
})

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-llm-replay
A replay LLM plugin for keyless snapshot tests. It installs a single `llm/stream` waterfall listener that short-circuits the waterfall (never calls `next()`) and yields model streams reconstructed from a recorded **session JSONL** fixture so a test can boot the real agent against a fixed model transcript with no API key.
A replay LLM plugin for keyless snapshot tests. It yields model streams reconstructed from a recorded **session JSONL** fixture, so a test can boot the real agent against a fixed model transcript with no API key. With `providers` configured it registers a replay-only adapter whose catalog is visible to clients such as ACP editors; without `providers` it installs the catch-all `llm/stream` waterfall used by tests that do not need discovery.
Its consumer is the ACP snapshot harness in `examples/acp-agent`, which loads this plugin (via `cordis.snapshot.yml`) in place of a real LLM adapter. The package exists so its derive/parse/replay logic falls under the per-file 100% coverage gate on `packages/*/src` (the same logic, while it lived under `examples/`, was outside the gate).
@@ -23,10 +23,18 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s
| `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture. Required (config or env). |
| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional path to a `ReplayEntry[]` sidecar that replaces the PRIMARY session's derived script. |
| `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. |
| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Configured routes dispatch through the replay adapter and never perform provider I/O. |
```yaml
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
config:
providers:
- id: deepseek
name: DeepSeek
models:
- id: deepseek-v4-flash
- id: deepseek-v4-pro
# file/overrideFile/childFiles default to $DSH_SNAPSHOT_FILE /
# $DSH_SNAPSHOT_OVERRIDE / $DSH_SNAPSHOT_CHILD_FILES, set by the snapshot
# harness per scenario.
@@ -34,11 +42,11 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s
## Exports
- `installLlmReplay(ctx, config)` — install the `llm/stream` listener; returns the disposer (HMR safety). Use this in tests to drive replay without the Loader or env vars.
- `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns the disposer (HMR safety). Use this in tests to drive replay without the Loader or env vars.
- `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order.
- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the PRIMARY session only (sidecar override if present, else derived from the JSONL; fail-loud if the fixture is missing).
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` — the pure helpers that turn a recorded session log into a script and read its header `id`/`createdAt`. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
- Types `ReplayEntry` / `SessionScript` / `ReplayConfig` / `Config`.
- Types `ReplayEntry` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `Config`.
## Plugin export shape

View File

@@ -10,8 +10,8 @@ import { existsSync, readFileSync } from 'node:fs'
import { delimiter as pathDelimiter } from 'node:path'
import type { Context } from 'cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmError, assertNever } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter, LlmError, assertNever } from '@deepseek-ai/dsh-llm'
/**
* One recorded model call. `throw` may replay prefix chunks before failing;
@@ -23,6 +23,26 @@ export type ReplayEntry =
| { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string; status?: number }
| { kind: 'hang' }
/** One model exposed by a replay-only provider catalog. */
export interface ReplayModelConfig {
/** Model id used for replay requests. */
id: string
/** Selector label; defaults to {@link id}. */
name?: string
/** Optional selector description. */
description?: string
}
/** One provider route exposed by the replay adapter. */
export interface ReplayProviderConfig {
/** Provider route used for replay requests. */
id: string
/** Selector label; defaults to {@link id}. */
name?: string
/** Advisory models exposed to clients such as ACP editors. */
models?: ReplayModelConfig[]
}
/** Resolved plugin configuration. */
export interface ReplayConfig {
/**
@@ -45,6 +65,12 @@ export interface ReplayConfig {
* for a single-session scenario.
*/
childFiles?: string[]
/**
* Optional provider catalog. When non-empty, replay registers an adapter for
* these routes; when absent or empty, it retains the catch-all waterfall used
* by tests that do not need discovery.
*/
providers?: ReplayProviderConfig[]
}
/**
@@ -203,6 +229,42 @@ export function loadSessionScripts(config: ReplayConfig): SessionScript[] {
return [primary, ...children]
}
/** Replay adapter that makes a configured provider catalog discoverable without provider I/O. */
class ReplayAdapter extends LlmAdapter {
private readonly providers: ReadonlyMap<string, ReplayProviderConfig>
constructor(
providers: readonly ReplayProviderConfig[],
private readonly replay: (options: GenerateOptions) => AsyncIterable<StreamChunk>,
) {
super()
this.providers = new Map(providers.map(provider => [provider.id, provider]))
}
override providerInfo(provider: string): LlmProviderInfo {
const configured = this.providers.get(provider)
/* v8 ignore next -- LlmService only asks about routes registered from this same map. */
if (configured === undefined) return super.providerInfo(provider)
return { id: provider, name: configured.name ?? provider }
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
const configured = this.providers.get(provider)
/* v8 ignore next -- LlmService only asks about routes registered from this same map. */
if (configured === undefined) return Promise.resolve([])
return Promise.resolve((configured.models ?? []).map(model => ({
provider,
id: model.id,
name: model.name ?? model.id,
...model.description === undefined ? {} : { description: model.description },
})))
}
override stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
return this.replay(options)
}
}
/** Yield a recorded stream back, honoring abort like a real adapter. */
async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined): AsyncIterable<StreamChunk> {
switch (entry.kind) {
@@ -243,12 +305,14 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
/**
* Install per-session positional replay. A newly seen live session takes the
* next ordered recorded script, then advances its own cursor synchronously at
* invocation time; calls without `sessionId` share one anonymous session.
* Returns the effect disposer for HMR-safe removal.
* invocation time; calls without `sessionId` share one anonymous session. A
* non-empty provider catalog registers a routed replay adapter; otherwise a
* catch-all waterfall intercepts requests. Returns the effect disposer for
* HMR-safe removal.
*
* @param ctx - the context whose `llm/stream` waterfall the listener short-circuits.
* @param ctx - the context whose LLM service receives the replay route or waterfall.
* @param config - the resolved fixture paths (env-var defaulting is `apply`'s job).
* @returns the `ctx.on` disposer that removes the listener.
* @returns the disposer that removes the registered adapter or listener.
*/
export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void {
const scripts = loadSessionScripts(config)
@@ -258,7 +322,7 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void
const bound = new Map<string, { entries: ReplayEntry[]; cursor: number }>()
let nextScript = 0
const ANON = '\0anon\0' // the key for a call that carries no sessionId
return ctx.on('llm/stream', (options: GenerateOptions, _next) => {
const replay = (options: GenerateOptions): AsyncIterable<StreamChunk> => {
const key = options.sessionId ?? ANON
let state = bound.get(key)
let unrecorded = false
@@ -296,7 +360,12 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void
}
yield* replayEntry(entry, options.signal)
})()
})
}
const providers = config.providers ?? []
if (providers.length > 0) {
return ctx.llm.registerAdapter(providers.map(provider => provider.id), new ReplayAdapter(providers, replay))
}
return ctx.on('llm/stream', (options: GenerateOptions, _next) => replay(options))
}
export const name = 'llm-replay'
@@ -314,6 +383,8 @@ export interface Config {
* a nested-agent scenario; absent/empty for a single-session scenario.
*/
childFiles?: string[]
/** Optional replay-only provider catalog; absent or empty selects catch-all waterfall replay. */
providers?: ReplayProviderConfig[]
}
export function apply(ctx: Context, config: Config = {}): void {
@@ -329,5 +400,6 @@ export function apply(ctx: Context, config: Config = {}): void {
file,
...overrideFile !== undefined && overrideFile.length > 0 ? { overrideFile } : {},
...childFiles.length > 0 ? { childFiles } : {},
...config.providers !== undefined ? { providers: config.providers } : {},
})
}

View File

@@ -198,7 +198,7 @@ describe('loadReplayScript', () => {
})
})
describe('installLlmReplay (through the real waterfall)', () => {
describe('installLlmReplay (through the real LlmService)', () => {
function writeLog(...calls: StreamChunk[][]): void {
let seq = 1
const events: SessionEvent[] = []
@@ -217,6 +217,40 @@ describe('installLlmReplay (through the real waterfall)', () => {
expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS)
})
it('registers a replay-only provider catalog when configured', async () => {
writeLog(TEXT_CHUNKS)
const ctx = new Context()
await ctx.plugin(LlmService)
const dispose = installLlmReplay(ctx, {
file,
providers: [
{
id: 'deepseek',
name: 'DeepSeek',
models: [
{ id: 'flash' },
{ id: 'pro', name: 'Pro', description: 'Larger model' },
],
},
{ id: 'empty' },
],
})
expect(ctx.llm.listProviders()).toEqual([
{ id: 'deepseek', name: 'DeepSeek' },
{ id: 'empty', name: 'empty' },
])
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
{ provider: 'deepseek', id: 'flash', name: 'flash' },
{ provider: 'deepseek', id: 'pro', name: 'Pro', description: 'Larger model' },
])
await expect(ctx.llm.listModels('empty')).resolves.toEqual([])
expect(await drain(ctx.llm.stream({ provider: 'deepseek', model: 'pro', messages: [] }))).toEqual(TEXT_CHUNKS)
dispose()
expect(ctx.llm.listProviders()).toEqual([])
})
it('serves the Nth call the Nth derived entry (positional)', async () => {
const second: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'text' },
@@ -574,11 +608,12 @@ describe('apply (the plugin entry)', () => {
expect(inject).toEqual(['llm'])
})
it('installs replay from an explicit config.file', async () => {
it('installs replay and its catalog from explicit config', async () => {
writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8')
const ctx = new Context()
await ctx.plugin(LlmService)
apply(ctx, { file })
apply(ctx, { file, providers: [{ id: 'm', models: [{ id: 'm' }] }] })
expect(ctx.llm.listProviders()).toEqual([{ id: 'm', name: 'm' }])
expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS)
})

View File

@@ -25,8 +25,8 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
| Key | Default | Routed to |
|---|---|---|
| `provider` | (required) | the provider route for each per-session agent the bridge creates |
| `model` | (required) | the per-session agent template the bridge creates agents from |
| `provider` | (required) | the initial provider route for each per-session agent the bridge creates; ACP model selection may replace it per session |
| `model` | (required) | the initial model for each per-session agent; ACP clients may switch among adapter-advertised models |
| `persona` | — | the deployment persona template (may reference `{{provider}}`/`{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-core` |

View File

@@ -8,14 +8,14 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
The plugin injects `agents`, `sessions`, `sessionPersistence`, `tools`, and `userInteraction`, never the concrete loop. Persistence backs `session/load`; tool definitions own presentation; user interaction maps agent questions to ACP forms.
The plugin injects `agents`, `sessions`, `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms.
### Config
| Key | Default | Meaning |
|---|---|---|
| `provider` | — | Provider route for created agents (must have a registered adapter). |
| `model` | — | Model name for created agents (must have a registered adapter). |
| `provider` | — | Initial provider route for created agents (must have a registered adapter). |
| `model` | — | Initial model id for created agents. |
(No persona key: `dsh-system-prompt`'s own `persona` config supplies the global default section, so ACP-created agents render it without the bridge carrying prompt text. An agent-scoped same-name section may still shadow that default.)
@@ -33,7 +33,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, and tool render intents |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
| `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" |
| `session/set_config_option` | `ctx.permission.set()` | per-session permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
| `session/set_config_option` | agent-scoped request target / `ctx.permission.set()` | per-session provider+model and permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
## Multi-session
@@ -41,7 +41,9 @@ Forward and reverse indexes route every event, prompt, cancel, and approval to o
## Session config options
When `ctx.permission` is composed, the bridge advertises one `permission` select in `session/new` and `session/load`. Options come from the deployment's preset table; the current value comes from the session fold, with switch-away-only `custom` for unmatched knobs. `session/set_config_option` accepts advertised presets and writes both sandbox-mode and approval-policy events through `PermissionService.set()`. Open-turn switches append immediately; idle switches overlay responses and anchor at the next `agent/prompt-submit`, before request assembly. A crash before anchoring restores the durable fold. See the [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md), [`dsh-permission`](../permission/README.md), and [protocol matrix](acp-feature-support.md#6-session-config-options).
The bridge advertises a `model`-category select in `session/new` and `session/load` when the session has a complete target whose provider is registered. Values encode the complete provider/model pair, are grouped by provider when more than one group is available, and come from `ctx.llm.listProviders()` / `listModels()`. The configured or last-requested model is added when absent because catalogs are advisory and private adapters may accept unlisted ids. A selection changes only that ACP session. Agent-scoped prompt assembly snapshots the selected pair for one step, supplies matching `{{provider}}` / `{{model}}` variables, and the `agent/request` waterfall applies the same pair; a concurrent selection therefore takes effect on the next step instead of splitting prompt text from routing. The resulting request header is the durable record restored by `session/load`; a selection never used by a request remains in-memory only.
When `ctx.permission` is composed, the bridge also advertises a `permission` select. Options come from the deployment's preset table; the current value comes from the session fold, with switch-away-only `custom` for unmatched knobs. `session/set_config_option` accepts advertised presets and writes both sandbox-mode and approval-policy events through `PermissionService.set()`. Open-turn switches append immediately; idle switches overlay responses and anchor at the next `agent/prompt-submit`, before request assembly. A crash before anchoring restores the durable fold. See the [model-catalog RFC](../../../docs/rfc/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md), [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md), [`dsh-permission`](../permission/README.md), and [protocol matrix](acp-feature-support.md#6-session-modes--config-options--models).
Background bash tasks use the session id as an opaque owner token, so one session cannot inspect or stop another's task. That contract belongs to [`dsh-tool-bash`](../../bash/tool-bash/).
@@ -108,6 +110,12 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa
**Token effect**: Zero direct tokens from the ACP option or the log-only `permission/preset` event. Downstream cost is limited to the owning plugins' policy prompt, conditional retained change notice, and any changed tool outcome.
### Model switches
**What the model sees**: The ACP selector itself emits no message. The selected provider/model pair supplies the next step's `{{provider}}` / `{{model}}` prompt variables and request routing together; all other call-config fields continue through the `agent/request` waterfall unchanged.
**Token effect**: The selector adds no direct tokens. A changed model may tokenize the same retained prompt/history differently, and any persona text that interpolates provider or model changes accordingly.
### Loaded sessions
**What the model sees**: `session/load` resumes the persisted log, after which the loop sends its reconstructed history and request header. Replaying that log to the editor is not an extra model message.
@@ -118,6 +126,5 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa
- **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented.
- **Prompt content is `text` + `resource_link` only** — image, audio, and embedded-resource blocks are rejected, as is a non-empty `mcpServers` list at `session/new`.
- **One configured `model` for every created session** — per-session model selection has no config or protocol surface here yet.
- **Terminal cards render completed output** — live incremental streaming and command classification are named follow-ups of [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
- **Permission answers are one-shot only** — the bridge offers `allow_once` / `reject_once`; durable `allow_always` grants and their storage/revocation policy remain deferred to the approval seam.

View File

@@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
## At a glance
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, and per-session permission presets. The largest **unbuilt** areas are **MCP passthrough**, runtime model selection, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough**, **slash commands**, and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
## 1. Agent methods (client → agent)
@@ -26,8 +26,8 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
| `session/prompt` | S | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. |
| `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. |
| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ✅ | ✅ | ✅ | One `permission` select when `ctx.permission` is composed; values come from the deployment preset table, a switch writes its preset event through to both knob events, and the response carries the complete refreshed state ([sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). |
| model selection | S | | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. The bridge fixes the model per-bridge via config; no runtime switch. Codex still uses the legacy `unstable_setSessionModel` ext method. |
| `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. |
| model selection | S | | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. Values preserve the provider/model pair, catalogs come from `ctx.llm`, selection is per session, and `session/load` restores the last requested pair. Codex also supports the legacy `unstable_setSessionModel` ext method. |
| `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. |
| `session/delete` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.delete`. |
| `session/fork` | U | ❌ | ✅ | ❌ | Claude ships `unstable_forkSession`; Codex does not. |
@@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
## 6. Session modes / config options / models
Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): when `ctx.permission` is composed, the bridge advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; `session/set_config_option` switches the preset end to end, with idle switches anchoring at the next `agent/prompt-submit` inside its open turn. Session modes stay deliberately unmodeled because config options replace them in ACP v2. Runtime model selection is still not modeled — the harness fixes the model per bridge via `AcpConfig.model` (both reference adapters ship a model selector).
Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. Session modes stay deliberately unmodeled because config options replace them in ACP v2. See the [model-catalog RFC](../../../docs/rfc/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
## 7. Content blocks
@@ -141,13 +141,12 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
Ranked by how commonly the reference adapters ship them and how much UX they unlock:
1. **Session lifecycle**`session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
2. **Model selection** — sandbox and approval config options are implemented; selecting the bridge's model at runtime remains open.
3. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
4. **Slash commands** (`available_commands_update`).
5. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
6. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
7. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
8. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
2. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
3. **Slash commands** (`available_commands_update`).
4. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
5. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
6. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
7. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
## Out of scope

View File

@@ -34,6 +34,7 @@
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",

View File

@@ -34,13 +34,15 @@ import {
type PromptRequest,
type PromptResponse,
type SessionConfigOption,
type SessionConfigSelectGroup,
type SessionConfigSelectOption,
type SessionNotification,
type SetSessionConfigOptionRequest,
type SetSessionConfigOptionResponse,
type Stream,
type StopReason,
} from '@agentclientprotocol/sdk'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
@@ -52,6 +54,9 @@ import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } f
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
import type {} from '@deepseek-ai/dsh-session-persistence'
// Side-effect type import: declaration-merges prompt assembly onto Context and
// the scoped waterfall used to keep persona variables aligned with requests.
import type {} from '@deepseek-ai/dsh-system-prompt'
// Side-effect type import: declaration-merges the `approval/request` waterfall
// the bridge answers for its own agents (see the approval answerer below).
import type {} from '@deepseek-ai/dsh-user-approval'
@@ -73,7 +78,7 @@ import {
export const name = 'acp'
// Interface services back advertised loading, tool-owned presentation with a generic fallback, and interaction.
// TODO(acp-session-inject): remove `sessions`; the bridge never reads it, and ownership is already behind `agents`.
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt']
/** Build an ACP invalid-params error with visible human detail. */
function invalidParams(detail: string): RequestError {
@@ -214,6 +219,31 @@ export const Config: Schema<AcpConfig> = Schema.object({
model: Schema.string(),
})
/** Provider/model pair selected for one ACP session. */
interface LlmTarget {
provider: string
model: string
}
/** Mutable target shared by one agent's scoped assembly and request listeners. */
interface LlmTargetRef {
current: LlmTarget | undefined
/** Step snapshot captured by prompt assembly so target switches cannot split prompt and request. */
assembled: LlmTarget | undefined
}
/** One resolved ACP model selector plus its opaque value lookup. */
interface ModelDirectory {
option: Extract<SessionConfigOption, { type: 'select' }> | undefined
targets: ReadonlyMap<string, LlmTarget>
}
/** One provider and its adapter-advertised models, detached for one RPC. */
interface ModelCatalogEntry {
provider: LlmProviderInfo
models: LlmModelInfo[]
}
/** Per-session bridge state keyed by ACP session id. */
interface SessionRecord {
sessionId: SessionId
@@ -224,6 +254,8 @@ interface SessionRecord {
presenter: ToolPresenter
/** Session-creation snapshot of terminal-card support for call/result consistency. */
terminalEnabled: boolean
/** Session-local provider/model selection and the current step snapshot. */
target: LlmTargetRef
/** In-flight prompt and its captured turn number for exact settlement. */
inflight: {
resolve: (reason: StopReason) => void
@@ -246,6 +278,7 @@ interface SessionRecord {
export function apply(ctx: Context, config: AcpConfig): void {
// Handlers run later outside this injection scope, so capture services now.
const agents = ctx.agents
const llm = ctx.llm
const sessionPersistence = ctx.sessionPersistence
const logger = ctx.logger
const tools = ctx.tools
@@ -253,6 +286,101 @@ export function apply(ctx: Context, config: AcpConfig): void {
// Presenter failures are logged and contained per session or replay.
const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent)
/** Resolve a complete target only; partial config remains available to other request listeners. */
const configuredTarget = (): LlmTarget | undefined => config.provider !== undefined && config.model !== undefined
? { provider: config.provider, model: config.model }
: undefined
/** Install the ACP target as an agent-scoped prompt/request override. */
const installTarget = (agentCtx: Context, target: LlmTargetRef): void => {
const agent = agentCtx.agent
/* v8 ignore next -- setup is invoked only with the freshly created agent's scoped context. */
if (agent === undefined) throw new Error('acp: agent setup has no scoped agent')
const logged = agent.session.requestHeader()?.config
if (logged !== undefined) target.current = { provider: logged.provider, model: logged.model }
// Capture once at assembly entry and apply the same pair after downstream
// prompt listeners. A selector change during async assembly therefore takes
// effect on the following step instead of splitting {{model}} from routing.
agentCtx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const selected = target.current
const assembled = await next()
target.assembled = selected
if (selected === undefined) return assembled
return {
...assembled,
variables: {
...assembled.variables,
provider: selected.provider,
model: selected.model,
},
}
})
agentCtx.on('agent/request', async (_agent, _turn, _step, _callConfig, next): Promise<LlmCallConfig> => {
const resolved = await next()
const selected = target.assembled
return selected === undefined ? resolved : {
...resolved,
provider: selected.provider,
model: selected.model,
}
})
}
/** Opaque ACP value preserving both routing dimensions. */
const targetValue = (target: LlmTarget): string => JSON.stringify([target.provider, target.model])
/** Read one detached advisory catalog snapshot before mutating session state. */
const readModelCatalog = async (): Promise<ModelCatalogEntry[]> => Promise.all(
llm.listProviders().map(async provider => ({
provider,
models: await llm.listModels(provider.id),
})),
)
/** Resolve one catalog snapshot into the ACP model selector for a session. */
const modelDirectory = (catalog: readonly ModelCatalogEntry[], current: LlmTarget | undefined): ModelDirectory => {
if (current === undefined) return { option: undefined, targets: new Map() }
const models = catalog.map(entry => ({ provider: entry.provider, models: [...entry.models] }))
const currentProvider = models.find(entry => entry.provider.id === current.provider)
if (currentProvider === undefined) return { option: undefined, targets: new Map() }
if (!currentProvider.models.some(model => model.id === current.model)) {
currentProvider.models = [...currentProvider.models, {
provider: current.provider,
id: current.model,
name: current.model,
}]
}
const targets = new Map<string, LlmTarget>()
const groups = models.flatMap(({ provider, models: entries }) => {
if (entries.length === 0) return []
const options = entries.map((model): SessionConfigSelectOption => {
const target = { provider: model.provider, model: model.id }
const value = targetValue(target)
targets.set(value, target)
return {
value,
name: model.name,
...model.description === undefined ? {} : { description: model.description },
}
})
return [{ group: provider.id, name: provider.name, options } satisfies SessionConfigSelectGroup]
})
return {
option: {
id: 'model',
name: 'Model',
description: 'Sets this session\'s provider and model.',
category: 'model',
type: 'select',
currentValue: targetValue(current),
options: groups.length === 1 ? groups.flatMap(group => group.options) : groups,
},
targets,
}
}
// TODO(derive-acp-session-id): derive event ids from `agent.session`, verify ownership, then remove the reverse map.
// Agent events currently carry only the Agent, so retain `SessionRecord.sessionId` and update both indexes together.
// Dropping the forward record lets the weak reverse entry expire.
@@ -436,16 +564,17 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- The ACP Agent method surface -----------------------------------------
/**
* Build the single Permissions option when `ctx.permission` is composed.
* Its value comes from the session log, overlaid by an unanchored idle
* switch, so `session/load` needs no catch-up state.
*/
const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => {
/** Build every ACP session option from the model directory and live services. */
const configOptionsFor = (
agent: Agent,
directory: ModelDirectory,
pending: SessionRecord['pendingSwitches'] = {},
): SessionConfigOption[] => {
const options = directory.option === undefined ? [] : [directory.option]
const presets = ctx.get('permission')
if (presets === undefined) return []
if (presets === undefined) return options
const currentValue = pending.preset ?? presets.current(agent.session.events)
return [{
return [...options, {
id: 'permission',
name: 'Permissions',
description: 'Sets this session\'s sandbox and approval behavior.',
@@ -544,11 +673,15 @@ export function apply(ctx: Context, config: AcpConfig): void {
validateWorkspaceParams(params)
validateMcpServers(params)
const sessionId = SessionId(randomUUID())
const target: LlmTargetRef = { current: configuredTarget(), assembled: undefined }
const directory = modelDirectory(await readModelCatalog(), target.current)
assertOpen()
const handle = await agents.create({
agentId: AgentId(sessionId),
sessionId,
meta: { cwd: params.cwd },
agentOptions: agentOptions(config),
setup: (agentCtx) => { installTarget(agentCtx, target) },
})
// Creation awaits the unpublished setup transaction. A client disconnect
// can therefore close this bridge
@@ -567,10 +700,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
dispose: () => handle.dispose(),
presenter: makePresenter(handle.agent),
terminalEnabled: terminalOutputCap,
target,
inflight: undefined,
pendingSwitches: {},
})
const configOptions = configOptionsFor(handle.agent)
const configOptions = configOptionsFor(handle.agent, directory)
return { sessionId, ...configOptions.length > 0 ? { configOptions } : {} }
},
@@ -615,10 +749,14 @@ export function apply(ctx: Context, config: AcpConfig): void {
throw invalidParams(`session ${sessionId} cwd mismatch: persisted ${persistedCwd}, requested ${params.cwd}`)
}
}
const catalog = await readModelCatalog()
assertOpen()
const target: LlmTargetRef = { current: configuredTarget(), assembled: undefined }
const handle = await agents.resume({
agentId: AgentId(sessionId),
resumeSessionId: sessionId,
agentOptions: agentOptions(config),
setup: (agentCtx) => { installTarget(agentCtx, target) },
})
// The bridge may have torn down (disposal / client disconnect) while
// resume() was pending. Its listeners are gone, so installing a record
@@ -634,6 +772,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
await handle.dispose()
throw invalidParams('connection closed during session/load')
}
const directory = modelDirectory(catalog, target.current)
const agent = handle.agent
bySession.set(agent, sessionId)
// Snapshot the terminal capability ONCE for this session (used by both
@@ -646,6 +785,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
dispose: () => handle.dispose(),
presenter: makePresenter(agent),
terminalEnabled,
target,
inflight: undefined,
pendingSwitches: {},
}
@@ -671,7 +811,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
for (const event of agent.session.events) {
streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal)
}
const configOptions = configOptionsFor(agent)
const configOptions = configOptionsFor(agent, directory)
return configOptions.length > 0 ? { configOptions } : {}
} finally {
loadingIds.delete(sessionId)
@@ -725,18 +865,35 @@ export function apply(ctx: Context, config: AcpConfig): void {
return Promise.resolve()
},
setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
assertOpen()
const rec = requireSession(SessionId(params.sessionId))
// The advertised option is a select, so the boolean-shaped variant of
// the request is a protocol misuse regardless of configId.
// Every advertised option is a select, so the boolean-shaped variant
// is a protocol misuse regardless of configId.
if (typeof params.value !== 'string') {
throw invalidParams(`config option ${params.configId} is a select; boolean values are not accepted`)
}
let directory = modelDirectory(await readModelCatalog(), rec.target.current)
// Open-turn switches append immediately; idle switches wait for the
// next prompt-submit. Only values advertised by this composition are
// accepted, and the session log remains the durable store.
switch (params.configId) {
case 'model': {
const target = directory.targets.get(params.value)
if (target === undefined) {
throw invalidParams(`unknown model value ${JSON.stringify(params.value)}`)
}
rec.target.current = { ...target }
const option = directory.option
/* v8 ignore next -- `targets` is populated only while constructing
this selector; a found target therefore proves it exists. */
if (option === undefined) throw internalError('model directory target has no selector')
directory = {
...directory,
option: { ...option, currentValue: params.value },
}
break
}
case 'permission': {
const presets = ctx.get('permission')
if (presets === undefined) {
@@ -758,7 +915,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
// The spec requires the COMPLETE refreshed config state in the response
// (a change may cascade); ours are independent, but the contract holds.
return Promise.resolve({ configOptions: configOptionsFor(rec.agent, rec.pendingSwitches) })
return { configOptions: configOptionsFor(rec.agent, directory, rec.pendingSwitches) }
},
}
}

View File

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

View File

@@ -5,7 +5,7 @@
*/
import { Context } from 'cordis'
import LlmService, { CallId, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmService, { 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'
@@ -39,10 +39,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 +156,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.
*
@@ -155,7 +172,9 @@ export interface BridgeHarness {
*/
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,7 +204,11 @@ 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)
@@ -211,7 +234,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
@@ -272,7 +295,7 @@ export async function makeBridgeHarness(options: {
})
// Default route fields only when the caller omitted them; explicit undefined values must survive.
const cfg: AcpConfig = { stream: agentStream, ...options.config }
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

View File

@@ -262,6 +262,6 @@ export class HarnessSdkServer {
}
private hasAdapterFor(provider: string): boolean {
return this.ctx.get('llm')?.providers().includes(provider) ?? false
return this.ctx.get('llm')?.listProviders().some(entry => entry.id === provider) ?? false
}
}

View File

@@ -399,7 +399,7 @@ describe('HarnessSdkServer', () => {
expect(inspect.hasAdapterFor('missing-provider')).toBe(false)
await server.initialize({ cwd: storageDir, provider: 'deepseek', model: 'preinstalled-model' })
expect(ctx.get('llm')?.providers().filter(provider => provider === 'deepseek')).toEqual(['deepseek'])
expect(ctx.get('llm')?.listProviders().filter(provider => provider.id === 'deepseek')).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
await server.shutdown()
} finally {
await ctx.fiber.dispose()
@@ -418,7 +418,7 @@ describe('HarnessSdkServer', () => {
await expect(server.initialize({ cwd: storageDir, provider: 'private', model: 'new-model' }))
.rejects.toThrow('no adapter registered for provider "private"')
expect(ctx.get('llm')?.providers()).toEqual(['deepseek'])
expect(ctx.get('llm')?.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
await server.shutdown()
} finally {
await ctx.fiber.dispose()
@@ -519,7 +519,7 @@ describe('HarnessSdkServer', () => {
const ctx = {
on: vi.fn(() => () => undefined),
agents: { create, get: () => undefined },
get: () => ({ providers: () => ['mock'] }),
get: () => ({ listProviders: () => [{ id: 'mock', name: 'Mock' }] }),
} as unknown as Context
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
initialize(params: { cwd: string; provider: string; model: string }): Promise<unknown>