mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'worktree/agent-execution-context-rfc' into worktree/explicit-turn-signal
This commit is contained in:
@@ -8,13 +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 |
|
||||
|---|---|---|
|
||||
| `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.)
|
||||
|
||||
@@ -32,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
|
||||
|
||||
@@ -40,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).
|
||||
|
||||
The shared [`ctx.tasks` runtime](../../tasks/tasks/) fences access to predictable task ids by the owning session; ACP sessions therefore cannot read or stop one another's background work.
|
||||
|
||||
@@ -107,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.
|
||||
@@ -117,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -41,8 +42,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-execution": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
|
||||
@@ -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 {
|
||||
@@ -201,6 +206,8 @@ function stringArrayContent(
|
||||
|
||||
/** Plugin config: the agent template ACP sessions are created from. */
|
||||
export interface AcpConfig {
|
||||
/** Provider route for created agents. */
|
||||
provider?: string
|
||||
/** Model name for created agents (must have a registered adapter). */
|
||||
model?: string
|
||||
/** Runtime-only transport override for tests; production uses stdio. */
|
||||
@@ -208,9 +215,35 @@ export interface AcpConfig {
|
||||
}
|
||||
|
||||
export const Config: Schema<AcpConfig> = Schema.object({
|
||||
provider: Schema.string(),
|
||||
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
|
||||
@@ -221,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
|
||||
@@ -243,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
|
||||
@@ -250,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, _signal, 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.
|
||||
@@ -433,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.',
|
||||
@@ -541,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
|
||||
@@ -564,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 } : {} }
|
||||
},
|
||||
|
||||
@@ -612,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
|
||||
@@ -631,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
|
||||
@@ -643,6 +785,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
dispose: () => handle.dispose(),
|
||||
presenter: makePresenter(agent),
|
||||
terminalEnabled,
|
||||
target,
|
||||
inflight: undefined,
|
||||
pendingSwitches: {},
|
||||
}
|
||||
@@ -668,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)
|
||||
@@ -722,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) {
|
||||
@@ -755,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) }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -851,11 +1011,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
* Build per-agent options from the plugin config, omitting absent fields
|
||||
* (exactOptionalPropertyTypes: never assign `undefined` to an optional key).
|
||||
* Exported for unit coverage of both the present and absent branches.
|
||||
* @param config - the plugin config carrying the optional model name.
|
||||
* @returns the per-agent options, with `model` present only when configured.
|
||||
* @param config - the plugin config carrying the optional provider/model target.
|
||||
* @returns the per-agent options, with each configured target field present.
|
||||
*/
|
||||
export function agentOptions(config: AcpConfig): { model?: string } {
|
||||
export function agentOptions(config: AcpConfig): { provider?: string; model?: string } {
|
||||
return {
|
||||
...config.provider !== undefined ? { provider: config.provider } : {},
|
||||
...config.model !== undefined ? { model: config.model } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,26 @@ function permissionOption(currentValue: string): object {
|
||||
}
|
||||
}
|
||||
|
||||
function modelValue(provider = 'mock', model = 'mock'): string {
|
||||
return JSON.stringify([provider, model])
|
||||
}
|
||||
|
||||
function modelOption(currentValue = modelValue()): object {
|
||||
return {
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'Sets this session\'s provider and model.',
|
||||
category: 'model',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [{ value: modelValue(), name: 'Mock' }],
|
||||
}
|
||||
}
|
||||
|
||||
function optionsWithPermission(currentValue: string): object[] {
|
||||
return [modelOption(), permissionOption(currentValue)]
|
||||
}
|
||||
|
||||
describe('acp bridge — session config options', () => {
|
||||
let storageDir: string
|
||||
let h: BridgeHarness | undefined
|
||||
@@ -64,19 +84,111 @@ describe('acp bridge — session config options', () => {
|
||||
return harness
|
||||
}
|
||||
|
||||
it('advertises no configOptions without the permission service — even with both knobs composed', async () => {
|
||||
it('advertises the model selector without requiring the permission service', async () => {
|
||||
h = await makeBridgeHarness({ storageDir })
|
||||
await h.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
|
||||
await h.ctx.plugin(ApprovalService)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toBeUndefined()
|
||||
expect(res.configOptions).toEqual([modelOption()])
|
||||
})
|
||||
|
||||
it('groups models by provider and switches routing plus prompt variables as one session target', async () => {
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('ok')],
|
||||
config: { provider: 'alpha', model: 'a1' },
|
||||
persona: 'Route {{provider}} / {{model}}',
|
||||
catalog: {
|
||||
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }],
|
||||
models: [
|
||||
{ provider: 'alpha', id: 'a1', name: 'Alpha One', description: 'Fast' },
|
||||
{ provider: 'beta', id: 'b1', name: 'Beta One' },
|
||||
],
|
||||
},
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const created = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(created.configOptions).toEqual([{
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'Sets this session\'s provider and model.',
|
||||
category: 'model',
|
||||
type: 'select',
|
||||
currentValue: modelValue('alpha', 'a1'),
|
||||
options: [
|
||||
{ group: 'alpha', name: 'Alpha', options: [{ value: modelValue('alpha', 'a1'), name: 'Alpha One', description: 'Fast' }] },
|
||||
{ group: 'beta', name: 'Beta', options: [{ value: modelValue('beta', 'b1'), name: 'Beta One' }] },
|
||||
],
|
||||
}])
|
||||
|
||||
const switched = await h.client.setSessionConfigOption({
|
||||
sessionId: created.sessionId,
|
||||
configId: 'model',
|
||||
value: modelValue('beta', 'b1'),
|
||||
})
|
||||
expect(switched.configOptions?.[0]).toMatchObject({ currentValue: modelValue('beta', 'b1') })
|
||||
await h.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'use beta' }] })
|
||||
expect(h.adapter.requests[0]).toMatchObject({
|
||||
provider: 'beta',
|
||||
model: 'b1',
|
||||
})
|
||||
expect(h.adapter.requests[0]?.system).toContain('Route beta / b1')
|
||||
expect(h.ctx.agents.list()[0]?.session.requestHeader()?.config).toMatchObject({ provider: 'beta', model: 'b1' })
|
||||
})
|
||||
|
||||
it('adds the configured private model to an advisory catalog and ignores empty non-current groups', async () => {
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
config: { provider: 'alpha', model: 'private-model' },
|
||||
catalog: {
|
||||
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'empty', name: 'Empty' }],
|
||||
models: [{ provider: 'alpha', id: 'public-model', name: 'Public Model' }],
|
||||
},
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions?.[0]).toMatchObject({
|
||||
currentValue: modelValue('alpha', 'private-model'),
|
||||
options: [
|
||||
{ value: modelValue('alpha', 'public-model'), name: 'Public Model' },
|
||||
{ value: modelValue('alpha', 'private-model'), name: 'private-model' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits model selection without a complete or registered current target', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, config: { model: undefined } })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const missing = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(missing.configOptions).toBeUndefined()
|
||||
await h.dispose()
|
||||
|
||||
h = await makeBridgeHarness({ storageDir, config: { provider: 'unregistered', model: 'm' } })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const unknown = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(unknown.configOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves model-less agents available to another agent/request supplier', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, config: { model: undefined }, script: [textResponse('ok')] })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = h.ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('expected an agent')
|
||||
agent.ctx.on('agent/request', async (_agent, _turn, _step, callConfig, _signal, _next) => ({
|
||||
...callConfig,
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
}))
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'supplied elsewhere' }] })
|
||||
expect(h.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' })
|
||||
})
|
||||
|
||||
it('advertises the Permissions select with the default preset current', async () => {
|
||||
h = await presetStack()
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
expect(res.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
})
|
||||
|
||||
it('an idle switch is pending (overlaid, not yet logged), then anchors inside the next prompt\'s turn', async () => {
|
||||
@@ -84,7 +196,7 @@ describe('acp bridge — session config options', () => {
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const after = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(after.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
expect(after.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
|
||||
const session = h.ctx.agents.list()[0]?.session
|
||||
expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
|
||||
@@ -105,7 +217,7 @@ describe('acp bridge — session config options', () => {
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const again = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(again.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
expect(again.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
|
||||
@@ -119,7 +231,7 @@ describe('acp bridge — session config options', () => {
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const back = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(back.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
expect(back.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.some(e => e.type === 'permission/preset' || e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
|
||||
@@ -129,10 +241,10 @@ describe('acp bridge — session config options', () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(echo.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
expect(echo.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(repeat.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
expect(repeat.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }])
|
||||
@@ -167,6 +279,8 @@ describe('acp bridge — session config options', () => {
|
||||
// This composition never advertised `permission`.
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }))
|
||||
.rejects.toThrow(/unknown permission value/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'model', value: modelValue('mock', 'missing') }))
|
||||
.rejects.toThrow(/unknown model value/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', type: 'boolean', value: true }))
|
||||
.rejects.toThrow(/select; boolean values are not accepted/)
|
||||
})
|
||||
@@ -184,9 +298,31 @@ describe('acp bridge — session config options', () => {
|
||||
const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(bAfter.configOptions).toEqual([permissionOption('workspace-write')])
|
||||
expect(bAfter.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(aAfter.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
expect(aAfter.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
})
|
||||
|
||||
it('keeps model targets isolated across concurrent sessions', async () => {
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('a'), textResponse('b')],
|
||||
config: { provider: 'mock', model: 'one' },
|
||||
catalog: {
|
||||
providers: [{ id: 'mock', name: 'Mock' }],
|
||||
models: [
|
||||
{ provider: 'mock', id: 'one', name: 'One' },
|
||||
{ provider: 'mock', id: 'two', name: 'Two' },
|
||||
],
|
||||
},
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'model', value: modelValue('mock', 'two') })
|
||||
await h.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: 'a' }] })
|
||||
await h.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: 'b' }] })
|
||||
expect(h.adapter.requests.map(request => request.model)).toEqual(['two', 'one'])
|
||||
})
|
||||
|
||||
it('a knob drifted outside the table derives a visible-but-untargetable custom current', async () => {
|
||||
@@ -199,12 +335,12 @@ describe('acp bridge — session config options', () => {
|
||||
agent.session.append('bash/sandbox-mode', { mode: 'read-only' })
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' })
|
||||
const option = echo.configOptions?.[0]
|
||||
const option = echo.configOptions?.find(entry => entry.id === 'permission')
|
||||
expect(option).toMatchObject({ currentValue: 'custom' })
|
||||
if (option === undefined || !('options' in option)) throw new Error('expected a select option')
|
||||
expect(option.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access', 'custom'])
|
||||
const away = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const afterOption = away.configOptions?.[0]
|
||||
const afterOption = away.configOptions?.find(entry => entry.id === 'permission')
|
||||
expect(afterOption).toMatchObject({ currentValue: 'danger-full-access' })
|
||||
if (afterOption === undefined || !('options' in afterOption)) throw new Error('expected a select option')
|
||||
expect(afterOption.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access'])
|
||||
@@ -223,6 +359,52 @@ describe('acp bridge — session config options', () => {
|
||||
|
||||
loader = await presetStack()
|
||||
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([permissionOption('danger-full-access')])
|
||||
expect(res.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
})
|
||||
|
||||
it('session/load restores the last requested provider/model from the request header', async () => {
|
||||
const catalog = {
|
||||
providers: [{ id: 'mock', name: 'Mock' }],
|
||||
models: [
|
||||
{ provider: 'mock', id: 'one', name: 'One' },
|
||||
{ provider: 'mock', id: 'two', name: 'Two' },
|
||||
],
|
||||
}
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('ok')],
|
||||
config: { provider: 'mock', model: 'one' },
|
||||
catalog,
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'model', value: modelValue('mock', 'two') })
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist target' }] })
|
||||
await h.dispose()
|
||||
h = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, config: { provider: 'mock', model: 'one' }, catalog })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const loaded = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(loaded.configOptions?.find(option => option.id === 'model')).toMatchObject({
|
||||
currentValue: modelValue('mock', 'two'),
|
||||
})
|
||||
})
|
||||
|
||||
it('session/load omits config options when the persisted session has no target or permission service', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, config: { model: undefined } })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = h.ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('expected an agent')
|
||||
agent.inject([{ type: 'text', text: 'checkpoint' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
await agent.whenIdle()
|
||||
await h.dispose()
|
||||
h = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, config: { model: undefined } })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const loaded = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(loaded.configOptions).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -175,10 +175,10 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
// published, which guards against context-wide teardown.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
const handleA = await harness.ctx.agents.create({
|
||||
agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' },
|
||||
agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const handleB = await harness.ctx.agents.create({
|
||||
agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' },
|
||||
agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
expect(harness.ctx.agents.get(AgentId('sib-a'))).toBe(handleA.agent)
|
||||
expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent)
|
||||
@@ -199,7 +199,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
||||
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
|
||||
const handle = await harness.ctx.agents.create({
|
||||
agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' },
|
||||
agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await handle.agent.whenIdle()
|
||||
@@ -216,7 +216,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
// clears. AgentHandle must memoize the whole async teardown so every caller awaits quiescence.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
const handle = await harness.ctx.agents.create({
|
||||
agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' },
|
||||
agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
// A hanging turn makes disposal produce a final flush; gate it so the second call arrives while
|
||||
// teardown is observably in flight.
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('acp bridge — demux & config edges', () => {
|
||||
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const before = harness.updates.length
|
||||
|
||||
const { agent: foreign } = await harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } })
|
||||
const { agent: foreign } = await harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
foreign.send([{ type: 'text', text: 'hi' }])
|
||||
await foreign.whenIdle()
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
@@ -5,14 +5,10 @@
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
@@ -40,10 +36,24 @@ import { type AcpConfig } from '../src/index.ts'
|
||||
/** A scripted mock adapter (mirrors the agent-loop test adapter). */
|
||||
class MockAdapter extends LlmAdapter {
|
||||
requests: GenerateOptions[] = []
|
||||
constructor(private script: (StreamChunk[] | 'hang')[]) {
|
||||
constructor(
|
||||
private script: (StreamChunk[] | 'hang')[],
|
||||
private readonly providers: readonly LlmProviderInfo[],
|
||||
private readonly models: readonly LlmModelInfo[],
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
const info = this.providers.find(entry => entry.id === provider)
|
||||
if (info === undefined) throw new Error(`MockAdapter: unknown provider ${provider}`)
|
||||
return info
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve(this.models.filter(model => model.provider === provider))
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const entry = this.script.shift()
|
||||
@@ -143,6 +153,9 @@ export interface BridgeHarness {
|
||||
storageDir: string
|
||||
}
|
||||
|
||||
/** Test-only overrides preserve explicit undefined to suppress harness defaults. */
|
||||
type AcpConfigOverrides = { [K in keyof AcpConfig]?: AcpConfig[K] | undefined }
|
||||
|
||||
/**
|
||||
* Build the bridge + a connected client over an in-memory transport pair.
|
||||
*
|
||||
@@ -151,12 +164,13 @@ export interface BridgeHarness {
|
||||
* The bridge's `apply` receives the agent-side `Stream` via `config.stream`;
|
||||
* the test holds the `ClientSideConnection`.
|
||||
*
|
||||
* Pass `config: { model: undefined }` to override the default `model: 'mock'`
|
||||
* (the model key is dropped entirely when explicitly undefined).
|
||||
* Pass an explicit undefined route field to suppress its mock default.
|
||||
*/
|
||||
export async function makeBridgeHarness(options: {
|
||||
script?: (StreamChunk[] | 'hang')[]
|
||||
config?: Partial<AcpConfig>
|
||||
config?: AcpConfigOverrides
|
||||
/** Provider-neutral directory exposed to ACP model-selection tests. */
|
||||
catalog?: { providers: LlmProviderInfo[]; models: LlmModelInfo[] }
|
||||
/** Deployment persona for the tree (the system-prompt plugin's config). */
|
||||
persona?: string
|
||||
storageDir: string
|
||||
@@ -186,15 +200,16 @@ export async function makeBridgeHarness(options: {
|
||||
withFs?: boolean
|
||||
fsCwd?: string
|
||||
} = { storageDir: '' }): Promise<BridgeHarness> {
|
||||
const adapter = new MockAdapter(options.script ?? [])
|
||||
const catalog = options.catalog ?? {
|
||||
providers: [{ id: 'mock', name: 'Mock' }],
|
||||
models: [{ provider: 'mock', id: 'mock', name: 'Mock' }],
|
||||
}
|
||||
const adapter = new MockAdapter(options.script ?? [], catalog.providers, catalog.models)
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: options.persona ?? '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await mountAgentLoopTestDependencies(ctx, {
|
||||
systemPrompt: { persona: options.persona ?? '' },
|
||||
})
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
|
||||
await ctx.plugin(UserInteractionService)
|
||||
@@ -213,7 +228,7 @@ export async function makeBridgeHarness(options: {
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs)
|
||||
}
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.llm.registerAdapter(catalog.providers.map(provider => provider.id), adapter)
|
||||
|
||||
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the agent writes flow
|
||||
// to the client's reader and vice versa. (ndJsonStream takes (output, input): the agent
|
||||
@@ -273,9 +288,9 @@ export async function makeBridgeHarness(options: {
|
||||
},
|
||||
})
|
||||
|
||||
// Default to `mock` only when the caller omitted the key; explicit `model: undefined` means no
|
||||
// model and must survive the object spread.
|
||||
const cfg: AcpConfig = { stream: agentStream, ...options.config }
|
||||
// Default route fields only when the caller omitted them; explicit undefined values must survive.
|
||||
const cfg = { stream: agentStream, ...options.config } as AcpConfig
|
||||
if (!(options.config && 'provider' in options.config)) cfg.provider = 'mock'
|
||||
if (!(options.config && 'model' in options.config)) cfg.model = 'mock'
|
||||
// Mount the bridge the way production does: as a cordis plugin (via `ctx.plugin` with the
|
||||
// real `inject`), not `AcpPlugin.apply(ctx, cfg)` on the ungated root. Later JSON-RPC callbacks run
|
||||
|
||||
@@ -794,5 +794,6 @@ describe('agentOptions', () => {
|
||||
it('includes only the fields present in config', () => {
|
||||
expect(agentOptions({})).toEqual({})
|
||||
expect(agentOptions({ model: 'm' })).toEqual({ model: 'm' })
|
||||
expect(agentOptions({ provider: 'p', model: 'm' })).toEqual({ provider: 'p', model: 'm' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ Stdio JSON-RPC plugin for out-of-process SDK clients such as Python `deepseek_ha
|
||||
|
||||
## Wiring
|
||||
|
||||
`inject: ['agents']`. The server gets or creates one agent per `sessionId` on `session/prompt` and demuxes `subagent/end` through the registry. If `initialize.model` lacks a registered adapter, it mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`; a config-registered adapter wins. Persistence, tools, and other adapters come from the surrounding `cordis.yml`.
|
||||
`inject: ['agents']`. The server gets or creates one agent per `sessionId` from the `initialize.provider`/`initialize.model` pair and demuxes `subagent/end` through the registry. A registered owner for the provider route wins; an unowned `deepseek` route mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`, while any other unowned provider fails initialization. Persistence, tools, and other adapters come from the surrounding `cordis.yml`.
|
||||
|
||||
## Config
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import type { JsonRpcTransportPeer } from './transport.ts'
|
||||
export interface InitializeParams {
|
||||
/** Working directory recorded on every SDK-created session's header. */
|
||||
cwd: string
|
||||
/** Provider route every SDK-created agent runs on. */
|
||||
provider: string
|
||||
/** Model name every SDK-created agent runs on (see {@link HarnessSdkServer.initialize} for adapter fallback). */
|
||||
model: string
|
||||
}
|
||||
@@ -66,6 +68,7 @@ interface SubagentRecord {
|
||||
*/
|
||||
export class HarnessSdkServer {
|
||||
private cwd = process.cwd()
|
||||
private provider = 'deepseek'
|
||||
private model = 'deepseek'
|
||||
private llmFiber: { dispose(): Promise<void> } | undefined
|
||||
private readonly sessions = new Map<string, SessionRecord>()
|
||||
@@ -124,16 +127,18 @@ export class HarnessSdkServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record cwd and model, mounting the DeepSeek adapter only when the config
|
||||
* registered no adapter for that model.
|
||||
* Record cwd and provider/model, mounting the DeepSeek adapter only when the
|
||||
* `deepseek` provider route has no configured owner.
|
||||
* @param params - the SDK handshake parameters.
|
||||
* @returns the server identity for the handshake.
|
||||
*/
|
||||
async initialize(params: InitializeParams): Promise<InitializeResult> {
|
||||
this.cwd = resolve(params.cwd)
|
||||
this.provider = params.provider
|
||||
this.model = params.model
|
||||
if (!this.llmFiber && !this.hasAdapterFor(this.model)) {
|
||||
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, { models: [this.model] })
|
||||
if (!this.hasAdapterFor(this.provider)) {
|
||||
if (this.provider !== 'deepseek') throw new Error(`no adapter registered for provider "${this.provider}"`)
|
||||
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {})
|
||||
}
|
||||
return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }
|
||||
}
|
||||
@@ -244,7 +249,7 @@ export class HarnessSdkServer {
|
||||
agentId: AgentId(sessionId),
|
||||
sessionId: SessionId(sessionId),
|
||||
meta: { cwd: this.cwd },
|
||||
agentOptions: { model: this.model },
|
||||
agentOptions: { provider: this.provider, model: this.model },
|
||||
})
|
||||
const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false }
|
||||
this.sessions.set(sessionId, rec)
|
||||
@@ -256,7 +261,7 @@ export class HarnessSdkServer {
|
||||
return reason.kind === 'completed' ? 'ok' : 'error'
|
||||
}
|
||||
|
||||
private hasAdapterFor(model: string): boolean {
|
||||
return this.ctx.get('llm')?.models().includes(model) ?? false
|
||||
private hasAdapterFor(provider: string): boolean {
|
||||
return this.ctx.get('llm')?.listProviders().some(entry => entry.id === provider) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ async function mountPlugin(
|
||||
options: { writeDelayMs?: number; failFlush?: boolean } = {},
|
||||
): Promise<ApplyHarness> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(agentCore)
|
||||
await ctx.plugin(agentCore, { workspaceContext: false })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: storageDir })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
||||
const harness = await mountPlugin(storageDir)
|
||||
try {
|
||||
harness.send({ jsonrpc: '2.0', id: 'init-1', method: 'initialize', params: { cwd: storageDir, model: 'apply-model' } })
|
||||
harness.send({ jsonrpc: '2.0', id: 'init-1', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'apply-model' } })
|
||||
|
||||
const response = await harness.waitForFrame(frame => frame.id === 'init-1', 'initialize response')
|
||||
expect(response).toEqual({
|
||||
@@ -175,7 +175,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
|
||||
const harness = await mountPlugin(storageDir)
|
||||
try {
|
||||
harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, model: 'dsagent-model' } })
|
||||
harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'dsagent-model' } })
|
||||
await harness.waitForFrame(frame => frame.id === 1, 'initialize response')
|
||||
|
||||
harness.send({
|
||||
@@ -236,7 +236,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
expect(harness.exits()).toEqual([0])
|
||||
|
||||
const before = harness.frames().length
|
||||
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, model: 'x' } })
|
||||
harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
|
||||
await settle()
|
||||
expect(harness.frames().length).toBe(before)
|
||||
} finally {
|
||||
@@ -257,7 +257,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
expect(harness.outputErrors.map(error => error.message)).toEqual(['flush callback failed'])
|
||||
|
||||
const before = harness.frames().length
|
||||
harness.send({ jsonrpc: '2.0', id: 'after-flush-failure', method: 'initialize', params: { cwd: storageDir, model: 'x' } })
|
||||
harness.send({ jsonrpc: '2.0', id: 'after-flush-failure', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
|
||||
await settle()
|
||||
expect(harness.frames().length).toBe(before)
|
||||
} finally {
|
||||
@@ -281,7 +281,7 @@ describe('dsh-jsonrpc plugin apply', () => {
|
||||
await harness.fiber.dispose()
|
||||
|
||||
const before = harness.frames().length
|
||||
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, model: 'x' } })
|
||||
harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, provider: 'deepseek', model: 'x' } })
|
||||
await settle()
|
||||
expect(harness.frames().length).toBe(before)
|
||||
expect(harness.exits()).toEqual([])
|
||||
|
||||
@@ -58,7 +58,7 @@ async function mockCompletionServer(): Promise<{ url: string; requests: unknown[
|
||||
|
||||
async function makeHarness(storageDir: string) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(agentCore)
|
||||
await ctx.plugin(agentCore, { workspaceContext: false })
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: storageDir })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
@@ -107,6 +107,7 @@ describe('HarnessSdkServer', () => {
|
||||
|
||||
const init = await server.handleRequest('initialize', {
|
||||
cwd: storageDir,
|
||||
provider: 'deepseek',
|
||||
model: 'dsagent-model',
|
||||
}) as { serverInfo: { name: string } }
|
||||
expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
|
||||
@@ -138,7 +139,7 @@ describe('HarnessSdkServer', () => {
|
||||
agentId: AgentId('orphan-agent'),
|
||||
sessionId: SessionId('orphan-session'),
|
||||
meta: { cwd: storageDir },
|
||||
agentOptions: { model: 'dsagent-model' },
|
||||
agentOptions: { provider: 'deepseek', model: 'dsagent-model' },
|
||||
})
|
||||
orphanHandle.agent.send([{ type: 'text', text: 'outside the sdk session map' }])
|
||||
await orphanHandle.agent.whenIdle()
|
||||
@@ -241,7 +242,7 @@ describe('HarnessSdkServer', () => {
|
||||
try {
|
||||
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
||||
|
||||
await server.initialize({ cwd: storageDir, model: 'plain-model' })
|
||||
await server.initialize({ cwd: storageDir, provider: 'deepseek', model: 'plain-model' })
|
||||
await server.prompt({
|
||||
sessionId: 'plain',
|
||||
contentBlocks: [{ type: 'text', text: 'hello' }],
|
||||
@@ -266,13 +267,13 @@ describe('HarnessSdkServer', () => {
|
||||
agentId: AgentId('parent-agent'),
|
||||
sessionId: SessionId('main'),
|
||||
meta: { cwd: storageDir },
|
||||
agentOptions: { model: 'deepseek' },
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
||||
})
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('child-agent'),
|
||||
sessionId: SessionId('child-session'),
|
||||
meta: { cwd: storageDir, parentSession: SessionId('main') },
|
||||
agentOptions: { model: 'deepseek' },
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
||||
})
|
||||
await settleSubagent(ctx, parentHandle.agent, {
|
||||
provider: 'spawn',
|
||||
@@ -314,19 +315,19 @@ describe('HarnessSdkServer', () => {
|
||||
agentId: AgentId('fallback-parent-agent'),
|
||||
sessionId: SessionId('fallback-parent'),
|
||||
meta: { cwd: storageDir },
|
||||
agentOptions: { model: 'deepseek' },
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
||||
})
|
||||
handle = await ctx.agents.create({
|
||||
agentId: AgentId('fallback-child-agent'),
|
||||
sessionId: SessionId('fallback-child-session'),
|
||||
meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
|
||||
agentOptions: { model: 'deepseek' },
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
||||
})
|
||||
failedHandle = await ctx.agents.create({
|
||||
agentId: AgentId('failed-child-agent'),
|
||||
sessionId: SessionId('failed-child-session'),
|
||||
meta: { cwd: storageDir },
|
||||
agentOptions: { model: 'deepseek' },
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek' },
|
||||
})
|
||||
const transport = new FakeTransport()
|
||||
const server = new HarnessSdkServer(ctx, transport)
|
||||
@@ -385,20 +386,20 @@ describe('HarnessSdkServer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not re-register an LLM adapter that already exists', async () => {
|
||||
it('does not re-register an LLM adapter whose provider already has an owner', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-existing-llm-'))
|
||||
const ctx = await makeHarness(storageDir)
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
||||
await ctx.plugin(LlmDeepSeek, { models: ['preinstalled-model'] })
|
||||
await ctx.plugin(LlmDeepSeek)
|
||||
try {
|
||||
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
||||
const inspect = server as unknown as { hasAdapterFor(model: string): boolean }
|
||||
const inspect = server as unknown as { hasAdapterFor(provider: string): boolean }
|
||||
|
||||
expect(inspect.hasAdapterFor('preinstalled-model')).toBe(true)
|
||||
expect(inspect.hasAdapterFor('missing-model')).toBe(false)
|
||||
await server.initialize({ cwd: storageDir, model: 'preinstalled-model' })
|
||||
expect(inspect.hasAdapterFor('deepseek')).toBe(true)
|
||||
expect(inspect.hasAdapterFor('missing-provider')).toBe(false)
|
||||
await server.initialize({ cwd: storageDir, provider: 'deepseek', model: 'preinstalled-model' })
|
||||
|
||||
expect(ctx.get('llm')?.models().filter(model => model === 'preinstalled-model')).toEqual(['preinstalled-model'])
|
||||
expect(ctx.get('llm')?.listProviders().filter(provider => provider.id === 'deepseek')).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
await server.shutdown()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
@@ -406,17 +407,18 @@ describe('HarnessSdkServer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('registers a missing model when an LLM service already exists', async () => {
|
||||
it('rejects a missing non-DeepSeek provider when an LLM service already exists', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-new-llm-'))
|
||||
const ctx = await makeHarness(storageDir)
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
||||
await ctx.plugin(LlmDeepSeek, { models: ['other-model'] })
|
||||
await ctx.plugin(LlmDeepSeek)
|
||||
try {
|
||||
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
||||
|
||||
await server.initialize({ cwd: storageDir, model: 'new-model' })
|
||||
await expect(server.initialize({ cwd: storageDir, provider: 'private', model: 'new-model' }))
|
||||
.rejects.toThrow('no adapter registered for provider "private"')
|
||||
|
||||
expect(ctx.get('llm')?.models()).toEqual(expect.arrayContaining(['other-model', 'new-model']))
|
||||
expect(ctx.get('llm')?.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
await server.shutdown()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
@@ -517,15 +519,15 @@ describe('HarnessSdkServer', () => {
|
||||
const ctx = {
|
||||
on: vi.fn(() => () => undefined),
|
||||
agents: { create, get: () => undefined },
|
||||
get: () => ({ models: () => ['model'] }),
|
||||
get: () => ({ listProviders: () => [{ id: 'mock', name: 'Mock' }] }),
|
||||
} as unknown as Context
|
||||
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
|
||||
initialize(params: { cwd: string; model: string }): Promise<unknown>
|
||||
initialize(params: { cwd: string; provider: string; model: string }): Promise<unknown>
|
||||
getOrCreateSession(sessionId: string): Promise<unknown>
|
||||
shutdown(): Promise<Record<string, never>>
|
||||
}
|
||||
|
||||
await server.initialize({ cwd: '.', model: 'model' })
|
||||
await server.initialize({ cwd: '.', provider: 'mock', model: 'model' })
|
||||
await server.getOrCreateSession('relative')
|
||||
|
||||
expect(create).toHaveBeenCalledWith(expect.objectContaining({ meta: { cwd: process.cwd() } }))
|
||||
|
||||
@@ -6,7 +6,7 @@ Each request must belong to an open agent turn. The service appends a paired `ap
|
||||
|
||||
Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP bridge is the shipped human answerer.
|
||||
|
||||
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice whose header marker distinguishes user changes from operator/config changes.
|
||||
`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch and is the only policy stated in the prompt. Switches produce at most one coalesced notice, attributed to the user when the override follows the last `request/header` and to operator/config otherwise.
|
||||
|
||||
The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP bridge is the shipped human answerer for calls it owns. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md) and [sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
* from the prompt section and the narrator's notices). The LAST such
|
||||
* event is the session's override ({@link effectiveApprovalPolicy});
|
||||
* who asked for it is derivable from position (an event after the log's
|
||||
* last `request/header*` was a runtime switch by the user).
|
||||
* last `request/header` was a runtime switch by the user).
|
||||
*/
|
||||
'approval/policy': { policy: ApprovalPolicy }
|
||||
}
|
||||
@@ -258,7 +258,7 @@ export class ApprovalService extends Service {
|
||||
// narrated no later than the next step. What each session was last told
|
||||
// is in-memory with a log-derived fallback (the folded header's system
|
||||
// text), so restarts lose nothing. Attribution is positional: an
|
||||
// override event after the log's last `request/header*` was a runtime
|
||||
// override event after the log's last `request/header` was a runtime
|
||||
// switch by the user; otherwise the configured default moved under the
|
||||
// session (operator/config).
|
||||
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
|
||||
@@ -271,7 +271,7 @@ export class ApprovalService extends Service {
|
||||
const event = events[index] as (typeof events)[number]
|
||||
if (overrideIndex < 0 && event.type === 'approval/policy') {
|
||||
overrideIndex = index
|
||||
} else if (headerIndex < 0 && (event.type === 'request/header' || event.type === 'request/header-delta')) {
|
||||
} else if (headerIndex < 0 && event.type === 'request/header') {
|
||||
headerIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +375,7 @@ describe('approval policy (the approval/policy fold)', () => {
|
||||
|
||||
/** Append a `request/header` snapshot whose system text is exactly `system`. */
|
||||
function appendHeader(session: Session, system: string): void {
|
||||
session.append('request/header', { header: { config: { model: 'mock' }, system }, reason: 'initial' })
|
||||
session.append('request/header', { header: { config: { provider: 'mock', model: 'mock' }, system }, reason: 'initial' })
|
||||
}
|
||||
|
||||
it('folds to the last event, or undefined without one', () => {
|
||||
|
||||
Reference in New Issue
Block a user