Merge remote-tracking branch 'origin/master' into codex/truncated-design
# Conflicts: # docs/capability-seams.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/rfc/INDEX.md # docs/tool-catalog.md # examples/acp-agent/README.md # packages/README.md # packages/bash/bash/README.md # packages/core/tools/tests/gen-tool-catalog.spec.ts # packages/support/acp-snapshot/src/harness.ts # pnpm-lock.yaml # scripts/gen-doc-graphs.ts # scripts/gen-tool-catalog.ts # scripts/type-equiv.manifest.json
This commit is contained in:
@@ -5,10 +5,15 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
|
||||
| `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` |
|
||||
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
|
||||
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
|
||||
| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
|
||||
| `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
|
||||
| `app-boot/` | Shared boot glue for the two app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
|
||||
|
||||
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own.
|
||||
|
||||
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.
|
||||
|
||||
`stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-acp-agent
|
||||
|
||||
The **ACP server app**: a Cordis app plugin that composes the providerless agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster an [Agent Client Protocol](../acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio.
|
||||
The **ACP server app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster an [Agent Client Protocol](../acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio.
|
||||
|
||||
It is the structured counterpart to [`@deepseek-ai/dsh-stdio-agent`](../stdio-agent/README.md): both consume the same spine, but this one bakes in the OPPOSITE front-door cluster.
|
||||
|
||||
@@ -11,8 +11,10 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
|
||||
| Plugin | Why |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) |
|
||||
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
|
||||
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC |
|
||||
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool |
|
||||
| ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately |
|
||||
| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../acp/README.md)) |
|
||||
| ~~`hmr`~~ | **omitted** — the editor owns the subprocess |
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
"@deepseek-ai/dsh-acp": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-core": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6",
|
||||
"schemastery": "^3.17.0"
|
||||
},
|
||||
@@ -45,8 +47,10 @@
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-acp": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-core": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6",
|
||||
"schemastery": "^3.17.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The ACP server app: the providerless agent spine ({@link
|
||||
* The ACP server app: the default agent spine ({@link
|
||||
* @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster an ACP
|
||||
* server needs — JSONL session persistence and the {@link @deepseek-ai/dsh-acp}
|
||||
* bridge, and DELIBERATELY NOTHING that writes to stdout.
|
||||
@@ -34,7 +34,9 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import * as acp from '@deepseek-ai/dsh-acp'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-core'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
export const name = 'acp-agent'
|
||||
|
||||
@@ -44,7 +46,8 @@ export const name = 'acp-agent'
|
||||
* pre-created agent — ACP creates agents at `session/new`); `persona` is the
|
||||
* deployment persona (forwarded to the system-prompt plugin); `toolOrder` is
|
||||
* the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
||||
* `persistenceRoot` is the JSONL backend's directory.
|
||||
* `tools` is the tool registry's config (its presentation `mode`, forwarded
|
||||
* through agent-core); `persistenceRoot` is the JSONL backend's directory.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Model name for ACP-created agents (must have a registered adapter). */
|
||||
@@ -53,8 +56,12 @@ export interface Config {
|
||||
persona?: string
|
||||
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
|
||||
toolOrder?: string[]
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */
|
||||
tools?: ToolsConfig
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||
skills?: agentCore.SkillConfig
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
@@ -64,7 +71,9 @@ export const Config: z<Config> = z.object({
|
||||
// order" (the owning dsh-system-prompt schema does the same), while
|
||||
// schemastery's native [] default would read as an invalid configured list.
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
tools: ToolRegistry.Config,
|
||||
persistenceRoot: z.string().default('./.sessions'),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -78,7 +87,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(agentCore, {
|
||||
...config.persona !== undefined ? { persona: config.persona } : {},
|
||||
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
|
||||
...config.tools !== undefined ? { tools: config.tools } : {},
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
})
|
||||
ctx.plugin(UserInteractionService)
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||
ctx.plugin(acp, { model: config.model })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtemp } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import * as acpAgent from '../src/index.ts'
|
||||
|
||||
/**
|
||||
@@ -23,13 +27,53 @@ async function mount(config: acpAgent.Config): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<acpAgent.Config['skills']>> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-acp-agent-skills-'))
|
||||
return {
|
||||
local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
|
||||
...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {},
|
||||
}
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
return await ctx.waterfall(
|
||||
'agent/session-prefix', { session: { header: { cwd: '/tmp' } } } as never,
|
||||
empty, new AbortController().signal, () => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-acp-agent-default-skills-'))
|
||||
process.env.DSH_HOME = join(home, '.dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(home, '.agents')
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
if (oldDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
} else {
|
||||
process.env.DSH_HOME = oldDshHome
|
||||
}
|
||||
if (oldAgentsHome === undefined) {
|
||||
delete process.env.DSH_AGENTS_HOME
|
||||
} else {
|
||||
process.env.DSH_AGENTS_HOME = oldAgentsHome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-acp-agent composition', () => {
|
||||
it('brings up the spine + persistence + the ACP bridge', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test' })
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-acp-agent-test', skills: await isolatedSkillsConfig() })
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('sessions')).toBeDefined()
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
expect(ctx.get('userInteraction')).toBeDefined()
|
||||
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()
|
||||
// No pre-created agents — ACP session/new creates them on demand.
|
||||
expect(ctx.get('agents')!.list()).toHaveLength(0)
|
||||
await ctx.fiber.dispose()
|
||||
@@ -42,12 +86,30 @@ describe('dsh-acp-agent composition', () => {
|
||||
// persistenceRoot, so the runtime fallback is the one that fires.
|
||||
const ctx = new Context()
|
||||
// No persona: covers the omitted-persona forwarding branch too.
|
||||
acpAgent.apply(ctx, { model: 'mock' })
|
||||
acpAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
acpAgent.apply(ctx, { model: 'mock' })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards skill config into agent-core', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
|
||||
ctx.skills.register({ name: 'acp-skill', description: 'ACP skill', source: 'runtime', content: 'body' })
|
||||
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `acp-skill`: ACP...')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exposes its plugin shape', () => {
|
||||
expect(acpAgent.name).toBe('acp-agent')
|
||||
expect(acpAgent.Config).toBeDefined()
|
||||
@@ -70,7 +132,7 @@ describe('dsh-acp-agent composition', () => {
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'skill'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ async function makeConsumer(): Promise<string> {
|
||||
' name: \'@deepseek-ai/dsh-acp-agent\'',
|
||||
' config:',
|
||||
' model: deepseek-v4-flash',
|
||||
' systemPrompt: \'test agent\'',
|
||||
' persona: \'test agent\'',
|
||||
'',
|
||||
].join('\n'))
|
||||
return dir
|
||||
@@ -120,7 +120,12 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js,
|
||||
child = spawn(process.execPath, ['--expose-internals', acpBin, './cordis.yml'], {
|
||||
cwd: consumer,
|
||||
// Dummy key: initialize never reaches the model, so it is never used.
|
||||
env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
|
||||
DSH_HOME: join(consumer, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(consumer, '.agents'),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
const stderr: string[] = []
|
||||
@@ -180,7 +185,12 @@ function runBinExpectingExit(configArg: string, cwd: string = tmpdir()): Promise
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(process.execPath, ['--expose-internals', acpBin, configArg], {
|
||||
cwd,
|
||||
env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' },
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot',
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
child = proc
|
||||
|
||||
@@ -54,7 +54,7 @@ const CORDIS_YML = `
|
||||
name: '@deepseek-ai/dsh-acp-agent'
|
||||
config:
|
||||
model: deepseek-v4-flash
|
||||
systemPrompt: 'You are a test agent.'
|
||||
persona: 'You are a test agent.'
|
||||
`
|
||||
|
||||
interface Spawned {
|
||||
@@ -90,6 +90,8 @@ async function boot(): Promise<Spawned & { cwd: string }> {
|
||||
TSX_TSCONFIG_PATH: repoTsconfig,
|
||||
// Key-present check only; no prompt is sent, so the model is never called.
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'keyless-acp-agent-smoke',
|
||||
DSH_HOME: join(cwd, '.dsh'),
|
||||
DSH_AGENTS_HOME: join(cwd, '.agents'),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
},
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
{
|
||||
"path": "../../core/agent-core"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../tool-ask-user"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-acp
|
||||
|
||||
The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
|
||||
The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
|
||||
|
||||
It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
|
||||
|
||||
@@ -8,7 +8,7 @@ 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.
|
||||
|
||||
`inject: ['agents', 'sessions', 'sessionPersistence', 'tools']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation).
|
||||
`inject: ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). `userInteraction` lets agent-owned `ask_user_question` calls become ACP form elicitations routed to the owning session.
|
||||
|
||||
### Config
|
||||
|
||||
@@ -30,10 +30,17 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
|
||||
| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
|
||||
| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
|
||||
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) |
|
||||
| `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 | the bridge is the [`ctx.approval`](../user-approval/README.md) answerer for the agents it owns: an `ask` (a hook or `tools/pre-execute` plugin) becomes an editor prompt attached to the streamed tool call, offering one-shot `allow_once`/`reject_once` options only; a foreign or call-less request delegates down the answerer chain (fail-closed `unavailable` default). See "Permission prompts" |
|
||||
| `session/set_config_option` | `setSandboxMode` / `setApprovalPolicy` | per-session knob switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
|
||||
|
||||
## Multi-session
|
||||
|
||||
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.)
|
||||
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. Permission prompts follow the same ownership: the `approval/request` answerer resolves the owning session through the reverse map and prompts only there.
|
||||
|
||||
## Session config options
|
||||
|
||||
The bridge advertises one independent `select` per composable knob in the `session/new`/`session/load` responses — `sandbox-mode` (`read-only`/`workspace-write`/`danger-full-access`, category `mode`) iff the mounted executor confines (`ctx.get('bash')?.sandboxMode` defined), `approval-policy` (`ask`/`never`) iff the approval seam is composed — with each session's `currentValue` folded from its OWN log (`effectiveSandboxMode`/`effectiveApprovalPolicy` ?? the composition default), so `session/load` reports a resumed session's overrides with no catch-up machinery. `session/set_config_option` validates the value against the same closed vocabulary, routes to the domain's write path (`setSandboxMode`/`setApprovalPolicy` — ONE log-only event on that session's log), and returns the complete refreshed state per the spec. Anchoring honors turn-enclosure: a switch while a turn is open appends immediately (openness read from the LOG — `agent.status` stays `running` between queued turns); an idle switch is held on the session record and anchored at the next turn's `agent/prompt-submit` (inside the turn, before anything assembles, last write per knob — an idle flip-flop anchors as one event), because appending from inside a `session/event` listener would reorder events for later-registered peers. Until anchored the switch lives in bridge memory only: responses overlay it truthfully, and a crash before the next turn reverts it — `session/load` then reports the fold's truth. Design: [the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md); protocol matrix: [acp-feature-support.md](acp-feature-support.md) § 6.
|
||||
|
||||
Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload.
|
||||
|
||||
@@ -66,18 +73,21 @@ When the client does NOT advertise the capability, none of the `_meta`/terminal
|
||||
|
||||
A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream). One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the durable boundary event (`closeTurn` appends it unconditionally; there is no `agent/*` turn mirror). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a peer `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang.
|
||||
|
||||
## Permission prompts
|
||||
|
||||
The bridge registers an `approval/request` waterfall listener — the ACP answerer of the [user-approval seam](../user-approval/README.md). When `ctx.approval` routes an `ask` for an agent the bridge owns, the listener resolves the owning session through the reverse map and issues `session/request_permission` with the request's `callId` as the `toolCall` reference (the editor attaches the prompt to the already-streamed call) and the one-shot options `allow_once`/`reject_once` (`allow_always` is deferred to the approval RFC's grant-storage question). Outcomes map `allow-once → allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled → cancelled`. A request for an agent the bridge does NOT own — or one without a `callId` to attach to — delegates via `next()` so another answerer or the seam's fail-closed `unavailable` default takes it. A rejected `requestPermission` RPC (client gone mid-prompt) propagates to the ApprovalService, which contains it as `unavailable`. Whether a call asks at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment; without such policy, tools keep the executor's full authority.
|
||||
|
||||
## Disposal & disconnect
|
||||
|
||||
Teardown reaches quiescence: for EVERY live session settle any pending prompt as `cancelled`, then run that session's [`AgentHandle`](../../core/agent/README.md) `dispose()` — which stops the loop (sets `disposed` + aborts the in-flight step), `await`s the loop's exit (the final `turn/end` + `session/flush` are captured while the session is still attached), unregisters the agent, and removes its session from the store. A turn cut off mid-flight by teardown ends with reason `disposed` (not `aborted` — `dispose()` uses the disposed path, not `session/cancel`'s queue-aware `cancel()`). The per-session disposes run in parallel. The same teardown runs on a **client disconnect** (`conn.closed` resolves when the editor quits / the transport EOFs), so a vanished client never leaves an orphaned running — or idled-but-still-registered — agent whose `session/update` writes are silently swallowed. The two paths are idempotent and memoized (the first clears the `sessions` map; a second caller awaits the same teardown promise).
|
||||
|
||||
## Known limitations (tracked TODOs)
|
||||
|
||||
- **`TODO(rfc010-permission-gate)`** — the `tools/pre-execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land.
|
||||
- **`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.
|
||||
|
||||
## stdout is the protocol
|
||||
|
||||
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging.
|
||||
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../../docs/rfc/implemented/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging.
|
||||
|
||||
## Running
|
||||
|
||||
|
||||
@@ -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), and resumable session replay. The largest **unbuilt** areas are the **permission gate** (`session/request_permission`), **MCP passthrough**, **session modes / config options / model selection**, **slash commands**, and **agent plans** — all of which both reference adapters ship — 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, and per-session sandbox/approval config options. 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).
|
||||
|
||||
## 1. Agent methods (client → agent)
|
||||
|
||||
@@ -25,8 +25,8 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
|
||||
| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. |
|
||||
| `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 not modeled (see [§6 Modes](#6-session-modes--config-options--models)). |
|
||||
| `session/set_config_option` | S | ❌ | ✅ | ✅ | Config options not modeled. |
|
||||
| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement (modes are slated for removal in ACP v2), and one mode list cannot carry the two orthogonal knobs (see [§6](#6-session-modes--config-options--models)). |
|
||||
| `session/set_config_option` | S | ✅ | ✅ | ✅ | Two capability-gated selects — `sandbox-mode` (confining executor mounted) and `approval-policy` (approval seam composed); values validated against the domain vocabularies, one log-only event per switch on the session's own log, complete refreshed state in the response ([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/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`. |
|
||||
@@ -39,7 +39,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
|
||||
| Method | Stable | Bridge | Claude | Codex | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| `session/update` | S | ✅ | ✅ | ✅ | The bridge's primary output channel (see [§4](#4-sessionupdate-variants)). |
|
||||
| `session/request_permission` | S | ❌ | ✅ | ✅ | **The biggest gap.** Tools run with the executor's full authority; no user authorization round-trip. The `agent→sessionId` reverse map is already in place to route a future permission request. Tracked `TODO(rfc010-permission-gate)`. |
|
||||
| `session/request_permission` | S | ✅ | ✅ | ✅ | The bridge answers the [`ctx.approval`](../user-approval/README.md) seam for the agents it owns: an `ask` from a hook/plugin becomes an editor prompt attached to the streamed tool call, one-shot `allow_once`/`reject_once` options only. Whether a call asks is policy (nothing asks by default); `allow_always` is deferred (grant storage). |
|
||||
| `fs/read_text_file` | S | ❌ | ✅ | ❌ | The harness reads files directly (it does not see the editor's unsaved buffer state). Claude delegates; Codex does not. |
|
||||
| `fs/write_text_file` | S | ❌ | ✅ | ❌ | Same — direct writes, no editor delegation. |
|
||||
| `terminal/create` | S | ❌ | ❌ | ❌ | Neither reference adapter drives the client terminal API either — both, like the bridge, render shell output as tool-call content + a `_meta` channel (see [§5 Terminal](#terminal-rendering)). |
|
||||
@@ -47,7 +47,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
|
||||
| `terminal/wait_for_exit` | S | ❌ | ❌ | ❌ | As above. |
|
||||
| `terminal/kill` | S | ❌ | ❌ | ❌ | As above. |
|
||||
| `terminal/release` | S | ❌ | ❌ | ❌ | As above. |
|
||||
| `elicitation/create` · `elicitation/complete` | U | ❌ | ✅ | ⚠️ | Structured user-input forms. Claude calls the `unstable_*` elicitation methods (to surface MCP server elicitations); Codex does NOT — its `CodexElicitationHandler` maps elicitations onto `session/request_permission` instead. |
|
||||
| `elicitation/create` · `elicitation/complete` | U | ⚠️ | ✅ | ⚠️ | The bridge drives `unstable_createElicitation` for `ask_user_question` form prompts (session-scoped, no URL-mode flow yet). Claude calls the `unstable_*` elicitation methods for MCP server elicitations; Codex maps elicitations onto `session/request_permission`. |
|
||||
|
||||
## 3. Capabilities
|
||||
|
||||
@@ -86,7 +86,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
|
||||
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). |
|
||||
| `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. |
|
||||
| `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. |
|
||||
| `config_option_update` | S | ❌ | ✅ | ✅ | No config options. |
|
||||
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md). |
|
||||
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). |
|
||||
| `session_info_update` | S | ❌ | ⚠️ | ⚠️ | Session title/metadata not pushed. |
|
||||
|
||||
@@ -111,7 +111,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
|
||||
|
||||
## 6. Session modes / config options / models
|
||||
|
||||
❌ None modeled. Both reference adapters ship modes (Claude: a "plan" auto-mode; Codex: read-only / agent / agent-full-access mapping to its approval+sandbox policy), the newer config-option surface, and runtime model selection. The harness fixes the model per-bridge via `AcpConfig.model`. These are coupled to the unbuilt **permission gate** (a mode often selects an approval policy), so they are natural follow-ups to it.
|
||||
Config options ✅ (the [sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): the bridge advertises one independent `select` per composable knob — `sandbox-mode` iff the mounted executor confines, `approval-policy` iff the approval seam is composed — with per-session current values folded from each session's own log, and honors `session/set_config_option` end to end (idle switches anchor at the next turn under the turn-enclosure contract). Session MODES stay deliberately unmodeled: config options are the spec's replacement (modes are slated for removal in ACP v2), and one mode list cannot carry two orthogonal knobs. Runtime model selection is still not modeled — the harness fixes the model per-bridge via `AcpConfig.model` (both reference adapters ship a model selector).
|
||||
|
||||
## 7. Content blocks
|
||||
|
||||
@@ -130,7 +130,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
|
||||
| Feature | Stable | Bridge | Notes |
|
||||
|---|---|---|---|
|
||||
| `StopReason` mapping | S | ✅ | `turnEndToStopReason` is total over harness turn-end reasons → `end_turn`/`max_tokens`/`cancelled`. |
|
||||
| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md). |
|
||||
| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). |
|
||||
| Disconnect / disposal teardown | S | ✅ | Quiesces every live session on client disconnect or Cordis disposal. |
|
||||
| `_meta` extensibility | S | ⚠️ | Consumed (Zed terminal cap) and emitted (terminal `_meta`); no other custom extensions. |
|
||||
| Background-task ownership isolation | — | ✅ | `bash_output`/`bash_kill` reject another session's task via an opaque owner token. |
|
||||
@@ -140,15 +140,14 @@ 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. **Permission gate** — `session/request_permission` + permission options. Tracked `TODO(rfc010-permission-gate)`; the reverse map is already wired. Foundational, and a prerequisite for modes.
|
||||
2. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
|
||||
3. **Modes / config options / model selection** — coupled to the permission gate.
|
||||
4. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
|
||||
5. **Slash commands** (`available_commands_update`).
|
||||
6. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
|
||||
7. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
|
||||
9. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
|
||||
10. **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.
|
||||
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.
|
||||
|
||||
## Out of scope
|
||||
|
||||
|
||||
@@ -28,19 +28,27 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@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-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
@@ -49,6 +57,8 @@
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
* `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every
|
||||
* `session/event` and `agent/*` event is routed strictly to its owning session
|
||||
* record, so two sessions streaming at once never interleave their
|
||||
* `session/update` notifications. The `tools/pre-execute` permission gate is
|
||||
* deferred — see the TODO(rfc010-permission-gate) note below.
|
||||
* `session/update` notifications. Permission prompts ride the same ownership
|
||||
* map: the bridge answers `approval/request` for its own agents over
|
||||
* `session/request_permission` (see the approval answerer below) — whether a
|
||||
* call ASKS is policy (a hook or plugin returning `ask`), not the bridge's.
|
||||
*
|
||||
* stdout is the protocol: this plugin must run in an example that loads NO
|
||||
* stdout logger (the console logger writes to stdout and would corrupt the
|
||||
@@ -47,6 +49,9 @@ import {
|
||||
type AuthenticateRequest,
|
||||
type CancelNotification,
|
||||
type ContentBlock as AcpContentBlock,
|
||||
type CreateElicitationRequest,
|
||||
type ElicitationContentValue,
|
||||
type EnumOption,
|
||||
type InitializeRequest,
|
||||
type InitializeResponse,
|
||||
type LoadSessionRequest,
|
||||
@@ -57,7 +62,10 @@ import {
|
||||
type PlanEntry,
|
||||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
type SessionConfigOption,
|
||||
type SessionNotification,
|
||||
type SetSessionConfigOptionRequest,
|
||||
type SetSessionConfigOptionResponse,
|
||||
type Stream,
|
||||
type StopReason,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
@@ -66,11 +74,26 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash'
|
||||
import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
|
||||
// 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 the `approval/request` waterfall
|
||||
// the bridge answers for its own agents (see the approval answerer below).
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import {
|
||||
UserInteractionError,
|
||||
type AskUserQuestionAnswer,
|
||||
type AskUserQuestionAnswerItem,
|
||||
type AskUserQuestionItem,
|
||||
type AskUserQuestionOption,
|
||||
type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
import {
|
||||
acpPromptToText,
|
||||
harnessBlockToAcpContent,
|
||||
@@ -84,7 +107,7 @@ export const name = 'acp'
|
||||
// because `initialize` advertises `loadSession: true`. `tools` lets a tool own
|
||||
// how its calls render (`presentCall`/`presentResult`); the bridge looks up the
|
||||
// definition by name and falls back to a generic presentation when absent.
|
||||
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools']
|
||||
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']
|
||||
|
||||
/**
|
||||
* Build an ACP "invalid params" error whose human detail rides in the message.
|
||||
@@ -111,6 +134,116 @@ function sameWorkspaceCwd(left: string, right: string): boolean {
|
||||
return resolvePath(left) === resolvePath(right)
|
||||
}
|
||||
|
||||
function optionDescription(option: AskUserQuestionOption): string {
|
||||
return option.description === undefined
|
||||
? option.label
|
||||
: `${option.label}: ${option.description}`
|
||||
}
|
||||
|
||||
function requireStringContent(
|
||||
content: Record<string, ElicitationContentValue> | null | undefined,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const value = content?.[key]
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function askAbortError(): UserInteractionError {
|
||||
return new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')
|
||||
}
|
||||
|
||||
function withAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||
if (signal === undefined) return promise
|
||||
if (signal.aborted) return Promise.reject(askAbortError())
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
reject(askAbortError())
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
reject(new Error(String(error), { cause: error }))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function elicitationForQuestion(
|
||||
sessionId: SessionId,
|
||||
question: AskUserQuestionItem,
|
||||
options: AskUserQuestionOption[],
|
||||
): CreateElicitationRequest {
|
||||
const title = question.header ?? 'Question'
|
||||
if (options.length === 0) {
|
||||
return {
|
||||
sessionId,
|
||||
mode: 'form',
|
||||
message: question.question,
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
title,
|
||||
properties: {
|
||||
custom: { type: 'string', title: question.question },
|
||||
},
|
||||
required: ['custom'],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const choiceOptions: EnumOption[] = options.map(option => ({
|
||||
const: option.label,
|
||||
title: optionDescription(option),
|
||||
}))
|
||||
const choice = question.multiSelect === true
|
||||
? {
|
||||
type: 'array' as const,
|
||||
title: question.question,
|
||||
description: 'Choose one or more options, or fill a custom answer below.',
|
||||
items: {
|
||||
anyOf: choiceOptions,
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: 'string' as const,
|
||||
title: question.question,
|
||||
description: 'Choose one option, or fill a custom answer below.',
|
||||
oneOf: choiceOptions,
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
mode: 'form',
|
||||
message: question.question,
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
title,
|
||||
properties: {
|
||||
choice,
|
||||
custom: {
|
||||
type: 'string',
|
||||
title: 'Custom answer',
|
||||
description: 'Optional free-form answer. Leave empty to use the selected option.',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stringArrayContent(
|
||||
content: Record<string, ElicitationContentValue> | null | undefined,
|
||||
key: string,
|
||||
): string[] {
|
||||
const value = content?.[key]
|
||||
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string' && item.length > 0)
|
||||
return typeof value === 'string' && value.length > 0 ? [value] : []
|
||||
}
|
||||
|
||||
/** Plugin config: the agent template ACP sessions are created from. */
|
||||
export interface AcpConfig {
|
||||
/** Model name for created agents (must have a registered adapter). */
|
||||
@@ -187,6 +320,19 @@ interface SessionRecord {
|
||||
turn: number | undefined
|
||||
logWatermark: number
|
||||
} | undefined
|
||||
/**
|
||||
* Config switches accepted while the session was IDLE, not yet anchored in
|
||||
* its log. The turn-enclosure contract makes a bare between-turns append
|
||||
* invalid (the JSONL backend treats a post-`turn/end` tail as crash
|
||||
* garbage, and dev invariants throw), so an idle switch waits here and is
|
||||
* anchored at the next turn's prompt-submit — before anything in that
|
||||
* turn assembles a prompt or runs a call, and last write
|
||||
* per knob wins (an idle flip-flop anchors as one event). Until anchored,
|
||||
* the switch lives only in bridge memory: the set/new/load responses
|
||||
* overlay it truthfully, and a restart before the next turn reverts it —
|
||||
* which `session/load` then reports honestly from the log's fold.
|
||||
*/
|
||||
pendingSwitches: { sandboxMode?: SandboxMode; approvalPolicy?: ApprovalPolicy }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,6 +357,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
const sessionPersistence = ctx.sessionPersistence
|
||||
const logger = ctx.logger
|
||||
const tools = ctx.tools
|
||||
const userInteraction = ctx.userInteraction
|
||||
// A new ToolPresenter per session (and a throwaway per load replay), each given
|
||||
// this warn sink so a throwing tool presenter is logged, not propagated.
|
||||
const makePresenter = (): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) })
|
||||
@@ -241,6 +388,42 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// `notify` never observes it unset — no undefined guard needed.
|
||||
let conn: AgentSideConnection
|
||||
|
||||
userInteraction.registerProvider({
|
||||
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
|
||||
if (request.agent === undefined) {
|
||||
throw new UserInteractionError('ACP user questions must come from an agent-owned request', 'NO_AGENT')
|
||||
}
|
||||
const sessionId = bySession.get(request.agent)
|
||||
if (sessionId === undefined) {
|
||||
throw new UserInteractionError('ACP user question has no matching session', 'NO_SESSION')
|
||||
}
|
||||
const answers: AskUserQuestionAnswerItem[] = []
|
||||
for (const question of request.questions) {
|
||||
const options = question.options ?? []
|
||||
const response = await withAbort(conn.unstable_createElicitation(
|
||||
elicitationForQuestion(sessionId, question, options),
|
||||
), request.signal).catch((error: unknown) => {
|
||||
if (error instanceof UserInteractionError) throw error
|
||||
throw new UserInteractionError('ACP elicitation request failed', 'ASK_FAILED', { cause: error })
|
||||
})
|
||||
if (response.action !== 'accept') {
|
||||
throw new UserInteractionError('ask_user_question was cancelled by the user', 'ASK_CANCELLED')
|
||||
}
|
||||
const custom = requireStringContent(response.content, 'custom')
|
||||
const selected = stringArrayContent(response.content, 'choice')
|
||||
if (custom === undefined && selected.length === 0) {
|
||||
throw new UserInteractionError('ask_user_question returned no answer', 'NO_ANSWER')
|
||||
}
|
||||
answers.push({
|
||||
id: question.id,
|
||||
selected: custom === undefined ? selected : [],
|
||||
...custom !== undefined ? { custom } : {},
|
||||
})
|
||||
}
|
||||
return { answers }
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Reject any RPC after the bridge has torn down. The `AgentSideConnection`
|
||||
* receive loop can outlive the plugin fiber — under an ACP-only HMR reload the
|
||||
@@ -397,8 +580,136 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
if (status === 'idle' || status === 'disposed') settleFromLog(rec)
|
||||
})
|
||||
|
||||
// --- Approval answerer -----------------------------------------------------
|
||||
// The bridge is the approval channel for the agents it owns: an `ask` routed
|
||||
// through `ctx.approval` (dsh-tools asks and sandbox escalation) becomes
|
||||
// an editor permission prompt attached to the already-streamed tool call. The
|
||||
// listener occupies the single decision slot ONLY for its own agents — a
|
||||
// foreign or call-less request delegates via next() so another answerer (or
|
||||
// the fail-closed `unavailable` default) takes the question. A rejected
|
||||
// `requestPermission` (client gone, bridge torn down) propagates and the
|
||||
// ApprovalService contains it as `unavailable`. Options are one-shot only:
|
||||
// allow_always is a grant-storage design the approval RFC defers, so the
|
||||
// prompt never offers a durable grant the harness could not honor.
|
||||
ctx.on('approval/request', (req, next) => {
|
||||
const sessionId = bySession.get(req.agent)
|
||||
// The protocol requires `toolCall` (the prompt renders attached to it), so
|
||||
// a request without a callId has nothing to attach to — delegate.
|
||||
if (sessionId === undefined || req.callId === undefined) return next()
|
||||
return conn.requestPermission({
|
||||
sessionId,
|
||||
toolCall: { toolCallId: req.callId },
|
||||
options: [
|
||||
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
|
||||
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
|
||||
],
|
||||
}).then(({ outcome }) => {
|
||||
if (outcome.outcome === 'cancelled') return 'cancelled'
|
||||
// Only the two advertised options exist; an unknown optionId from a
|
||||
// non-conforming client counts as a rejection, never a grant.
|
||||
return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected'
|
||||
})
|
||||
})
|
||||
|
||||
// --- The ACP Agent method surface -----------------------------------------
|
||||
|
||||
/**
|
||||
* The session config options this composition can honor, with current
|
||||
* values folded from the AGENT'S OWN session log (`effectiveSandboxMode` /
|
||||
* `effectiveApprovalPolicy` — the log is the per-session store, so a
|
||||
* `session/load` reports a resumed session's overrides with no catch-up
|
||||
* machinery), overlaid with the record's not-yet-anchored pending switches
|
||||
* (see {@link SessionRecord.pendingSwitches}). Capability-gated like every
|
||||
* advertised lever: the sandbox option exists only when the mounted
|
||||
* executor confines (`ctx.get('bash')?.sandboxMode` defined), the approval
|
||||
* option only when the approval seam is composed — both read
|
||||
* opportunistically so this bridge keeps working in compositions without
|
||||
* them.
|
||||
*/
|
||||
const configOptionsFor = (agent: Agent, pending: SessionRecord['pendingSwitches'] = {}): SessionConfigOption[] => {
|
||||
const options: SessionConfigOption[] = []
|
||||
const defaultMode = ctx.get('bash')?.sandboxMode
|
||||
if (defaultMode !== undefined) {
|
||||
options.push({
|
||||
id: 'sandbox-mode',
|
||||
name: 'Sandbox',
|
||||
description: 'The file sandbox mode bash commands in this session run under.',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue: pending.sandboxMode ?? effectiveSandboxMode(agent.session.events) ?? defaultMode,
|
||||
options: SANDBOX_MODES.map(mode => ({ value: mode, name: mode })),
|
||||
})
|
||||
}
|
||||
const approval = ctx.get('approval')
|
||||
if (approval !== undefined) {
|
||||
options.push({
|
||||
id: 'approval-policy',
|
||||
name: 'Approvals',
|
||||
description: 'ask: permission prompts reach you; never: they are rejected automatically.',
|
||||
type: 'select',
|
||||
// `?? 'ask'` also shields against a provided stand-in whose config
|
||||
// never went through the plugin schema (tests do this).
|
||||
currentValue: pending.approvalPolicy ?? effectiveApprovalPolicy(agent.session.events) ?? approval.config.policy ?? 'ask',
|
||||
options: APPROVAL_POLICIES.map(policy => ({ value: policy, name: policy })),
|
||||
})
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session's log currently has an open turn — the last boundary
|
||||
* event is a `turn/start`. Decides whether a config switch may append NOW
|
||||
* (enclosed) or must wait for the next turn (see
|
||||
* {@link SessionRecord.pendingSwitches}). Read from the LOG, not
|
||||
* `agent.status`: status stays `running` across the gap between two queued
|
||||
* turns, where a bare append would still land outside any turn.
|
||||
*/
|
||||
const isTurnOpen = (agent: Agent): boolean => {
|
||||
const events = agent.session.events
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const type = (events[index] as SessionEvent).type
|
||||
if (type === 'turn/start') return true
|
||||
if (type === 'turn/end') return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor a record's pending switches into its (just-opened) turn, last
|
||||
* write per knob — skipping a value the session already effectively has,
|
||||
* so a net-zero idle flip-flop anchors NOTHING (the log records switches,
|
||||
* not select clicks).
|
||||
*/
|
||||
const flushPendingSwitches = (rec: SessionRecord): void => {
|
||||
const pending = rec.pendingSwitches
|
||||
rec.pendingSwitches = {}
|
||||
const events = rec.agent.session.events
|
||||
if (pending.sandboxMode !== undefined
|
||||
&& pending.sandboxMode !== (effectiveSandboxMode(events) ?? ctx.get('bash')?.sandboxMode)) {
|
||||
setSandboxMode(rec.agent.session, pending.sandboxMode)
|
||||
}
|
||||
if (pending.approvalPolicy !== undefined
|
||||
&& pending.approvalPolicy !== (effectiveApprovalPolicy(events) ?? ctx.get('approval')?.config.policy ?? 'ask')) {
|
||||
setApprovalPolicy(rec.agent.session, pending.approvalPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
// Idle-accepted switches anchor at the next turn's prompt-submit: the turn
|
||||
// is open (the seam fires inside it, per drained message — the first flush
|
||||
// empties the slot, later ones no-op), the loop has not yet assembled
|
||||
// anything for it, and — unlike appending from inside a `session/event`
|
||||
// listener — this seam fires OUTSIDE any log emit, so peer listeners
|
||||
// (the dev invariants, persistence) observe the anchored events in strict
|
||||
// log order. A turn with no prompt (an idle inject's one-shot injection
|
||||
// turn) leaves the switch pending — it runs no step, so nothing executes
|
||||
// or assembles under a stale value.
|
||||
ctx.on('agent/prompt-submit', (agent, _content, _source, next) => {
|
||||
const sessionId = bySession.get(agent)
|
||||
const rec = sessionId === undefined ? undefined : sessions.get(sessionId)
|
||||
if (rec !== undefined) flushPendingSwitches(rec)
|
||||
return next()
|
||||
})
|
||||
|
||||
const makeAgent = (connection: AgentSideConnection): AcpAgent => {
|
||||
conn = connection
|
||||
return {
|
||||
@@ -452,8 +763,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
presenter: makePresenter(),
|
||||
terminalEnabled: terminalOutputCap,
|
||||
inflight: undefined,
|
||||
pendingSwitches: {},
|
||||
})
|
||||
return Promise.resolve({ sessionId })
|
||||
const configOptions = configOptionsFor(handle.agent)
|
||||
return Promise.resolve({ sessionId, ...configOptions.length > 0 ? { configOptions } : {} })
|
||||
},
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
@@ -529,6 +842,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
presenter: makePresenter(),
|
||||
terminalEnabled,
|
||||
inflight: undefined,
|
||||
pendingSwitches: {},
|
||||
}
|
||||
sessions.set(sessionId, record)
|
||||
// Replay the persisted event log to the client as session/update. Use
|
||||
@@ -552,7 +866,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
for (const event of agent.session.events) {
|
||||
streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal)
|
||||
}
|
||||
return {}
|
||||
const configOptions = configOptionsFor(agent)
|
||||
return configOptions.length > 0 ? { configOptions } : {}
|
||||
} finally {
|
||||
loadingIds.delete(sessionId)
|
||||
}
|
||||
@@ -607,6 +922,62 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
settlePrompt(rec, 'cancelled')
|
||||
return Promise.resolve()
|
||||
},
|
||||
|
||||
setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
|
||||
assertOpen()
|
||||
const rec = requireSession(SessionId(params.sessionId))
|
||||
// Both advertised options are selects, so the boolean-shaped variant of
|
||||
// the request 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`)
|
||||
}
|
||||
// The setters append ONE log-only event on this session's own log —
|
||||
// the log is the store (the sandbox RFC § Per-session mode switching): execution, the
|
||||
// prompt section, and the narrator all fold it from there, and a
|
||||
// resumed session reports the override back through
|
||||
// configOptionsFor. A switch while a turn is OPEN anchors
|
||||
// immediately (the next step sees it); an IDLE switch waits in
|
||||
// pendingSwitches for the next `turn/start` (turn-enclosure: a bare
|
||||
// between-turns append would be dropped as crash tail on reload).
|
||||
// Values are validated against the same closed lists the options
|
||||
// advertised; an id this composition never advertised (or an unknown
|
||||
// one) rejects.
|
||||
switch (params.configId) {
|
||||
case 'sandbox-mode': {
|
||||
const defaultMode = ctx.get('bash')?.sandboxMode
|
||||
if (defaultMode === undefined || !SANDBOX_MODES.includes(params.value as SandboxMode)) {
|
||||
throw invalidParams(`unknown sandbox-mode value ${JSON.stringify(params.value)}`)
|
||||
}
|
||||
const value = params.value as SandboxMode
|
||||
// A no-op switch (the value the session already shows — pending,
|
||||
// else fold, else default) is acknowledged without recording
|
||||
// anything: clients that re-push current selections on session
|
||||
// start must not mint override events out of thin air.
|
||||
const current = rec.pendingSwitches.sandboxMode ?? effectiveSandboxMode(rec.agent.session.events) ?? defaultMode
|
||||
if (value === current) break
|
||||
if (isTurnOpen(rec.agent)) setSandboxMode(rec.agent.session, value)
|
||||
else rec.pendingSwitches.sandboxMode = value
|
||||
break
|
||||
}
|
||||
case 'approval-policy': {
|
||||
const approval = ctx.get('approval')
|
||||
if (approval === undefined || !APPROVAL_POLICIES.includes(params.value as ApprovalPolicy)) {
|
||||
throw invalidParams(`unknown approval-policy value ${JSON.stringify(params.value)}`)
|
||||
}
|
||||
const value = params.value as ApprovalPolicy
|
||||
const current = rec.pendingSwitches.approvalPolicy ?? effectiveApprovalPolicy(rec.agent.session.events) ?? approval.config.policy ?? 'ask'
|
||||
if (value === current) break
|
||||
if (isTurnOpen(rec.agent)) setApprovalPolicy(rec.agent.session, value)
|
||||
else rec.pendingSwitches.approvalPolicy = value
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw invalidParams(`unknown config option ${JSON.stringify(params.configId)}`)
|
||||
}
|
||||
// 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) })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
108
packages/ui/acp/tests/approval.spec.ts
Normal file
108
packages/ui/acp/tests/approval.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
|
||||
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
|
||||
|
||||
/**
|
||||
* The bridge's `approval/request` answerer: an ask for an agent the bridge
|
||||
* owns becomes a `session/request_permission` prompt attached to the tool
|
||||
* call; foreign or call-less requests delegate down to the fail-closed
|
||||
* default. Driven through `ctx.approval` — the same path dsh-tools' ask
|
||||
* routing takes — against the harness's scriptable client.
|
||||
*/
|
||||
describe('acp bridge — approval answerer', () => {
|
||||
let storageDir: string
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-approval-')) })
|
||||
afterEach(async () => {
|
||||
await harness?.dispose()
|
||||
harness = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function ownedAgentRequest(
|
||||
h: BridgeHarness, overrides: Partial<ApprovalRequest> = {},
|
||||
): Promise<{ agent: Agent; request: ApprovalRequest }> {
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = h.ctx.agents.get(AgentId(sessionId))
|
||||
if (agent === undefined) throw new Error('newSession created no agent')
|
||||
// In production an ask always fires mid-turn (tool execution); open one so
|
||||
// request()'s turn-enclosure precondition holds for the direct drive below.
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
return { agent, request: { agent, toolName: 'echo', callId: CallId('call-9'), ...overrides } }
|
||||
}
|
||||
|
||||
it('prompts the editor for an owned agent and maps allow-once → allowed-once', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
|
||||
|
||||
const { request } = await ownedAgentRequest(harness)
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('allowed-once')
|
||||
|
||||
expect(harness.permissionRequests).toHaveLength(1)
|
||||
const wire = harness.permissionRequests[0]
|
||||
expect(wire?.toolCall).toEqual({ toolCallId: 'call-9' })
|
||||
expect(wire?.options.map(o => ({ optionId: o.optionId, kind: o.kind }))).toEqual([
|
||||
{ optionId: 'allow-once', kind: 'allow_once' },
|
||||
{ optionId: 'reject-once', kind: 'reject_once' },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps reject-once → rejected', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'reject-once' } })
|
||||
|
||||
const { request } = await ownedAgentRequest(harness)
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('maps a client cancellation → cancelled', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'cancelled' } })
|
||||
|
||||
const { request } = await ownedAgentRequest(harness)
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('cancelled')
|
||||
})
|
||||
|
||||
it('treats an unknown optionId from a non-conforming client as a rejection, never a grant', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-always-i-insist' } })
|
||||
|
||||
const { request } = await ownedAgentRequest(harness)
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('delegates a foreign agent down to the fail-closed default', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
|
||||
|
||||
// Not created through the bridge: no bySession entry, so the answerer must
|
||||
// call next() — nobody else answers, so the seam fails closed.
|
||||
const foreign = { session: { events: [{ type: 'turn/start' }], append: () => ({}) } } as unknown as Agent
|
||||
await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'echo', callId: CallId('c') }))
|
||||
.resolves.toBe('unavailable')
|
||||
expect(harness.permissionRequests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('delegates a call-less request — the protocol prompt must attach to a tool call', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
|
||||
|
||||
const { agent } = await ownedAgentRequest(harness)
|
||||
await expect(harness.ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('unavailable')
|
||||
expect(harness.permissionRequests).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
||||
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness } from './harness.ts'
|
||||
|
||||
/**
|
||||
* End-to-end bridge specs over an in-memory transport: a real
|
||||
@@ -53,6 +53,209 @@ describe('acp bridge', () => {
|
||||
expect(text).toBe('hello there')
|
||||
})
|
||||
|
||||
it('routes ask_user_question through ACP form elicitation and continues with the selected option', async () => {
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withAskUser: true,
|
||||
script: [
|
||||
toolCallResponse('ask-1', 'ask_user_question', {
|
||||
questions: [{
|
||||
id: 'language',
|
||||
header: 'Project config',
|
||||
question: 'Which language should I use?',
|
||||
options: [
|
||||
{ label: 'TypeScript', description: 'Good for UI apps' },
|
||||
{ label: 'Python', description: 'Good for scripts' },
|
||||
],
|
||||
}],
|
||||
}),
|
||||
textResponse('Python it is.'),
|
||||
],
|
||||
})
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'Python' } })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'ask me' }] })
|
||||
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
expect(harness.elicitationRequests).toHaveLength(1)
|
||||
expect(harness.elicitationRequests[0]).toMatchObject({
|
||||
sessionId,
|
||||
mode: 'form',
|
||||
message: 'Which language should I use?',
|
||||
requestedSchema: {
|
||||
title: 'Project config',
|
||||
properties: {
|
||||
choice: {
|
||||
oneOf: [
|
||||
{ const: 'TypeScript', title: 'TypeScript: Good for UI apps' },
|
||||
{ const: 'Python', title: 'Python: Good for scripts' },
|
||||
],
|
||||
},
|
||||
custom: { type: 'string' },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
})
|
||||
const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result')
|
||||
const toolResultBlock = toolResult?.type === 'tool/result' ? toolResult.data.content[0] : undefined
|
||||
const toolResultText = toolResultBlock?.type === 'text' ? toolResultBlock.text : undefined
|
||||
expect(toolResultText).toBe('{"answers":[{"id":"language","selected":["Python"]}]}')
|
||||
})
|
||||
|
||||
it('routes optionless ask_user_question through an ACP free-form answer field', async () => {
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withAskUser: true,
|
||||
script: [
|
||||
toolCallResponse('ask-1', 'ask_user_question', {
|
||||
questions: [{ id: 'name', question: 'What should I name it?' }],
|
||||
}),
|
||||
textResponse('Name recorded.'),
|
||||
],
|
||||
})
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { custom: 'apollo' } })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'ask me' }] })
|
||||
|
||||
expect(harness.elicitationRequests[0]).toMatchObject({
|
||||
requestedSchema: {
|
||||
properties: { custom: { type: 'string', title: 'What should I name it?' } },
|
||||
required: ['custom'],
|
||||
},
|
||||
})
|
||||
const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result')
|
||||
expect(JSON.stringify(toolResult)).toContain('apollo')
|
||||
})
|
||||
|
||||
it('supports ACP custom answers alongside choices', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { custom: 'Use Zig' } })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
|
||||
const result = await harness.ctx.userInteraction.ask({
|
||||
agent,
|
||||
questions: [{
|
||||
id: 'language',
|
||||
question: 'Which language?',
|
||||
options: [{ label: 'TypeScript' }],
|
||||
}],
|
||||
})
|
||||
|
||||
expect(result).toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] })
|
||||
expect(harness.elicitationRequests[0]).toMatchObject({
|
||||
requestedSchema: {
|
||||
properties: {
|
||||
choice: {
|
||||
description: 'Choose one option, or fill a custom answer below.',
|
||||
oneOf: [{ const: 'TypeScript', title: 'TypeScript' }],
|
||||
},
|
||||
custom: { type: 'string' },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('treats ACP custom answers as overriding selected choices', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'TypeScript', custom: 'Use Zig' } })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
|
||||
await expect(harness.ctx.userInteraction.ask({
|
||||
agent,
|
||||
questions: [{
|
||||
id: 'language',
|
||||
question: 'Which language?',
|
||||
options: [{ label: 'TypeScript' }],
|
||||
}],
|
||||
})).resolves.toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] })
|
||||
})
|
||||
|
||||
it('supports ACP multi-select answers', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { choice: ['Tests', 'Docs'] } })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
|
||||
await expect(harness.ctx.userInteraction.ask({
|
||||
agent,
|
||||
questions: [{
|
||||
id: 'targets',
|
||||
question: 'Pick',
|
||||
options: [{ label: 'Tests' }, { label: 'Docs' }],
|
||||
multiSelect: true,
|
||||
}],
|
||||
})).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Tests', 'Docs'] }] })
|
||||
})
|
||||
|
||||
it('reports ACP ask-user routing and answer failures as structured errors', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
|
||||
await expect(harness.ctx.userInteraction.ask({ questions: [{ id: 'x', question: 'No agent?' }] }))
|
||||
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' })
|
||||
await expect(harness.ctx.userInteraction.ask({ agent: { id: 'other' } as typeof agent, questions: [{ id: 'x', question: 'No session?' }] }))
|
||||
.rejects.toMatchObject({ code: 'NO_SESSION' })
|
||||
|
||||
harness.onElicitation = () => ({ action: 'cancel' })
|
||||
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Cancel?' }] }))
|
||||
.rejects.toMatchObject({ code: 'ASK_CANCELLED' })
|
||||
|
||||
harness.onElicitation = () => ({ action: 'accept', content: {} })
|
||||
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Empty?' }] }))
|
||||
.rejects.toMatchObject({ code: 'NO_ANSWER' })
|
||||
|
||||
harness.onElicitation = () => { throw new Error('client boom') }
|
||||
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Client fails?' }], signal: new AbortController().signal }))
|
||||
.rejects.toMatchObject({ code: 'ASK_FAILED' })
|
||||
})
|
||||
|
||||
it('aborts ACP ask-user requests before and while waiting for elicitation', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(AgentId(sessionId))!
|
||||
|
||||
const alreadyAborted = new AbortController()
|
||||
alreadyAborted.abort()
|
||||
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Already?' }], signal: alreadyAborted.signal }))
|
||||
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
|
||||
let abortedReads = 0
|
||||
const racingAbort = {
|
||||
get aborted() { return abortedReads++ > 0 },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent() { return false },
|
||||
onabort: null,
|
||||
reason: undefined,
|
||||
throwIfAborted() {},
|
||||
} as AbortSignal
|
||||
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Raced?' }], signal: racingAbort }))
|
||||
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
|
||||
let release: ((value: { action: 'accept'; content: { custom: string } }) => void) | undefined
|
||||
harness.onElicitation = () => new Promise((resolve) => { release = resolve })
|
||||
const pendingAbort = new AbortController()
|
||||
const ask = harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Pending?' }], signal: pendingAbort.signal })
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
pendingAbort.abort()
|
||||
|
||||
await expect(ask).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
release?.({ action: 'accept', content: { custom: 'too late' } })
|
||||
})
|
||||
|
||||
it('allows multiple concurrent sessions, each with a distinct id', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
|
||||
260
packages/ui/acp/tests/config-options.spec.ts
Normal file
260
packages/ui/acp/tests/config-options.spec.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Session config options over the bridge: the two per-session knobs
|
||||
* (`sandbox-mode`, `approval-policy`) advertised from composition capability,
|
||||
* their current values folded from each session's own log, switching via
|
||||
* `session/set_config_option` (one log-only event per switch — the log is the
|
||||
* store), and a resumed session reporting its overrides back on
|
||||
* `session/load` with no catch-up machinery.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
||||
|
||||
/**
|
||||
* The REAL local executor reporting a confining default — `sandboxMode` is
|
||||
* the documented capability override point (`dsh-bash-sandbox` overrides it
|
||||
* the same way), so the bridge sees exactly what a sandboxing composition
|
||||
* advertises without this suite dragging in a kernel sandbox stack.
|
||||
*/
|
||||
class SandboxedLocalExecutor extends LocalBashExecutor {
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return 'read-only'
|
||||
}
|
||||
}
|
||||
|
||||
/** The exact option payloads the bridge advertises (pinned verbatim). */
|
||||
function sandboxOption(currentValue: SandboxMode): object {
|
||||
return {
|
||||
id: 'sandbox-mode',
|
||||
name: 'Sandbox',
|
||||
description: 'The file sandbox mode bash commands in this session run under.',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [
|
||||
{ value: 'read-only', name: 'read-only' },
|
||||
{ value: 'workspace-write', name: 'workspace-write' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function approvalOption(currentValue: ApprovalPolicy): object {
|
||||
return {
|
||||
id: 'approval-policy',
|
||||
name: 'Approvals',
|
||||
description: 'ask: permission prompts reach you; never: they are rejected automatically.',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [
|
||||
{ value: 'ask', name: 'ask' },
|
||||
{ value: 'never', name: 'never' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe('acp bridge — session config options', () => {
|
||||
let storageDir: string
|
||||
let h: BridgeHarness | undefined
|
||||
let loader: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-config-')) })
|
||||
afterEach(async () => {
|
||||
if (h) await h.dispose()
|
||||
if (loader) await loader.dispose()
|
||||
h = loader = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A harness whose composition can honor both knobs (sandboxed executor + approval seam). */
|
||||
async function bothKnobs(options: { policy?: ApprovalPolicy; script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
|
||||
const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} })
|
||||
// The dev invariants police turn-enclosure: an idle switch that appended
|
||||
// outside a turn would throw right here in the suite, not in production.
|
||||
await harness.ctx.plugin(Invariants)
|
||||
await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
|
||||
await harness.ctx.plugin(ApprovalService, options.policy !== undefined ? { policy: options.policy } : {})
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
return harness
|
||||
}
|
||||
|
||||
it('advertises no configOptions in a composition with neither knob', async () => {
|
||||
h = await makeBridgeHarness({ storageDir })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a non-confining executor advertises no sandbox option (nothing would honor it)', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, withBash: true })
|
||||
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).toEqual([approvalOption('ask')])
|
||||
})
|
||||
|
||||
it('advertises both knobs with capability-derived currents (config default included)', async () => {
|
||||
h = await bothKnobs({ policy: 'never' })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')])
|
||||
})
|
||||
|
||||
it('an idle switch is pending (overlaid, not yet logged), then anchors INSIDE the next turn', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const afterSandbox = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
expect(afterSandbox.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('ask')])
|
||||
const afterApproval = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
expect(afterApproval.configOptions).toEqual([sandboxOption('workspace-write'), approvalOption('never')])
|
||||
|
||||
// Idle: nothing in the log yet — turn-enclosure forbids a bare append
|
||||
// (the dev invariants in this suite would throw), so the switch lives on
|
||||
// the record until a turn opens.
|
||||
const session = h.ctx.agents.list()[0]?.session
|
||||
expect(session?.events.some(e => e.type === 'bash/sandbox-mode' || e.type === 'approval/policy')).toBe(false)
|
||||
|
||||
// The next turn anchors both switches inside itself, one event per knob.
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = session?.events ?? []
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }])
|
||||
expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
|
||||
const turnStart = events.findIndex(e => e.type === 'turn/start')
|
||||
const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode')
|
||||
expect(turnStart).toBeGreaterThanOrEqual(0)
|
||||
expect(anchored).toBeGreaterThan(turnStart)
|
||||
})
|
||||
|
||||
it('an idle flip-flop anchors as ONE event (last write per knob wins)', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: '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 === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
|
||||
// Idle again AFTER a completed turn (the log now ends in turn/end): a new
|
||||
// switch pends rather than appending outside the closed turn.
|
||||
const again = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' })
|
||||
expect(again.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' })
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
// Re-pushing the composition default (what clients that echo current
|
||||
// selections on session start do) must not mint an override event.
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' })
|
||||
expect(echo.configOptions?.find(option => option.id === 'approval-policy')).toMatchObject({ currentValue: 'ask' })
|
||||
// Re-sending a PENDING value keeps the pending switch alive (it is what
|
||||
// the session shows), rather than cancelling it.
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
expect(repeat.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'workspace-write' })
|
||||
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 === 'approval/policy')).toHaveLength(0)
|
||||
expect(events.filter(e => e.type === 'bash/sandbox-mode').map(e => e.data)).toEqual([{ mode: 'workspace-write' }])
|
||||
})
|
||||
|
||||
it('a net-zero idle flip-flop anchors NOTHING (switches are recorded, select clicks are not)', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
const back = await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'read-only' })
|
||||
expect(back.configOptions?.find(option => option.id === 'sandbox-mode')).toMatchObject({ currentValue: 'read-only' })
|
||||
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 === 'bash/sandbox-mode')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a mid-turn switch anchors immediately (the open turn encloses it)', async () => {
|
||||
h = await bothKnobs({ script: ['hang'] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const hung = h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
// Give the loop a tick to open the turn (the turns.spec hang idiom).
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
const turnStart = events.findIndex(e => e.type === 'turn/start')
|
||||
const anchored = events.findIndex(e => e.type === 'bash/sandbox-mode')
|
||||
expect(turnStart).toBeGreaterThanOrEqual(0)
|
||||
expect(anchored).toBeGreaterThan(turnStart)
|
||||
expect(events.some(e => e.type === 'approval/policy')).toBe(true)
|
||||
await h.client.cancel({ sessionId })
|
||||
await hung
|
||||
})
|
||||
|
||||
it('tolerates a provided approval stand-in whose config skipped the plugin schema', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
||||
h.ctx.provide('approval', { config: {} } as unknown as InstanceType<typeof ApprovalService>)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([approvalOption('ask')])
|
||||
const sessionId = res.sessionId
|
||||
// The schema-less config also shields the no-op guard ('ask' by the ?? fallback)…
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'ask' })
|
||||
expect(echo.configOptions).toEqual([approvalOption('ask')])
|
||||
// …and the anchor-time comparison: a real switch under the stand-in still anchors.
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
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 === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
|
||||
})
|
||||
|
||||
it('rejects unknown ids, unadvertised ids, boolean values, and out-of-vocabulary values', async () => {
|
||||
h = await makeBridgeHarness({ storageDir })
|
||||
await h.ctx.plugin(ApprovalService)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'reasoning-effort', value: 'max' }))
|
||||
.rejects.toThrow(/unknown config option/)
|
||||
// sandbox-mode exists as a concept but THIS composition never advertised it.
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'workspace-write' }))
|
||||
.rejects.toThrow(/unknown sandbox-mode value/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', type: 'boolean', value: true }))
|
||||
.rejects.toThrow(/select; boolean values are not accepted/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'always' }))
|
||||
.rejects.toThrow(/unknown approval-policy value/)
|
||||
})
|
||||
|
||||
it('a switch in one session never leaks into a concurrent one (state and pending both per-session)', async () => {
|
||||
h = await bothKnobs()
|
||||
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: 'sandbox-mode', value: 'danger-full-access' })
|
||||
// B sees its own composition defaults, not A's pending switch...
|
||||
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'approval-policy', value: 'never' })
|
||||
expect(bAfter.configOptions).toEqual([sandboxOption('read-only'), approvalOption('never')])
|
||||
// ...and A keeps its own state, untouched by B's.
|
||||
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
|
||||
expect(aAfter.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('ask')])
|
||||
})
|
||||
|
||||
it('session/load reports a resumed session\'s overrides from its own log', async () => {
|
||||
h = await bothKnobs({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'sandbox-mode', value: 'danger-full-access' })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'approval-policy', value: 'never' })
|
||||
// One turn checkpoints the log (the switch events flush with it).
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist me' }] })
|
||||
await h.dispose()
|
||||
h = undefined
|
||||
|
||||
loader = await bothKnobs()
|
||||
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([sandboxOption('danger-full-access'), approvalOption('never')])
|
||||
})
|
||||
})
|
||||
@@ -29,11 +29,15 @@ import {
|
||||
ndJsonStream,
|
||||
type Agent as AcpAgent,
|
||||
type Client,
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
type Stream,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as AcpPlugin from '../src/index.ts'
|
||||
import { type AcpConfig } from '../src/index.ts'
|
||||
|
||||
@@ -121,6 +125,10 @@ export interface BridgeHarness {
|
||||
permissionRequests: RequestPermissionRequest[]
|
||||
/** Decide each permission request's outcome (default: cancelled). */
|
||||
onPermission: (req: RequestPermissionRequest) => RequestPermissionResponse
|
||||
/** Elicitation requests the bridge issued for ask_user_question. */
|
||||
elicitationRequests: CreateElicitationRequest[]
|
||||
/** Decide each elicitation response (default: cancel). */
|
||||
onElicitation: (req: CreateElicitationRequest) => CreateElicitationResponse | Promise<CreateElicitationResponse>
|
||||
/** If set, the client's sessionUpdate throws this (tests notify error path). */
|
||||
onSessionUpdateError: (() => void) | undefined
|
||||
/**
|
||||
@@ -164,6 +172,8 @@ export async function makeBridgeHarness(options: {
|
||||
* implementation over a mock in tests").
|
||||
*/
|
||||
withBash?: boolean
|
||||
/** Plug the REAL `ask_user_question` tool and ACP user-interaction provider. */
|
||||
withAskUser?: boolean
|
||||
/**
|
||||
* Plug the REAL `dsh-tool-todo` tool so a test can drive `todo_write` through
|
||||
* the bridge and assert the resulting `plan` sessionUpdate — the shipping
|
||||
@@ -190,6 +200,10 @@ export async function makeBridgeHarness(options: {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
|
||||
await ctx.plugin(UserInteractionService)
|
||||
if (options.withAskUser) {
|
||||
await ctx.plugin(ToolAskUser)
|
||||
}
|
||||
if (options.withBash) {
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash)
|
||||
@@ -226,6 +240,7 @@ export async function makeBridgeHarness(options: {
|
||||
const updates: CapturedUpdate[] = []
|
||||
const sessionUpdates: { sessionId: string; update: CapturedUpdate }[] = []
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const elicitationRequests: CreateElicitationRequest[] = []
|
||||
const harness: BridgeHarness = {
|
||||
ctx,
|
||||
adapter,
|
||||
@@ -233,6 +248,8 @@ export async function makeBridgeHarness(options: {
|
||||
sessionUpdates,
|
||||
permissionRequests,
|
||||
onPermission: () => ({ outcome: { outcome: 'cancelled' } }),
|
||||
elicitationRequests,
|
||||
onElicitation: () => ({ action: 'cancel' }),
|
||||
onSessionUpdateError: undefined,
|
||||
client: undefined as unknown as ClientSideConnection,
|
||||
acpFiber: undefined as unknown as BridgeHarness['acpFiber'],
|
||||
@@ -258,6 +275,10 @@ export async function makeBridgeHarness(options: {
|
||||
permissionRequests.push(params)
|
||||
return Promise.resolve(harness.onPermission(params))
|
||||
},
|
||||
unstable_createElicitation(params: CreateElicitationRequest): Promise<CreateElicitationResponse> {
|
||||
elicitationRequests.push(params)
|
||||
return Promise.resolve(harness.onElicitation(params))
|
||||
},
|
||||
})
|
||||
|
||||
// Wire the bridge (agent side) and the client (test side). The test config
|
||||
|
||||
@@ -29,8 +29,20 @@
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-stdio-agent
|
||||
|
||||
The **terminal stdio chat app**: a Cordis app plugin that composes the providerless agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`.
|
||||
The **terminal stdio chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`.
|
||||
|
||||
It is the readline counterpart to [`@deepseek-ai/dsh-acp-agent`](../acp-agent/README.md): both consume the same spine, but each bakes in the OPPOSITE front-door cluster.
|
||||
|
||||
@@ -11,8 +11,10 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
|
||||
| Plugin | Why it is here |
|
||||
|---|---|
|
||||
| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) |
|
||||
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model` and carrying its `persona` |
|
||||
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model` with `process.cwd()` as the fresh session cwd and carrying its `persona` |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
|
||||
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
|
||||
| `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent |
|
||||
|
||||
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.
|
||||
@@ -30,6 +32,8 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
|
||||
| `welcome` | `ready.` | the stdin-chat banner |
|
||||
| `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) |
|
||||
|
||||
Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-agent` was started. Resumed sessions keep the cwd stored in the persisted session header.
|
||||
|
||||
## The bin
|
||||
|
||||
`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages) through its internal module loader, which is only active under that flag. The `demo:echo` / `demo:repl` scripts invoke it that way.
|
||||
|
||||
@@ -38,7 +38,10 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-core": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6",
|
||||
"schemastery": "^3.17.0"
|
||||
},
|
||||
@@ -52,7 +55,10 @@
|
||||
"@deepseek-ai/dsh-agent-core": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6",
|
||||
"schemastery": "^3.17.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The stdio chat app: the providerless agent spine ({@link
|
||||
* The stdio chat app: the default agent spine ({@link
|
||||
* @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal
|
||||
* chat needs — a console logger, the readline UI (the in-package `stdio-chat`
|
||||
* module), JSONL session
|
||||
@@ -43,8 +43,11 @@ import ConsoleExporter from '@cordisjs/plugin-logger-console'
|
||||
import z from 'schemastery'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-core'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as uiStdio from './stdio-chat.ts'
|
||||
|
||||
export const name = 'stdio-agent'
|
||||
@@ -55,7 +58,9 @@ export const name = 'stdio-agent'
|
||||
* {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is
|
||||
* the deployment persona (forwarded to the system-prompt plugin); `toolOrder`
|
||||
* is the explicit model-facing tool order (forwarded to the system-prompt plugin);
|
||||
* `persistenceRoot` is the JSONL backend's directory; `welcome` is the UI banner.
|
||||
* fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions
|
||||
* keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory;
|
||||
* `welcome` is the UI banner.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Model name for the `main` agent (must have a registered adapter). */
|
||||
@@ -64,10 +69,14 @@ export interface Config {
|
||||
persona?: string
|
||||
/** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */
|
||||
toolOrder?: string[]
|
||||
/** Tool-registry config — its presentation `mode` (forwarded through agent-core; see dsh-tools). */
|
||||
tools?: ToolsConfig
|
||||
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** stdin-chat banner printed once on start. Defaults to `'ready.'`. */
|
||||
welcome?: string
|
||||
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */
|
||||
skills?: agentCore.SkillConfig
|
||||
/**
|
||||
* If set, the `main` agent RESUMES this persisted session id instead of
|
||||
* starting fresh. Sourced from an env var in the leaf `cordis.yml`
|
||||
@@ -83,8 +92,10 @@ export const Config: z<Config> = z.object({
|
||||
// order" (the owning dsh-system-prompt schema does the same), while
|
||||
// schemastery's native [] default would read as an invalid configured list.
|
||||
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
|
||||
tools: ToolRegistry.Config,
|
||||
persistenceRoot: z.string().default('./.sessions'),
|
||||
welcome: z.string().default('ready.'),
|
||||
skills: agentCore.SkillConfigSchema,
|
||||
resumeSessionId: z.string(),
|
||||
})
|
||||
|
||||
@@ -100,12 +111,17 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(agentCore, {
|
||||
...config.persona !== undefined ? { persona: config.persona } : {},
|
||||
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
|
||||
...config.tools !== undefined ? { tools: config.tools } : {},
|
||||
agents: [{
|
||||
id: AgentId('main'),
|
||||
model: config.model,
|
||||
cwd: process.cwd(),
|
||||
...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {},
|
||||
}],
|
||||
...config.skills !== undefined ? { skills: config.skills } : {},
|
||||
})
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||
ctx.plugin(UserInteractionService)
|
||||
ctx.plugin(toolAskUser)
|
||||
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })
|
||||
}
|
||||
|
||||
@@ -20,9 +20,17 @@ import type { Readable, Writable } from 'node:stream'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
UserInteractionError,
|
||||
type AskUserQuestionAnswer,
|
||||
type AskUserQuestionAnswerItem,
|
||||
type AskUserQuestionItem,
|
||||
type AskUserQuestionOption,
|
||||
type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
export const name = 'ui-stdio'
|
||||
export const inject = ['agents']
|
||||
export const inject = ['agents', 'userInteraction']
|
||||
|
||||
/** Serializable plugin configuration (cordis-native, schemastery). */
|
||||
export interface Config {
|
||||
@@ -57,6 +65,20 @@ function isTTYPair(input: Readable, output: Writable): boolean {
|
||||
return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY)
|
||||
}
|
||||
|
||||
interface PendingQuestion {
|
||||
request: AskUserQuestionRequest
|
||||
questionIndex: number
|
||||
answers: AskUserQuestionAnswerItem[]
|
||||
resolve(answer: AskUserQuestionAnswer): void
|
||||
reject(error: unknown): void
|
||||
onAbort: () => void
|
||||
}
|
||||
|
||||
type OptionSelection =
|
||||
| { kind: 'selected'; options: AskUserQuestionOption[] }
|
||||
| { kind: 'custom' }
|
||||
| { kind: 'invalid' }
|
||||
|
||||
/**
|
||||
* The plugin body, parameterized over its I/O runtime. `apply` is the thin
|
||||
* production wrapper that binds the real `process` streams; tests call this
|
||||
@@ -154,6 +176,8 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
||||
let submittedWork = false
|
||||
let sawRunning = false
|
||||
let exitTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let activeQuestion: PendingQuestion | undefined
|
||||
const questionQueue: PendingQuestion[] = []
|
||||
|
||||
const maybeExit = (): void => {
|
||||
if (disposed || !stdinClosed) return
|
||||
@@ -180,7 +204,152 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
||||
if (status === 'idle') maybeExit()
|
||||
})
|
||||
|
||||
const activeQuestionItem = (pending: PendingQuestion): AskUserQuestionItem =>
|
||||
pending.request.questions[pending.questionIndex] as AskUserQuestionItem
|
||||
|
||||
const renderQuestion = (pending: PendingQuestion): void => {
|
||||
const question = activeQuestionItem(pending)
|
||||
const options = question.options ?? []
|
||||
output.write('\n')
|
||||
output.write(question.header ? `[${question.header}] ${question.question}\n` : `${question.question}\n`)
|
||||
options.forEach((option, index) => {
|
||||
output.write(` ${index + 1}. ${option.label}\n`)
|
||||
if (option.description) output.write(` ${option.description}\n`)
|
||||
})
|
||||
output.write('> ')
|
||||
}
|
||||
|
||||
const removeAbortListener = (pending: PendingQuestion): void => {
|
||||
pending.request.signal?.removeEventListener('abort', pending.onAbort)
|
||||
}
|
||||
|
||||
const startNextQuestion = (): void => {
|
||||
if (activeQuestion !== undefined) return
|
||||
const pending = questionQueue.shift()
|
||||
if (pending === undefined) return
|
||||
// The queue never contains an aborted pending ask: the seam rejects an
|
||||
// already-aborted request synchronously, and queued asks attach their
|
||||
// abort listener before enqueueing.
|
||||
activeQuestion = pending
|
||||
renderQuestion(pending)
|
||||
}
|
||||
|
||||
const disposeQuestion = (pending: PendingQuestion): void => {
|
||||
removeAbortListener(pending)
|
||||
pending.reject(new UserInteractionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'))
|
||||
}
|
||||
|
||||
const disposePendingQuestions = (): void => {
|
||||
if (activeQuestion !== undefined) {
|
||||
disposeQuestion(activeQuestion)
|
||||
activeQuestion = undefined
|
||||
}
|
||||
for (const pending of questionQueue.splice(0)) {
|
||||
disposeQuestion(pending)
|
||||
}
|
||||
}
|
||||
|
||||
const finishQuestion = (pending: PendingQuestion): void => {
|
||||
activeQuestion = undefined
|
||||
removeAbortListener(pending)
|
||||
pending.resolve({ answers: pending.answers })
|
||||
output.write('\n')
|
||||
startNextQuestion()
|
||||
}
|
||||
|
||||
const answerCurrentQuestion = (pending: PendingQuestion, answer: AskUserQuestionAnswerItem): void => {
|
||||
pending.answers.push(answer)
|
||||
pending.questionIndex += 1
|
||||
if (pending.questionIndex >= pending.request.questions.length) {
|
||||
finishQuestion(pending)
|
||||
return
|
||||
}
|
||||
renderQuestion(pending)
|
||||
}
|
||||
|
||||
const selectedOptions = (text: string, options: AskUserQuestionOption[], multiSelect: boolean): OptionSelection => {
|
||||
if (text === '') return { kind: 'invalid' }
|
||||
if (!multiSelect) {
|
||||
if (!/^\d+$/.test(text)) return { kind: 'custom' }
|
||||
const selected = options[Number(text) - 1]
|
||||
return selected === undefined ? { kind: 'invalid' } : { kind: 'selected', options: [selected] }
|
||||
}
|
||||
const indices = text.split(/[,\s]+/).filter(Boolean)
|
||||
if (indices.length === 0) return { kind: 'invalid' }
|
||||
if (indices.some(part => !/^\d+$/.test(part))) return { kind: 'custom' }
|
||||
const uniqueIndices = [...new Set(indices)]
|
||||
const selected = uniqueIndices.map(part => options[Number(part) - 1])
|
||||
return selected.some(option => option === undefined)
|
||||
? { kind: 'invalid' }
|
||||
: { kind: 'selected', options: selected as AskUserQuestionOption[] }
|
||||
}
|
||||
|
||||
const answerQuestion = (line: string): void => {
|
||||
const pending = activeQuestion as PendingQuestion
|
||||
const question = activeQuestionItem(pending)
|
||||
|
||||
const text = line.trim()
|
||||
const options = question.options ?? []
|
||||
const selection = options.length > 0
|
||||
? selectedOptions(text, options, question.multiSelect ?? false)
|
||||
: { kind: text === '' ? 'invalid' : 'custom' } as OptionSelection
|
||||
if (selection.kind === 'selected') {
|
||||
answerCurrentQuestion(pending, { id: question.id, selected: selection.options.map(option => option.label) })
|
||||
return
|
||||
}
|
||||
|
||||
if (selection.kind === 'custom' && text !== '') {
|
||||
answerCurrentQuestion(pending, { id: question.id, selected: [], custom: text })
|
||||
return
|
||||
}
|
||||
|
||||
output.write(options.length > 0
|
||||
? 'Please enter one of the option numbers'
|
||||
+ (question.multiSelect ? ' (comma or space separated)' : '')
|
||||
+ ' or a custom answer'
|
||||
+ '.\n> '
|
||||
: 'Please enter an answer.\n> ')
|
||||
}
|
||||
|
||||
const disposeUserInteractionProvider = ctx.userInteraction.registerProvider({
|
||||
ask(request) {
|
||||
if (disposed || stdinClosed) {
|
||||
return Promise.reject(
|
||||
new UserInteractionError('ask_user_question cannot be answered because stdin is closed', 'ASK_ABORTED'),
|
||||
)
|
||||
}
|
||||
return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
|
||||
const pending: PendingQuestion = {
|
||||
request,
|
||||
questionIndex: 0,
|
||||
answers: [],
|
||||
resolve,
|
||||
reject,
|
||||
onAbort: () => {
|
||||
if (activeQuestion === pending) {
|
||||
activeQuestion = undefined
|
||||
disposeQuestion(pending)
|
||||
startNextQuestion()
|
||||
return
|
||||
}
|
||||
// If it is not active, this listener can only fire while the ask
|
||||
// remains queued; settled asks remove the listener first.
|
||||
questionQueue.splice(questionQueue.indexOf(pending), 1)
|
||||
disposeQuestion(pending)
|
||||
},
|
||||
}
|
||||
request.signal?.addEventListener('abort', pending.onAbort, { once: true })
|
||||
questionQueue.push(pending)
|
||||
startNextQuestion()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
reader.on('line', (line) => {
|
||||
if (activeQuestion !== undefined) {
|
||||
answerQuestion(line)
|
||||
return
|
||||
}
|
||||
const text = line.trim()
|
||||
if (!text) return
|
||||
const agent = ctx.agents.get(agentId)
|
||||
@@ -199,12 +368,15 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
||||
// Fires for BOTH stdin EOF and plugin disposal (reader.close() below);
|
||||
// `disposed` guards teardown so HMR/dispose never exits the process.
|
||||
stdinClosed = true
|
||||
if (!disposed) disposePendingQuestions()
|
||||
maybeExit()
|
||||
})
|
||||
output.write(`${welcome}\n> `)
|
||||
return () => {
|
||||
disposed = true
|
||||
if (exitTimer !== undefined) clearTimeout(exitTimer)
|
||||
disposePendingQuestions()
|
||||
disposeUserInteractionProvider()
|
||||
disposeStatusListener()
|
||||
reader.close()
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ async function makeConsumer(
|
||||
' name: \'@deepseek-ai/dsh-stdio-agent\'',
|
||||
' config:',
|
||||
' model: mock-echo',
|
||||
' systemPrompt: \'demo\'',
|
||||
' persona: \'demo\'',
|
||||
` welcome: '${welcome}'`,
|
||||
...extraEntries,
|
||||
...disabledBrokenEntry
|
||||
@@ -129,7 +129,7 @@ function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ st
|
||||
const child = spawn(process.execPath, ['--expose-internals', stdioBin, configArg], {
|
||||
cwd,
|
||||
// Mock model: never calls the network, so no key needed.
|
||||
env: { ...process.env },
|
||||
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
|
||||
@@ -19,6 +19,7 @@ function fakeContext(): Context {
|
||||
// The UI seeds its label map from the registry at install; this suite only
|
||||
// exercises readline terminal-mode selection, so an empty roster suffices.
|
||||
agents: { list: vi.fn(() => []) },
|
||||
userInteraction: { registerProvider: vi.fn(() => vi.fn()) },
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mkdtemp } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as stdioAgent from '../src/index.ts'
|
||||
|
||||
@@ -30,15 +34,57 @@ async function mount(config: stdioAgent.Config): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function isolatedSkillsConfig(catalogDescriptionMaxLength?: number): Promise<NonNullable<stdioAgent.Config['skills']>> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-skills-'))
|
||||
return {
|
||||
local: { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') },
|
||||
...catalogDescriptionMaxLength !== undefined ? { tool: { catalogDescriptionMaxLength } } : {},
|
||||
}
|
||||
}
|
||||
|
||||
async function composePrefix(ctx: Context): Promise<Message[]> {
|
||||
const empty: Message[] = []
|
||||
return await ctx.waterfall(
|
||||
'agent/session-prefix', { session: { header: { cwd: '/tmp' } } } as never,
|
||||
empty, new AbortController().signal, () => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
async function withIsolatedSkillHomes<T>(run: () => Promise<T>): Promise<T> {
|
||||
const oldDshHome = process.env.DSH_HOME
|
||||
const oldAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-stdio-agent-default-skills-'))
|
||||
process.env.DSH_HOME = join(home, '.dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(home, '.agents')
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
if (oldDshHome === undefined) {
|
||||
delete process.env.DSH_HOME
|
||||
} else {
|
||||
process.env.DSH_HOME = oldDshHome
|
||||
}
|
||||
if (oldAgentsHome === undefined) {
|
||||
delete process.env.DSH_AGENTS_HOME
|
||||
} else {
|
||||
process.env.DSH_AGENTS_HOME = oldAgentsHome
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('dsh-stdio-agent app', () => {
|
||||
it('composes the spine + front-door cluster and pre-creates the main agent', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec' })
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-agent-spec', skills: await isolatedSkillsConfig() })
|
||||
// The spine services (brought up by the agent-core bundle) are all present.
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect(ctx.get('userInteraction')).toBeDefined()
|
||||
expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
|
||||
// The pre-created `main` agent the UI drives.
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
|
||||
const agent = ctx.get('agents')?.get(AgentId('main'))
|
||||
expect(agent).toBeDefined()
|
||||
expect(agent?.session.header.cwd).toBe(process.cwd())
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -49,13 +95,24 @@ describe('dsh-stdio-agent app', () => {
|
||||
// schema-bypassing direct-mount caller.
|
||||
const ctx = new Context()
|
||||
// No persona: covers the omitted-persona forwarding branch too.
|
||||
stdioAgent.apply(ctx, { model: 'mock' })
|
||||
stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() })
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('uses default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
stdioAgent.apply(ctx, { model: 'mock' })
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards resumeSessionId onto the pre-created agent when set', async () => {
|
||||
// A resume id defers agent creation until persistence loads; with no backing
|
||||
// session the resume is contained + logged, so no `main` agent registers —
|
||||
@@ -65,11 +122,19 @@ describe('dsh-stdio-agent app', () => {
|
||||
persona: 'hi',
|
||||
persistenceRoot: '/tmp/dsh-stdio-agent-spec-resume',
|
||||
resumeSessionId: 'no-such-session',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
})
|
||||
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards skill config into agent-core', async () => {
|
||||
const ctx = await mount({ model: 'mock', persona: 'hi', skills: await isolatedSkillsConfig(6) })
|
||||
ctx.skills.register({ name: 'stdio-skill', description: 'Stdio skill', source: 'runtime', content: 'body' })
|
||||
expect(JSON.stringify(await composePrefix(ctx))).toContain('- `stdio-skill`: Std...')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('exposes its name and Config schema', () => {
|
||||
expect(stdioAgent.name).toBe('stdio-agent')
|
||||
expect(stdioAgent.Config).toBeDefined()
|
||||
@@ -92,7 +157,7 @@ describe('dsh-stdio-agent app', () => {
|
||||
})
|
||||
}
|
||||
const assembly = await ctx.get('systemPrompt')!.assemble()
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question', 'skill'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Readable } from 'node:stream'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts'
|
||||
|
||||
/**
|
||||
@@ -79,10 +80,11 @@ const CONFIG: Config = { welcome: 'hi there', agent: 'main' }
|
||||
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const { runtime, input, out, exit } = makeRuntime(runtimeOver)
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
createStdioChat(inner, config, runtime)
|
||||
}, { inject: ['agents'] }))
|
||||
}, { inject: ['agents', 'userInteraction'] }))
|
||||
return { ctx, fiber, input, out, exit }
|
||||
}
|
||||
|
||||
@@ -105,6 +107,30 @@ describe('createStdioChat rendering', () => {
|
||||
// And it drives the default agent id 'main'.
|
||||
})
|
||||
|
||||
it('detects readline terminal mode from both stream TTY flags', async () => {
|
||||
for (const [inputTTY, outputTTY] of [[true, false], [true, true]] as const) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
let text = ''
|
||||
const output = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
text += String(chunk)
|
||||
callback()
|
||||
},
|
||||
}) as Writable & { isTTY?: boolean }
|
||||
const { runtime } = makeRuntime({ output })
|
||||
;(runtime.input as Readable & { isTTY?: boolean }).isTTY = inputTTY
|
||||
output.isTTY = outputTTY
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
createStdioChat(inner, CONFIG, runtime)
|
||||
}, { inject: ['agents', 'userInteraction'] }))
|
||||
|
||||
expect(text).toContain('hi there')
|
||||
await fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders text-delta chunks verbatim', async () => {
|
||||
const { ctx, out } = await setup()
|
||||
ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' }))
|
||||
@@ -160,12 +186,13 @@ describe('createStdioChat rendering', () => {
|
||||
// of the raw session id.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const agent = makeAgent('main')
|
||||
ctx.agents.register(agent) // registered BEFORE the UI plugin below
|
||||
const { runtime, out } = makeRuntime()
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
createStdioChat(inner, CONFIG, runtime)
|
||||
}, { inject: ['agents'] }))
|
||||
}, { inject: ['agents', 'userInteraction'] }))
|
||||
ctx.emit('session/event', makeSession('main'), {
|
||||
type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } },
|
||||
} as SessionEvent)
|
||||
@@ -264,6 +291,347 @@ describe('createStdioChat rendering', () => {
|
||||
})
|
||||
|
||||
describe('createStdioChat input', () => {
|
||||
it('answers a pending user question instead of sending the line to the agent', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const agent = makeAgent('main', 'idle')
|
||||
ctx.agents.register(agent)
|
||||
|
||||
const answer = ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'confirm',
|
||||
header: 'Confirm',
|
||||
question: 'Proceed with the edit?',
|
||||
options: [{ label: 'Yes', description: 'Apply the edit now.' }],
|
||||
}],
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed('Use a smaller change')
|
||||
|
||||
await expect(answer).resolves.toEqual({ answers: [{ id: 'confirm', selected: [], custom: 'Use a smaller change' }] })
|
||||
expect(agent.sent).toEqual([])
|
||||
expect(out.text()).toContain('[Confirm] Proceed with the edit?')
|
||||
expect(out.text()).toContain('1. Yes')
|
||||
expect(out.text()).toContain('Apply the edit now.')
|
||||
})
|
||||
|
||||
it('answers a pending user question by numeric option selection', async () => {
|
||||
const { ctx, input } = await setup()
|
||||
const answer = ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'mode',
|
||||
question: 'Which mode?',
|
||||
options: [
|
||||
{ label: 'Safe' },
|
||||
{ label: 'Fast' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed('2')
|
||||
|
||||
await expect(answer).resolves.toEqual({
|
||||
answers: [{ id: 'mode', selected: ['Fast'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('renders options in input order and selects by displayed number', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const answer = ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'topic',
|
||||
question: 'Which topic?',
|
||||
options: [
|
||||
{ label: 'Hobbies' },
|
||||
{ label: 'Work', description: 'Questions about current projects.' },
|
||||
{ label: 'Casual', description: 'Easy conversation.' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
expect(out.text()).toContain([
|
||||
'Which topic?',
|
||||
' 1. Hobbies',
|
||||
' 2. Work',
|
||||
' Questions about current projects.',
|
||||
' 3. Casual',
|
||||
' Easy conversation.',
|
||||
].join('\n'))
|
||||
input.feed('3')
|
||||
|
||||
await expect(answer).resolves.toEqual({
|
||||
answers: [{ id: 'topic', selected: ['Casual'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('answers a multi-select question with multiple numeric selections', async () => {
|
||||
const { ctx, input } = await setup()
|
||||
const answer = ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'targets',
|
||||
question: 'What should I update?',
|
||||
options: [{ label: 'Tests' }, { label: 'Docs' }, { label: 'Code' }],
|
||||
multiSelect: true,
|
||||
}],
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed('1 1, 3')
|
||||
|
||||
await expect(answer).resolves.toEqual({
|
||||
answers: [{ id: 'targets', selected: ['Tests', 'Code'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts non-numeric multi-select input as a custom answer', async () => {
|
||||
const { ctx, input } = await setup()
|
||||
const answer = ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'targets',
|
||||
question: 'What should I update?',
|
||||
options: [{ label: 'Tests' }, { label: 'Docs' }],
|
||||
multiSelect: true,
|
||||
}],
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed('the release notes')
|
||||
|
||||
await expect(answer).resolves.toEqual({
|
||||
answers: [{ id: 'targets', selected: [], custom: 'the release notes' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('asks every question in a batch and returns answers by id', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const answer = ctx.userInteraction.ask({
|
||||
questions: [
|
||||
{ id: 'language', question: 'Which language?', options: [{ label: 'Python' }, { label: 'TypeScript' }] },
|
||||
{ id: 'note', question: 'Any note?' },
|
||||
],
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed('2')
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('\nAny note?\n')
|
||||
input.feed('ship today')
|
||||
|
||||
await expect(answer).resolves.toEqual({
|
||||
answers: [
|
||||
{ id: 'language', selected: ['TypeScript'] },
|
||||
{ id: 'note', selected: [], custom: 'ship today' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('re-prompts when option input is invalid', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const answer = ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'mode',
|
||||
question: 'Which mode?',
|
||||
options: [{ label: 'Safe' }],
|
||||
multiSelect: true,
|
||||
}],
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed('2')
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.')
|
||||
input.feed('1')
|
||||
|
||||
await expect(answer).resolves.toEqual({
|
||||
answers: [{ id: 'mode', selected: ['Safe'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('re-prompts when single-select option input is out of range', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const answer = ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'mode',
|
||||
question: 'Which mode?',
|
||||
options: [{ label: 'Safe' }],
|
||||
}],
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed('2')
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.')
|
||||
input.feed('1')
|
||||
|
||||
await expect(answer).resolves.toEqual({
|
||||
answers: [{ id: 'mode', selected: ['Safe'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('re-prompts when multi-select input contains no option numbers', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const answer = ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'mode',
|
||||
question: 'Which mode?',
|
||||
options: [{ label: 'Safe' }],
|
||||
multiSelect: true,
|
||||
}],
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed(',')
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.')
|
||||
input.feed('1')
|
||||
|
||||
await expect(answer).resolves.toEqual({
|
||||
answers: [{ id: 'mode', selected: ['Safe'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('re-prompts when an option question receives an empty answer', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const answer = ctx.userInteraction.ask({
|
||||
questions: [{
|
||||
id: 'mode',
|
||||
question: 'Which mode?',
|
||||
options: [{ label: 'Safe' }],
|
||||
}],
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed('')
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.')
|
||||
input.feed('1')
|
||||
|
||||
await expect(answer).resolves.toEqual({
|
||||
answers: [{ id: 'mode', selected: ['Safe'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('re-prompts when a question receives an empty answer', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const answer = ctx.userInteraction.ask({ questions: [{ id: 'path', question: 'What should I use?' }] })
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed('')
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('Please enter an answer.')
|
||||
input.feed('Use defaults')
|
||||
|
||||
await expect(answer).resolves.toEqual({ answers: [{ id: 'path', selected: [], custom: 'Use defaults' }] })
|
||||
})
|
||||
|
||||
it('rejects an active question when its signal aborts', async () => {
|
||||
const { ctx } = await setup()
|
||||
const controller = new AbortController()
|
||||
const answer = ctx.userInteraction.ask({ questions: [{ id: 'continue', question: 'Continue?' }], signal: controller.signal })
|
||||
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
controller.abort()
|
||||
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('continues to the next queued question when the active question aborts', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const controller = new AbortController()
|
||||
const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }], signal: controller.signal })
|
||||
const firstRejected = expect(first).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }] })
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
controller.abort()
|
||||
await firstRejected
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('\nSecond?\n')
|
||||
input.feed('second answer')
|
||||
|
||||
await expect(second).resolves.toEqual({ answers: [{ id: 'second', selected: [], custom: 'second answer' }] })
|
||||
})
|
||||
|
||||
it('skips a queued question whose signal aborted before it became active', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const controller = new AbortController()
|
||||
const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] })
|
||||
const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal })
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(Promise.race([
|
||||
second.then(
|
||||
() => 'resolved',
|
||||
(error: unknown) => (error as { code?: string }).code,
|
||||
),
|
||||
new Promise<string>((resolve) => { setImmediate(() => { resolve('pending') }) }),
|
||||
])).resolves.toBe('ASK_ABORTED')
|
||||
expect(out.text()).not.toContain('\nSecond?\n')
|
||||
input.feed('first answer')
|
||||
await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] })
|
||||
})
|
||||
|
||||
it('removes an aborted queued question without promoting later queued work early', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const controller = new AbortController()
|
||||
const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] })
|
||||
const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal })
|
||||
const third = ctx.userInteraction.ask({ questions: [{ id: 'third', question: 'Third?' }] })
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(second).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
expect(out.text()).toContain('\nFirst?\n')
|
||||
expect(out.text()).not.toContain('\nSecond?\n')
|
||||
expect(out.text()).not.toContain('\nThird?\n')
|
||||
input.feed('first answer')
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
expect(out.text()).toContain('\nThird?\n')
|
||||
input.feed('third answer')
|
||||
|
||||
await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] })
|
||||
await expect(third).resolves.toEqual({ answers: [{ id: 'third', selected: [], custom: 'third answer' }] })
|
||||
})
|
||||
|
||||
it('rejects active and queued questions when the UI is disposed', async () => {
|
||||
const { ctx, fiber } = await setup()
|
||||
const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
|
||||
const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
|
||||
const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
await activeRejected
|
||||
await queuedRejected
|
||||
})
|
||||
|
||||
it('rejects active and queued questions when stdin closes before the user answers', async () => {
|
||||
const { ctx, input, exit } = await setup()
|
||||
const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
|
||||
const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
|
||||
const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
input.finish()
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
await activeRejected
|
||||
await queuedRejected
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects new questions immediately after stdin has closed', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
input.finish()
|
||||
await new Promise(r => setImmediate(r))
|
||||
const before = out.text()
|
||||
|
||||
const answer = ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Too late?' }] })
|
||||
|
||||
await expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
expect(out.text()).toBe(before)
|
||||
})
|
||||
|
||||
it('sends a typed line to an idle agent', async () => {
|
||||
const { ctx, input } = await setup()
|
||||
const agent = makeAgent('main', 'idle')
|
||||
|
||||
@@ -32,6 +32,12 @@
|
||||
{
|
||||
"path": "../../core/agent-core"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../tool-ask-user"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
}
|
||||
|
||||
20
packages/ui/tool-ask-user/README.md
Normal file
20
packages/ui/tool-ask-user/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# @deepseek-ai/dsh-tool-ask-user
|
||||
|
||||
Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the model ask the human a concise question when it needs confirmation, a choice, or missing information before continuing.
|
||||
|
||||
## Tool
|
||||
|
||||
`ask_user_question` accepts:
|
||||
|
||||
- `questions` — required non-empty array of question objects.
|
||||
- `id` — required stable id on each question, echoed in the answer.
|
||||
- `question` — required question text for each question.
|
||||
- `header` — optional short heading.
|
||||
- `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label.
|
||||
- `multi_select` — whether that question may return more than one selected option.
|
||||
|
||||
The tool calls `ctx.userInteraction.ask()` and returns JSON text shaped as `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices.
|
||||
|
||||
## Role
|
||||
|
||||
This is the consumer package for the user-interaction seam. It does not render UI and does not know how input is collected; it only translates model arguments into `AskUserQuestionRequest` and returns the human answer to the agent loop.
|
||||
38
packages/ui/tool-ask-user/package.json
Normal file
38
packages/ui/tool-ask-user/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-ask-user",
|
||||
"description": "Model-facing ask_user_question tool over the ctx.userInteraction seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
71
packages/ui/tool-ask-user/src/index.ts
Normal file
71
packages/ui/tool-ask-user/src/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Model-facing `ask_user_question` tool over the `ctx.userInteraction` seam.
|
||||
* The tool pauses until a UI provider returns a human answer, then feeds that
|
||||
* answer back into the agent loop as an ordinary tool result.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-ask-user
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
export const name = 'tool-ask-user'
|
||||
export const inject = ['tools', 'userInteraction']
|
||||
|
||||
const description = 'Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. '
|
||||
+ 'Send one or more questions, each with a stable id that will be echoed in the answer.'
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'ask_user_question',
|
||||
description,
|
||||
parameters: {
|
||||
questions: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
description: 'Questions to ask the user before continuing.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', required: true, description: 'Stable id for this question; echoed in the answer.' },
|
||||
question: { type: 'string', required: true, description: 'The specific question to ask the user.' },
|
||||
header: {
|
||||
type: 'string',
|
||||
description: 'Optional short heading for the question, such as "Confirm" or "Choose Mode".',
|
||||
},
|
||||
options: {
|
||||
type: 'array',
|
||||
description: 'Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string', required: true, description: 'Short user-facing option label.' },
|
||||
description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
multi_select: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the user may select more than one option. Defaults to false.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const result = await ctx.userInteraction.ask({
|
||||
questions: args.questions.map(question => ({
|
||||
id: question.id,
|
||||
question: question.question,
|
||||
...question.header !== undefined ? { header: question.header } : {},
|
||||
...question.options !== undefined ? { options: question.options } : {},
|
||||
...question.multi_select !== undefined ? { multiSelect: question.multi_select } : {},
|
||||
})),
|
||||
...exec.agent !== undefined ? { agent: exec.agent } : {},
|
||||
...exec.signal !== undefined ? { signal: exec.signal } : {},
|
||||
})
|
||||
return [{ type: 'text', text: JSON.stringify(result) }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
253
packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts
Normal file
253
packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
|
||||
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
|
||||
interface OptionSchemaShape {
|
||||
properties: {
|
||||
questions: {
|
||||
items: {
|
||||
properties: {
|
||||
options: {
|
||||
items: {
|
||||
properties: Record<string, { type: string }>
|
||||
}
|
||||
}
|
||||
} & Record<string, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(toolAskUser)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('ask_user_question tool', () => {
|
||||
it('registers a model-facing tool schema', async () => {
|
||||
const ctx = await setup()
|
||||
const schema = ctx.tools.schemas().find(tool => tool.name === 'ask_user_question')
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
name: 'ask_user_question',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
questions: { type: 'array' },
|
||||
},
|
||||
required: ['questions'],
|
||||
},
|
||||
})
|
||||
const parameters = schema?.parameters as unknown as OptionSchemaShape
|
||||
expect(parameters.properties.questions.items.properties).toMatchObject({
|
||||
id: { type: 'string' },
|
||||
question: { type: 'string' },
|
||||
header: { type: 'string' },
|
||||
options: { type: 'array' },
|
||||
multi_select: { type: 'boolean' },
|
||||
})
|
||||
expect(parameters.properties.questions.items.properties.options.items.properties).toMatchObject({
|
||||
label: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
})
|
||||
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('value')
|
||||
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('recommended')
|
||||
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('preview')
|
||||
})
|
||||
|
||||
it('asks the registered user-interaction provider and projects structured answers to text', async () => {
|
||||
const ctx = await setup()
|
||||
const seen: AskUserQuestionRequest[] = []
|
||||
ctx.userInteraction.registerProvider({
|
||||
async ask(request) {
|
||||
seen.push(request)
|
||||
return { answers: [{ id: 'pkg', selected: ['pnpm'] }] }
|
||||
},
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('ask-1'),
|
||||
name: 'ask_user_question',
|
||||
arguments: {
|
||||
questions: [{
|
||||
id: 'pkg',
|
||||
question: 'Which package manager should I use?',
|
||||
options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }],
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isError: false,
|
||||
content: [{ type: 'text', text: '{"answers":[{"id":"pkg","selected":["pnpm"]}]}' }],
|
||||
})
|
||||
expect(seen).toMatchObject([{
|
||||
questions: [{
|
||||
id: 'pkg',
|
||||
question: 'Which package manager should I use?',
|
||||
options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }],
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
it('passes recommended option labels through without adding schema fields', async () => {
|
||||
const ctx = await setup()
|
||||
const seen: AskUserQuestionRequest[] = []
|
||||
ctx.userInteraction.registerProvider({
|
||||
async ask(request) {
|
||||
seen.push(request)
|
||||
return { answers: [{ id: 'pkg', selected: ['pnpm (Recommended)'] }] }
|
||||
},
|
||||
})
|
||||
|
||||
await ctx.tools.execute({
|
||||
callId: CallId('ask-recommended'),
|
||||
name: 'ask_user_question',
|
||||
arguments: {
|
||||
questions: [{
|
||||
id: 'pkg',
|
||||
question: 'Which package manager should I use?',
|
||||
options: [
|
||||
{ label: 'pnpm (Recommended)' },
|
||||
{ label: 'npm' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
expect(seen[0]?.questions[0]?.options).toEqual([
|
||||
{ label: 'pnpm (Recommended)' },
|
||||
{ label: 'npm' },
|
||||
])
|
||||
})
|
||||
|
||||
it('projects custom answers and multi-select choices', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.userInteraction.registerProvider({
|
||||
async ask() {
|
||||
return {
|
||||
answers: [
|
||||
{ id: 'targets', selected: ['tests', 'docs'] },
|
||||
{ id: 'notes', selected: [], custom: 'ship today' },
|
||||
],
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('ask-multi'),
|
||||
name: 'ask_user_question',
|
||||
arguments: {
|
||||
questions: [
|
||||
{
|
||||
id: 'targets',
|
||||
question: 'What should I update?',
|
||||
options: [{ label: 'tests' }, { label: 'docs' }],
|
||||
multi_select: true,
|
||||
},
|
||||
{ id: 'notes', question: 'Any note?' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.content).toEqual([{
|
||||
type: 'text',
|
||||
text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
|
||||
}])
|
||||
})
|
||||
|
||||
it('passes the tool abort signal to the user-interaction request', async () => {
|
||||
const ctx = await setup()
|
||||
const seen: AskUserQuestionRequest[] = []
|
||||
ctx.userInteraction.registerProvider({
|
||||
async ask(request) {
|
||||
seen.push(request)
|
||||
return { answers: [{ id: 'continue', selected: ['ok'] }] }
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
|
||||
await ctx.tools.execute({
|
||||
callId: CallId('ask-2'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
expect(seen[0]?.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('passes optional header and agent through to the user-interaction request', async () => {
|
||||
const ctx = await setup()
|
||||
const seen: AskUserQuestionRequest[] = []
|
||||
ctx.userInteraction.registerProvider({
|
||||
async ask(request) {
|
||||
seen.push(request)
|
||||
return { answers: [{ id: 'continue', selected: ['ok'] }] }
|
||||
},
|
||||
})
|
||||
const agent = { id: 'main' } as unknown as Agent
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('ask-3'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }] },
|
||||
agent,
|
||||
})
|
||||
|
||||
expect(result.content).toEqual([{ type: 'text', text: '{"answers":[{"id":"continue","selected":["ok"]}]}' }])
|
||||
expect(seen[0]).toMatchObject({ questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }], agent })
|
||||
})
|
||||
|
||||
it('returns structured user-interaction errors through tool execution', async () => {
|
||||
const ctx = await setup()
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('ask-no-provider'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
error: { name: 'UserInteractionError', code: 'NO_PROVIDER' },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a structured error for empty question batches', async () => {
|
||||
const ctx = await setup()
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('ask-empty'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { questions: [] },
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
error: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' },
|
||||
})
|
||||
})
|
||||
|
||||
it('unregisters the tool when its plugin fiber is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const fiber = await ctx.plugin(toolAskUser)
|
||||
expect(ctx.tools.get('ask_user_question')).toBeDefined()
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
expect(ctx.tools.get('ask_user_question')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
36
packages/ui/tool-ask-user/tsconfig.json
Normal file
36
packages/ui/tool-ask-user/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
packages/ui/user-approval/README.md
Normal file
13
packages/ui/user-approval/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# @deepseek-ai/dsh-user-approval
|
||||
|
||||
User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI.
|
||||
|
||||
The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and always resolves to an outcome, never rejects: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. The one precondition: ask from inside an open turn — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask throws before appending anything.
|
||||
|
||||
The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates.
|
||||
|
||||
The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`).
|
||||
|
||||
One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's `sandbox_permissions` gate in [`@deepseek-ai/dsh-tool-bash`](../../bash/tool-bash/) — [the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). The full design: [the approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md).
|
||||
|
||||
Answerers today: the ACP bridge ([`@deepseek-ai/dsh-acp`](../../ui/acp/)) forwards to the editor's `session/request_permission` prompt for agents it owns. The audit events are log-only session records — the model only ever sees the tool result the asker derives from the outcome.
|
||||
43
packages/ui/user-approval/package.json
Normal file
43
packages/ui/user-approval/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-user-approval",
|
||||
"description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
437
packages/ui/user-approval/src/index.ts
Normal file
437
packages/ui/user-approval/src/index.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Approval seam: `ctx.approval` answers exactly one question — "may this
|
||||
* specific action proceed?" — by dispatching the `approval/request` waterfall
|
||||
* to whatever answerers the deployment composed (an ACP editor prompt, an
|
||||
* auto-decide policy, a scripted test listener) and returning a closed
|
||||
* {@link ApprovalOutcome}. With no answerer the waterfall falls through to the
|
||||
* built-in default `'unavailable'`: absence of a UI can never grant anything.
|
||||
*
|
||||
* The service is the MECHANISM (dispatch, cancellation, audit); answerers are
|
||||
* the POLICY. It serves both ask paths the sandbox RFC names — the
|
||||
* `tools/pre-execute` `ask` decision and the sandbox post-denial escalation —
|
||||
* so every asker shares one outcome
|
||||
* vocabulary and one audit trail. Grants are one-shot by design: an
|
||||
* `'allowed-once'` outcome authorizes the single action it was asked about,
|
||||
* never a class of future actions.
|
||||
*
|
||||
* Every request lands two log-only session events on the requesting agent's
|
||||
* log (`approval/asked` / `approval/decided`, paired by
|
||||
* {@link ApprovalRequestId}) — an audit trail, deliberately NOT part of the
|
||||
* model-visible transcript: the model only ever sees the tool result the
|
||||
* caller derives from the outcome.
|
||||
*
|
||||
* The seam also owns the per-session POLICY tier (the sandbox RFC § Per-session mode switching):
|
||||
* `effective = fold(the session's 'approval/policy' events, last one wins)
|
||||
* ?? config.policy` — the session log is the store, so an override survives
|
||||
* restart by replay. The service resolves `'never'` sessions to
|
||||
* `'rejected'` inside `request()` before dispatching any answerer (no
|
||||
* registration order, including a later `prepend`, can precede it); a prompt section states `'never'`
|
||||
* (and only `'never'` — an availability promise is unknowable without
|
||||
* asking); an `agent/pre-step` narrator explains a switch to the model in at
|
||||
* most one coalesced notice per step.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-user-approval
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
approval: ApprovalService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Waterfall asking the composed answerers to decide one approval request.
|
||||
* Dispatched only from {@link ApprovalService.request} — callers go through
|
||||
* the service (which owns cancellation and the audit events), never through
|
||||
* `ctx.waterfall` directly. A listener that can answer for this request's
|
||||
* agent returns an outcome WITHOUT calling `next()` (the decision slot is
|
||||
* single-occupancy, first listener to answer wins); a listener that does
|
||||
* not recognize the agent MUST call `next()` so another answerer — or the
|
||||
* fail-closed default `'unavailable'` — gets the question. Throwing is
|
||||
* contained by the service and yields `'unavailable'`.
|
||||
* @param req - the pending decision (agent, tool identity, reason, signal).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'approval/request'(this: ApprovalService, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* An approval question was put to the answerer chain — log-only audit
|
||||
* (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs
|
||||
* it with the `approval/decided` that always follows; `toolName` is the
|
||||
* tool the question is about, `callId` the exact tool call when the asker
|
||||
* had one, `reason` the asker's human-readable explanation (e.g. a hook's
|
||||
* permission-decision reason).
|
||||
*/
|
||||
'approval/asked': {
|
||||
id: ApprovalRequestId
|
||||
toolName: string
|
||||
callId?: CallId
|
||||
reason?: string
|
||||
}
|
||||
/**
|
||||
* The outcome of a prior `approval/asked` (same `id`) — log-only audit.
|
||||
* Exactly one per ask, appended when the outcome is known: a decision, a
|
||||
* cancellation, or the fail-closed `'unavailable'`.
|
||||
*/
|
||||
'approval/decided': {
|
||||
id: ApprovalRequestId
|
||||
outcome: ApprovalOutcome
|
||||
}
|
||||
/**
|
||||
* The session's approval policy was switched — log-only, durable,
|
||||
* replayable, never in the model transcript (the model learns the policy
|
||||
* 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).
|
||||
*/
|
||||
'approval/policy': { policy: ApprovalPolicy }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairs one `approval/asked` audit event with its `approval/decided`.
|
||||
* Service-issued (one fresh id per {@link ApprovalService.request} call).
|
||||
*/
|
||||
export type ApprovalRequestId = Branded<'ApprovalRequestId'>
|
||||
|
||||
/**
|
||||
* Brand a string as an {@link ApprovalRequestId}.
|
||||
* @param id - the raw id string to brand.
|
||||
* @returns the same string carrying the brand.
|
||||
*/
|
||||
export function ApprovalRequestId(id: string): ApprovalRequestId {
|
||||
return id as ApprovalRequestId
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed outcome vocabulary of one approval request.
|
||||
*
|
||||
* - `'allowed-once'` — a one-shot grant for exactly the asked-about action;
|
||||
* consumed by proceeding, never a durable authorization.
|
||||
* - `'rejected'` — an answerer (human or policy) said no.
|
||||
* - `'cancelled'` — the question was withdrawn: the prompt was dismissed, or
|
||||
* the requesting execution aborted while the question was pending.
|
||||
* - `'unavailable'` — nobody composed could answer (no listener, none that
|
||||
* recognizes the agent, or an answerer failed). Callers MUST fail closed on
|
||||
* it, exactly like `'rejected'` — the two differ only for audit and wording.
|
||||
*/
|
||||
export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
||||
|
||||
/** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */
|
||||
const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cancelled', 'unavailable']
|
||||
|
||||
/**
|
||||
* A session's approval policy — what happens to an {@link ApprovalService}
|
||||
* ask BEFORE any interactive answerer sees it:
|
||||
*
|
||||
* - `'ask'` (the default) — delegate to the composed answerers; with none
|
||||
* composed the chain falls through to the fail-closed `'unavailable'`
|
||||
* (exactly today's behavior).
|
||||
* - `'never'` — never prompt anyone: every ask resolves `'rejected'`
|
||||
* deterministically. The strict headless stance (CI, unattended runs) and
|
||||
* the only policy value stated in the system prompt — unlike `'ask'`, its
|
||||
* outcome is knowable without asking, so stating it cannot overclaim.
|
||||
*/
|
||||
export type ApprovalPolicy = 'ask' | 'never'
|
||||
|
||||
/** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */
|
||||
export const APPROVAL_POLICIES: readonly ApprovalPolicy[] = ['ask', 'never']
|
||||
|
||||
/**
|
||||
* The prompt sentence stating a `'never'` policy — visibility for the one
|
||||
* deterministic policy (see {@link ApprovalPolicy}). Narrator persistence
|
||||
* does NOT parse this prose: deployments can quote it in a persona or another
|
||||
* section, so the section also emits a source-owned marker.
|
||||
*/
|
||||
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
|
||||
|
||||
/** Source-owned prompt markers used to reconstruct the policy in a logged header. */
|
||||
const POLICY_MARKERS = {
|
||||
ask: '<!-- dsh-user-approval-policy:ask -->',
|
||||
never: '<!-- dsh-user-approval-policy:never -->',
|
||||
} as const satisfies Record<ApprovalPolicy, string>
|
||||
|
||||
/**
|
||||
* Read the policy fact emitted by this service from a logged system prompt.
|
||||
* The section is ordered after deployment persona text, and the last marker
|
||||
* wins so a persona quoting an earlier marker cannot shadow the service's own
|
||||
* contribution. Ordinary policy prose is deliberately ignored.
|
||||
*/
|
||||
function toldApprovalPolicy(system: string | undefined): ApprovalPolicy | undefined {
|
||||
if (system === undefined) return undefined
|
||||
const ask = system.lastIndexOf(POLICY_MARKERS.ask)
|
||||
const never = system.lastIndexOf(POLICY_MARKERS.never)
|
||||
if (ask < 0 && never < 0) return undefined
|
||||
return never > ask ? 'never' : 'ask'
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's approval-policy override: the last `approval/policy` event in
|
||||
* the log, or undefined when the session never switched (callers apply the
|
||||
* plugin's configured default). The pure fold — resume needs no catch-up
|
||||
* machinery because replaying the log IS the state.
|
||||
* @param events - session events in log order (other event types are skipped).
|
||||
* @returns the policy of the last switch event, or undefined without one.
|
||||
*/
|
||||
export function effectiveApprovalPolicy(events: readonly SessionEvent[]): ApprovalPolicy | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type === 'approval/policy') return event.data.policy
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the log currently sits inside an open turn (a `turn/start` not yet
|
||||
* closed by a `turn/end`) — the {@link ApprovalService.request} precondition.
|
||||
* The audit pair must be turn-enclosed: the turn is the durable log's
|
||||
* commit/replay boundary, so a bare event appended between turns is
|
||||
* indistinguishable from a crash tail and silently dropped on reload.
|
||||
*/
|
||||
function hasOpenTurn(events: readonly SessionEvent[]): boolean {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const type = (events[index] as SessionEvent).type
|
||||
if (type === 'turn/start') return true
|
||||
if (type === 'turn/end') return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* THE write path for a session's approval-policy override: appends exactly
|
||||
* one `approval/policy` event — the switch IS its event; nothing mutates
|
||||
* policy state out of band. Takes effect on the session's next ask and next
|
||||
* prompt assembly (the consumers fold on every read).
|
||||
* @param session - the session the override belongs to.
|
||||
* @param policy - the policy every subsequent ask for this session resolves
|
||||
* under (until the next switch).
|
||||
*/
|
||||
export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void {
|
||||
session.append('approval/policy', { policy })
|
||||
}
|
||||
|
||||
/**
|
||||
* One concrete permission question. Identifies the action precisely enough
|
||||
* for an answerer to present it and for the audit events to reconstruct what
|
||||
* was asked — it deliberately does NOT carry tool arguments: a UI answerer
|
||||
* attaches the prompt to the already-streamed tool call via `callId` instead
|
||||
* of re-rendering the call.
|
||||
*/
|
||||
export interface ApprovalRequest {
|
||||
/**
|
||||
* The agent on whose behalf the question is asked. Routes the question (a
|
||||
* UI answerer only answers for agents it owns) and receives the audit
|
||||
* events on its session log.
|
||||
*/
|
||||
agent: Agent
|
||||
/** The tool the question is about (presentation and audit). */
|
||||
toolName: string
|
||||
/**
|
||||
* The exact tool call being decided, when the asker has one — lets a UI
|
||||
* attach the prompt to the tool call it already streamed.
|
||||
*/
|
||||
callId?: CallId
|
||||
/** The asker's human-readable explanation of WHY it is asking. */
|
||||
reason?: string
|
||||
/**
|
||||
* Aborting withdraws the question: the request settles `'cancelled'`
|
||||
* immediately and a late answer from a still-pending answerer is discarded.
|
||||
*/
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
export interface Config {
|
||||
/**
|
||||
* The deployment's default {@link ApprovalPolicy} for sessions without an
|
||||
* `approval/policy` override — `'ask'` delegates to the composed answerers
|
||||
* (fail-closed with none); `'never'` auto-rejects every ask without
|
||||
* prompting (the deterministic CI/unattended stance).
|
||||
*/
|
||||
policy?: ApprovalPolicy
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ctx.approval` service: dispatches {@link ApprovalRequest}s to the
|
||||
* `approval/request` waterfall and audits every ask/outcome pair to the
|
||||
* requesting agent's session log. Stateless between requests — grants are
|
||||
* returned to the caller, never stored here.
|
||||
*
|
||||
* Owns the policy tier too (`effective = fold(the session's 'approval/policy'
|
||||
* events) ?? config.policy`): `request()` resolves `'never'` to `'rejected'`
|
||||
* before dispatching any interactive answerer, a per-agent prompt section
|
||||
* states a `'never'` policy (and only that one in prose — an `'ask'` promise
|
||||
* could overclaim an answerer that headless compositions do not have), and an
|
||||
* `agent/pre-step` narrator injects at most one coalesced notice when a
|
||||
* session's effective policy moved past what the model was last told.
|
||||
*/
|
||||
export class ApprovalService extends Service {
|
||||
static Config: z<Config> = z.object({
|
||||
policy: z.union(['ask', 'never'] as const).default('ask'),
|
||||
})
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'approval')
|
||||
|
||||
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent)
|
||||
|
||||
// Visibility layer 1, scoped on the prompt registry so headless
|
||||
// compositions mount the seam without it: state the one deterministic
|
||||
// policy per session. 'ask' renders only a source-owned state marker —
|
||||
// stating "you will be asked" would overclaim in a composition with no
|
||||
// answerer. The marker, not deployment-controlled prose, is what the
|
||||
// restart narrator reads back from the logged request header.
|
||||
ctx.inject(['systemPrompt'], (scope: Context) => {
|
||||
scope.systemPrompt.section({
|
||||
name: 'approval:policy',
|
||||
order: 115,
|
||||
text: (context) => {
|
||||
const agent = context.agent
|
||||
// A bare assemble() (tests, diagnostics) has no session to state.
|
||||
if (agent === undefined) return ''
|
||||
const policy = effective(agent)
|
||||
return policy === 'never' ? `${NEVER_SENTENCE}\n${POLICY_MARKERS.never}` : POLICY_MARKERS.ask
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Visibility layer 2: the boundary narrator. pre-step runs after prompt
|
||||
// assembly but before the request history is derived, so the notice is
|
||||
// seen by THIS step's request: idle-time flip-flops coalesce at the
|
||||
// turn's first step (net-zero → nothing), and a mid-turn switch is
|
||||
// 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
|
||||
// switch by the user; otherwise the configured default moved under the
|
||||
// session (operator/config).
|
||||
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
|
||||
ctx.on('agent/pre-step', (agent) => {
|
||||
const session = agent.session
|
||||
const events = session.events
|
||||
let overrideIndex = -1
|
||||
let headerIndex = -1
|
||||
for (let index = events.length - 1; index >= 0 && (overrideIndex < 0 || headerIndex < 0); index -= 1) {
|
||||
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')) {
|
||||
headerIndex = index
|
||||
}
|
||||
}
|
||||
// Same fold effectivePolicy performs — override is scanned here anyway
|
||||
// for POSITIONAL attribution; the default lives once, in the method.
|
||||
const current = this.effectivePolicy(agent)
|
||||
const header = session.requestHeader()
|
||||
const told = narrated.get(session) ?? toldApprovalPolicy(header?.system)
|
||||
narrated.set(session, current)
|
||||
// Cold start (nothing ever told) narrates nothing — the section about
|
||||
// to go out states the truth, and there is no delta to explain.
|
||||
if (told === undefined || told === current) return
|
||||
const cause = overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
|
||||
agent.inject(
|
||||
[{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }],
|
||||
{ source: { kind: 'plugin', plugin: 'user-approval' } },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the composed answerers to decide one request. Requires an open turn
|
||||
* on the requesting agent's session — the audit pair below is turn-enclosed
|
||||
* by contract (the turn is the log's commit/replay boundary; an idle append
|
||||
* would be dropped as crash tail on reload) — and throws before appending
|
||||
* anything when called idle; asking outside a turn is a deferred design.
|
||||
* Within that precondition it always resolves to an outcome, never rejects:
|
||||
* an aborted signal yields `'cancelled'`, a missing or throwing answerer
|
||||
* yields `'unavailable'` (fail closed), and a rogue non-vocabulary return
|
||||
* value is normalized to `'unavailable'`. Appends the
|
||||
* `approval/asked`/`approval/decided` audit pair (log-only) around the
|
||||
* decision regardless of outcome.
|
||||
* @param req - the pending decision (agent, tool identity, reason, signal).
|
||||
* @returns the closed outcome; `'allowed-once'` is the only grant.
|
||||
*/
|
||||
async request(req: ApprovalRequest): Promise<ApprovalOutcome> {
|
||||
if (!hasOpenTurn(req.agent.session.events)) {
|
||||
throw new Error(
|
||||
'approval.request() outside an open turn: the approval/asked + approval/decided audit pair '
|
||||
+ 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). '
|
||||
+ 'Ask from inside the turn that needs the decision.',
|
||||
)
|
||||
}
|
||||
const id = ApprovalRequestId(randomUUID())
|
||||
req.agent.session.append('approval/asked', {
|
||||
id,
|
||||
toolName: req.toolName,
|
||||
...req.callId !== undefined ? { callId: req.callId } : {},
|
||||
...req.reason !== undefined ? { reason: req.reason } : {},
|
||||
})
|
||||
const outcome = await this.decide(req)
|
||||
req.agent.session.append('approval/decided', { id, outcome })
|
||||
return outcome
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's effective policy: its own `approval/policy` fold, else the
|
||||
* configured default (the schema already defaulted an omitted policy to
|
||||
* `'ask'`; the `??` only narrows the optional-input TYPE).
|
||||
* @param agent - the agent whose session's policy applies.
|
||||
* @returns the policy every ask for this agent resolves under right now.
|
||||
*/
|
||||
private effectivePolicy(agent: Agent): ApprovalPolicy {
|
||||
return effectiveApprovalPolicy(agent.session.events) ?? this.config.policy ?? 'ask'
|
||||
}
|
||||
|
||||
/** Dispatch the waterfall, contained and raced against `req.signal`. */
|
||||
private async decide(req: ApprovalRequest): Promise<ApprovalOutcome> {
|
||||
if (req.signal?.aborted) return 'cancelled'
|
||||
// The 'never' policy is decided HERE, before any dispatch: a listener
|
||||
// registered with `prepend: true` after this service mounts would sit
|
||||
// ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
|
||||
// documented promise that 'never' rejects deterministically regardless
|
||||
// of registration order — only the service's own request path can.
|
||||
if (this.effectivePolicy(req.agent) === 'never') return 'rejected'
|
||||
// Enter the promise chain BEFORE dispatching: a listener that throws
|
||||
// SYNCHRONOUSLY (before its first await) must land in the same rejection
|
||||
// path as an async one — `Promise.resolve(call())` would let it escape
|
||||
// the containment into the caller.
|
||||
const answer: Promise<ApprovalOutcome> = Promise.resolve().then(
|
||||
() => this.ctx.waterfall(this, 'approval/request', req, () => Promise.resolve<ApprovalOutcome>('unavailable')),
|
||||
).then(
|
||||
// Normalize a rogue (non-vocabulary) answerer return to the fail-closed
|
||||
// outcome instead of leaking it into callers' closed-union switches.
|
||||
outcome => OUTCOMES.includes(outcome) ? outcome : 'unavailable',
|
||||
// A throwing answerer must fail the QUESTION closed, not the caller's
|
||||
// tool call open — the seam contains its callbacks.
|
||||
() => 'unavailable',
|
||||
)
|
||||
const signal = req.signal
|
||||
if (signal === undefined) return answer
|
||||
return await new Promise<ApprovalOutcome>((resolve) => {
|
||||
const onAbort = () => { resolve('cancelled') }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void answer.then((outcome) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
// After an abort won the race this resolve is a settled-promise no-op:
|
||||
// the late answer is discarded by construction.
|
||||
resolve(outcome)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default ApprovalService
|
||||
424
packages/ui/user-approval/tests/approval.spec.ts
Normal file
424
packages/ui/user-approval/tests/approval.spec.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
|
||||
|
||||
/**
|
||||
* A minimal Agent stand-in — the service only reaches `agent.session.append`
|
||||
* and folds `.events`. Seeded inside an open turn by default (request()'s
|
||||
* turn-enclosure precondition); pass `seed` to stage idle/closed logs.
|
||||
* Returns the recorded audit appends alongside the fake.
|
||||
*/
|
||||
function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { type: 'user/message' }]): { agent: Agent; appended: Array<{ type: string; data: Record<string, unknown> }> } {
|
||||
const appended: Array<{ type: string; data: Record<string, unknown> }> = []
|
||||
const agent = {
|
||||
session: {
|
||||
events: seed,
|
||||
append: (type: string, data: Record<string, unknown>) => {
|
||||
appended.push({ type, data })
|
||||
return { type, data } as unknown as SessionEvent
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
return { agent, appended }
|
||||
}
|
||||
|
||||
async function mounted(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function requestOf(agent: Agent, overrides: Partial<ApprovalRequest> = {}): ApprovalRequest {
|
||||
return { agent, toolName: 'echo', ...overrides }
|
||||
}
|
||||
|
||||
describe('ApprovalService.request', () => {
|
||||
it('throws before appending anything when no turn has ever opened (idle ask)', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent([])
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/)
|
||||
expect(appended).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('throws between turns — a closed turn does not satisfy the enclosure precondition', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent([{ type: 'turn/start' }, { type: 'turn/end' }])
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/)
|
||||
expect(appended).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fails closed to unavailable when nobody listens, auditing the asked/decided pair', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
|
||||
const outcome = await ctx.approval.request(requestOf(agent, { callId: CallId('call-1'), reason: 'hook says ask' }))
|
||||
|
||||
expect(outcome).toBe('unavailable')
|
||||
expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
const [asked, decided] = appended
|
||||
expect(asked?.data).toMatchObject({ toolName: 'echo', callId: 'call-1', reason: 'hook says ask' })
|
||||
expect(decided?.data).toMatchObject({ outcome: 'unavailable' })
|
||||
expect(decided?.data['id']).toBe(asked?.data['id'])
|
||||
})
|
||||
|
||||
it('omits absent optional fields from the asked audit event', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
|
||||
await ctx.approval.request(requestOf(agent))
|
||||
|
||||
expect(Object.keys(appended[0]?.data ?? {}).sort()).toEqual(['id', 'toolName'])
|
||||
})
|
||||
|
||||
it('returns the first answering listener outcome (single decision slot)', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
let secondRan = false
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
ctx.on('approval/request', () => {
|
||||
secondRan = true
|
||||
return Promise.resolve<ApprovalOutcome>('rejected')
|
||||
})
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once')
|
||||
expect(secondRan).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a non-owning listener delegate via next() down to the fail-closed default', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
ctx.on('approval/request', (_req, next) => next())
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
|
||||
})
|
||||
|
||||
it('contains a throwing answerer as unavailable', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
ctx.on('approval/request', () => Promise.reject(new Error('transport died')))
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
|
||||
expect(appended[1]?.data).toMatchObject({ outcome: 'unavailable' })
|
||||
})
|
||||
|
||||
it('normalizes a rogue non-vocabulary answer to unavailable', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
// A JS answerer can return anything; the seam must not leak it into
|
||||
// callers' closed-union switches.
|
||||
ctx.on('approval/request', () => Promise.resolve('yolo' as ApprovalOutcome))
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
|
||||
})
|
||||
|
||||
it('settles cancelled immediately on an already-aborted signal without asking anyone', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
let asked = false
|
||||
ctx.on('approval/request', () => {
|
||||
asked = true
|
||||
return Promise.resolve<ApprovalOutcome>('allowed-once')
|
||||
})
|
||||
|
||||
const outcome = await ctx.approval.request(requestOf(agent, { signal: AbortSignal.abort() }))
|
||||
|
||||
expect(outcome).toBe('cancelled')
|
||||
expect(asked).toBe(false)
|
||||
expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
expect(appended[1]?.data).toMatchObject({ outcome: 'cancelled' })
|
||||
})
|
||||
|
||||
it('resolves cancelled when the signal aborts mid-question and discards the late answer', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
let settleLate: ((outcome: ApprovalOutcome) => void) | undefined
|
||||
ctx.on('approval/request', () => new Promise<ApprovalOutcome>((resolve) => { settleLate = resolve }))
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = ctx.approval.request(requestOf(agent, { signal: controller.signal }))
|
||||
controller.abort()
|
||||
await expect(pending).resolves.toBe('cancelled')
|
||||
|
||||
// The answerer settles after the fact: no second decided event appears.
|
||||
settleLate?.('allowed-once')
|
||||
await Promise.resolve()
|
||||
expect(appended.filter(e => e.type === 'approval/decided')).toHaveLength(1)
|
||||
expect(appended[1]?.data).toMatchObject({ outcome: 'cancelled' })
|
||||
})
|
||||
|
||||
it('discards a late REJECTION after abort without an unhandled rejection', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
let rejectLate: ((error: Error) => void) | undefined
|
||||
ctx.on('approval/request', () => new Promise<ApprovalOutcome>((_resolve, reject) => { rejectLate = reject }))
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = ctx.approval.request(requestOf(agent, { signal: controller.signal }))
|
||||
controller.abort()
|
||||
await expect(pending).resolves.toBe('cancelled')
|
||||
|
||||
rejectLate?.(new Error('answered too late'))
|
||||
// Drain microtasks: the contained rejection must not escape the seam.
|
||||
await new Promise((resolve) => { setTimeout(resolve, 0) })
|
||||
})
|
||||
|
||||
it('resolves the answer when the signal never aborts', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent, { signal: controller.signal }))).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('issues a fresh id per request', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
|
||||
await ctx.approval.request(requestOf(agent))
|
||||
await ctx.approval.request(requestOf(agent))
|
||||
|
||||
const ids = appended.filter(e => e.type === 'approval/asked').map(e => e.data['id'])
|
||||
expect(ids).toHaveLength(2)
|
||||
expect(ids[0]).not.toBe(ids[1])
|
||||
})
|
||||
|
||||
it('drops a disposed plugin listener from the chain (HMR safety)', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
const fiber = await ctx.plugin((inner: Context) => {
|
||||
inner.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
})
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once')
|
||||
|
||||
await fiber.dispose()
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('approval policy (the approval/policy fold)', () => {
|
||||
const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).'
|
||||
const ASK_MARKER = '<!-- dsh-user-approval-policy:ask -->'
|
||||
const NEVER_MARKER = '<!-- dsh-user-approval-policy:never -->'
|
||||
|
||||
/**
|
||||
* An agent stand-in over a REAL Session — gate, section, and narrator fold
|
||||
* real events; the opened turn satisfies request()'s enclosure precondition.
|
||||
*/
|
||||
function sessionAgent(id: string): { agent: Agent; session: Session; injected: string[] } {
|
||||
const session = new Session(SessionId(id))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const injected: string[] = []
|
||||
const agent = {
|
||||
id,
|
||||
session,
|
||||
inject: (content: { type: string; text: string }[]) => { injected.push(content[0]?.text ?? '') },
|
||||
} as unknown as Agent
|
||||
return { agent, session, injected }
|
||||
}
|
||||
|
||||
const preStep = (ctx: Context, agent: Agent): Promise<void> =>
|
||||
ctx.serial('agent/pre-step', agent, 1, 1, '', [], new AbortController().signal)
|
||||
|
||||
/** 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' })
|
||||
}
|
||||
|
||||
it('folds to the last event, or undefined without one', () => {
|
||||
const { session } = sessionAgent('sess-fold')
|
||||
expect(effectiveApprovalPolicy(session.events)).toBeUndefined()
|
||||
setApprovalPolicy(session, 'never')
|
||||
setApprovalPolicy(session, 'ask')
|
||||
expect(effectiveApprovalPolicy(session.events)).toBe('ask')
|
||||
expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } })
|
||||
})
|
||||
|
||||
it('defaults a schema-less construction to ask (the ?? narrows the optional TYPE)', async () => {
|
||||
// Direct construction bypasses the plugin schema (the SystemPrompt-test
|
||||
// precedent for covering a defaulted Config field's type-narrowing ??).
|
||||
const ctx = new Context()
|
||||
const service = new ApprovalService(ctx, {})
|
||||
const { agent } = sessionAgent('sess-bare-config')
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
await expect(service.request({ agent, toolName: 'echo' })).resolves.toBe('allowed-once')
|
||||
})
|
||||
|
||||
it('contains an answerer that throws SYNCHRONOUSLY as unavailable', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent } = sessionAgent('sess-syncthrow')
|
||||
ctx.on('approval/request', () => { throw new Error('sync bug') })
|
||||
await expect(ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('unavailable')
|
||||
})
|
||||
|
||||
it('a never config rejects deterministically without consulting any answerer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
const consulted = vi.fn()
|
||||
ctx.on('approval/request', (_req, next) => { consulted(); return next() })
|
||||
const { agent, session } = sessionAgent('sess-gate-1')
|
||||
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
|
||||
expect(consulted).not.toHaveBeenCalled()
|
||||
// The audit pair still lands on the session log.
|
||||
expect(session.events.filter(e => e.type === 'approval/asked')).toHaveLength(1)
|
||||
expect(session.events.filter(e => e.type === 'approval/decided')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('the gate decides FIRST even against an answerer registered before the service (prepend)', async () => {
|
||||
const ctx = new Context()
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
const { agent } = sessionAgent('sess-gate-2')
|
||||
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('never is unbypassable even by an answerer PREPENDED after the service mounts', async () => {
|
||||
// Cordis prepend unshifts ahead of every existing listener, including
|
||||
// any gate LISTENER the service could register — which is exactly why
|
||||
// the 'never' decision lives inside request() instead. The eager grant
|
||||
// below must never be consulted.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
const consulted = vi.fn()
|
||||
ctx.on('approval/request', () => { consulted(); return Promise.resolve<ApprovalOutcome>('allowed-once') }, { prepend: true })
|
||||
const { agent, appended } = fakeAgent()
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('rejected')
|
||||
expect(consulted).not.toHaveBeenCalled()
|
||||
expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
})
|
||||
|
||||
it('a session override outranks the configured default, in both directions', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
const { agent, session } = sessionAgent('sess-gate-3')
|
||||
setApprovalPolicy(session, 'ask')
|
||||
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('allowed-once')
|
||||
setApprovalPolicy(session, 'never')
|
||||
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('states never (and only never) in prose while recording either policy with a source-owned marker', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ApprovalService)
|
||||
const askAgent = sessionAgent('sess-sect-ask').agent
|
||||
const { agent: neverAgent, session } = sessionAgent('sess-sect-never')
|
||||
setApprovalPolicy(session, 'never')
|
||||
const sectionFor = async (context: object) =>
|
||||
(await ctx.systemPrompt.assemble(context)).sections.find(s => s.name === 'approval:policy')?.text
|
||||
expect(await sectionFor({ agent: askAgent })).toBe(ASK_MARKER)
|
||||
expect(await sectionFor({ agent: neverAgent })).toBe(`${NEVER_SENTENCE}\n${NEVER_MARKER}`)
|
||||
// A bare assemble (no agent) has no session to state.
|
||||
expect(await sectionFor({})).toBe('')
|
||||
})
|
||||
|
||||
it('narrates nothing cold, once per coalesced switch (user wording), and idempotently', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-1')
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual([])
|
||||
setApprovalPolicy(session, 'never')
|
||||
setApprovalPolicy(session, 'ask')
|
||||
setApprovalPolicy(session, 'never')
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toHaveLength(1)
|
||||
setApprovalPolicy(session, 'ask')
|
||||
setApprovalPolicy(session, 'never')
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reads what the model was told back from the folded header text after a restart', async () => {
|
||||
// A session whose last request carried the never sentence resumes under
|
||||
// an ask default: the narrator attributes the change to the operator.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-2')
|
||||
appendHeader(session, `persona\n\n${NEVER_SENTENCE}\n${NEVER_MARKER}`)
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual(['The approval policy changed from "never" to "ask" (changed by the operator/config).'])
|
||||
})
|
||||
|
||||
it('narrates a config default drift from the logged ask marker', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-3')
|
||||
appendHeader(session, `persona only\n${ASK_MARKER}`)
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the operator/config).'])
|
||||
})
|
||||
|
||||
it('a pinned override survives a default change silently', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-4')
|
||||
appendHeader(session, `persona only\n${ASK_MARKER}`)
|
||||
setApprovalPolicy(session, 'ask')
|
||||
appendHeader(session, `persona only\n${ASK_MARKER}`)
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual([])
|
||||
})
|
||||
|
||||
it('does not infer never from deployment prose that quotes the never sentence', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-spoof-prose')
|
||||
appendHeader(session, `persona quotes this warning: ${NEVER_SENTENCE}\n${ASK_MARKER}`)
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual([])
|
||||
})
|
||||
|
||||
it('treats a legacy header with no source-owned marker as untold', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService, { policy: 'never' })
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-unmarked-header')
|
||||
appendHeader(session, 'legacy persona-only header')
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual([])
|
||||
})
|
||||
|
||||
it('uses the service marker after an earlier persona marker', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
const { agent, session, injected } = sessionAgent('sess-narr-spoof-marker')
|
||||
appendHeader(session, `persona quotes ${NEVER_MARKER}\n${ASK_MARKER}`)
|
||||
await preStep(ctx, agent)
|
||||
expect(injected).toEqual([])
|
||||
})
|
||||
|
||||
it('disposes the service prompt section and pre-step narrator together (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
const fiber = await ctx.plugin(ApprovalService)
|
||||
const live = sessionAgent('sess-hmr-service-live')
|
||||
const afterDispose = sessionAgent('sess-hmr-service-disposed')
|
||||
const sectionFor = async () =>
|
||||
(await ctx.systemPrompt.assemble({ agent: live.agent })).sections.find(section => section.name === 'approval:policy')
|
||||
expect(await sectionFor()).toBeDefined()
|
||||
|
||||
appendHeader(live.session, `persona\n${ASK_MARKER}`)
|
||||
setApprovalPolicy(live.session, 'never')
|
||||
await preStep(ctx, live.agent)
|
||||
expect(live.injected).toEqual(['The approval policy changed from "ask" to "never" (changed by the user).'])
|
||||
|
||||
appendHeader(afterDispose.session, `persona\n${ASK_MARKER}`)
|
||||
setApprovalPolicy(afterDispose.session, 'never')
|
||||
await fiber.dispose()
|
||||
|
||||
expect(await sectionFor()).toBeUndefined()
|
||||
await preStep(ctx, afterDispose.agent)
|
||||
expect(afterDispose.injected).toEqual([])
|
||||
})
|
||||
})
|
||||
36
packages/ui/user-approval/tsconfig.json
Normal file
36
packages/ui/user-approval/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
}
|
||||
]
|
||||
}
|
||||
24
packages/ui/user-interaction/README.md
Normal file
24
packages/ui/user-interaction/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# @deepseek-ai/dsh-user-interaction
|
||||
|
||||
Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a model-facing tool or permission plugin uses when it needs to pause work and ask the human for a decision.
|
||||
|
||||
## Service: `UserInteractionService` (ctx key: `userInteraction`)
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.userInteraction.registerProvider(provider): () => void` Register the UI-side provider. Only one provider may be active in a context; disposal unregisters it.
|
||||
- `ctx.userInteraction.ask(request): Promise<AskUserQuestionAnswer>` Ask the active provider and wait for the answer.
|
||||
|
||||
### Key Types
|
||||
|
||||
- `AskUserQuestionRequest` — `{ questions: [{ id, question, header?, options?, multiSelect? }], agent?, signal? }`.
|
||||
- `AskUserQuestionOption` — `{ label, description? }`.
|
||||
- `AskUserQuestionAnswer` — `{ answers: [{ id, selected, custom? }] }`.
|
||||
- `UserInteractionProvider` — UI implementation with `ask(request)`.
|
||||
- `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
|
||||
|
||||
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices.
|
||||
|
||||
## Role
|
||||
|
||||
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call simply awaits a promise, and the tool result resumes the normal agent loop.
|
||||
34
packages/ui/user-interaction/package.json
Normal file
34
packages/ui/user-interaction/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-user-interaction",
|
||||
"description": "Abstract user-interaction seam (ctx.userInteraction) for asking the human during agent runs",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
128
packages/ui/user-interaction/src/index.ts
Normal file
128
packages/ui/user-interaction/src/index.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* User-interaction seam (`ctx.userInteraction`): a UI-backed service for
|
||||
* pausing an agent tool call until the human answers a question. The model-
|
||||
* facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages provide
|
||||
* the single active provider.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-user-interaction
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
userInteraction: UserInteractionService
|
||||
}
|
||||
}
|
||||
|
||||
/** One selectable answer offered to the user. */
|
||||
export interface AskUserQuestionOption {
|
||||
/** User-facing label. */
|
||||
label: string
|
||||
/** Optional extra context rendered by capable UIs. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** One question in an ask_user_question request. */
|
||||
export interface AskUserQuestionItem {
|
||||
/** Stable model-provided question id, echoed in the answer. */
|
||||
id: string
|
||||
/** The question to display. */
|
||||
question: string
|
||||
/** Optional short heading/group label. */
|
||||
header?: string
|
||||
/** Optional choices the UI can render as a menu. */
|
||||
options?: AskUserQuestionOption[]
|
||||
/** Whether more than one option may be selected. Defaults to single-select. */
|
||||
multiSelect?: boolean
|
||||
}
|
||||
|
||||
/** Request for a human answer. */
|
||||
export interface AskUserQuestionRequest {
|
||||
/** Questions to display. */
|
||||
questions: AskUserQuestionItem[]
|
||||
/** Calling agent, when the request came from an agent tool call. */
|
||||
agent?: Agent
|
||||
/** Abort signal for the owning tool/step. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Answer to one question. */
|
||||
export interface AskUserQuestionAnswerItem {
|
||||
/** The answered question id. */
|
||||
id: string
|
||||
/** Selected option labels. Empty when the answer is purely custom text. */
|
||||
selected: string[]
|
||||
/** Optional free-text "Other" answer. */
|
||||
custom?: string
|
||||
}
|
||||
|
||||
/** The human's answer. */
|
||||
export interface AskUserQuestionAnswer {
|
||||
/** Structured answers keyed by question id. */
|
||||
answers: AskUserQuestionAnswerItem[]
|
||||
}
|
||||
|
||||
/** UI-side provider for user questions. */
|
||||
export interface UserInteractionProvider {
|
||||
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
|
||||
}
|
||||
|
||||
/** Stable error taxonomy for user-interaction failures. */
|
||||
export class UserInteractionError extends HarnessError {
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, code, options)
|
||||
this.name = 'UserInteractionError'
|
||||
}
|
||||
}
|
||||
|
||||
/** `ctx.userInteraction`: one active UI provider plus an `ask()` surface. */
|
||||
export class UserInteractionService extends Service {
|
||||
private provider: UserInteractionProvider | undefined
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'userInteraction')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the UI provider. Only one provider may be active in a context.
|
||||
*
|
||||
* @param provider UI-side implementation that collects answers.
|
||||
* @returns Disposer that unregisters this provider.
|
||||
*/
|
||||
registerProvider(provider: UserInteractionProvider): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: UserInteractionService) {
|
||||
if (this.provider !== undefined) {
|
||||
throw new UserInteractionError('a user-interaction provider is already registered', 'DUPLICATE_PROVIDER')
|
||||
}
|
||||
this.provider = provider
|
||||
yield () => {
|
||||
this.provider = undefined
|
||||
}
|
||||
}.bind(this), 'userInteraction.registerProvider()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the active UI provider and wait for the user's answer.
|
||||
*
|
||||
* @param request Questions, owner agent, and abort signal.
|
||||
* @returns The answer chosen or typed by the human.
|
||||
*/
|
||||
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
|
||||
if (request.signal?.aborted) {
|
||||
throw new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')
|
||||
}
|
||||
if (request.questions.length === 0) {
|
||||
throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
|
||||
}
|
||||
if (this.provider === undefined) {
|
||||
throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER')
|
||||
}
|
||||
return this.provider.ask(request)
|
||||
}
|
||||
}
|
||||
|
||||
export default UserInteractionService
|
||||
86
packages/ui/user-interaction/tests/user-interaction.spec.ts
Normal file
86
packages/ui/user-interaction/tests/user-interaction.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import UserInteractionService, {
|
||||
UserInteractionError,
|
||||
type AskUserQuestionRequest,
|
||||
type UserInteractionProvider,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
function provider(answer = 'approved'): UserInteractionProvider & { seen: AskUserQuestionRequest[] } {
|
||||
const seen: AskUserQuestionRequest[] = []
|
||||
return {
|
||||
seen,
|
||||
async ask(request) {
|
||||
seen.push(request)
|
||||
return { answers: [{ id: request.questions[0]?.id ?? 'missing', selected: [answer] }] }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('UserInteractionService', () => {
|
||||
it('delegates ask requests to the registered provider', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const p = provider('yes')
|
||||
ctx.userInteraction.registerProvider(p)
|
||||
|
||||
const result = await ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] })
|
||||
|
||||
expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
|
||||
expect(p.seen).toEqual([{ questions: [{ id: 'confirm', question: 'Proceed?' }] }])
|
||||
})
|
||||
|
||||
it('rejects ask requests when no provider is registered', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
|
||||
await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
|
||||
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_PROVIDER' })
|
||||
})
|
||||
|
||||
it('registers providers with HMR-safe disposal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const p = provider()
|
||||
const dispose = ctx.userInteraction.registerProvider(p)
|
||||
|
||||
dispose()
|
||||
dispose()
|
||||
|
||||
await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
|
||||
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
||||
})
|
||||
|
||||
it('rejects duplicate providers instead of replacing the active UI', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.userInteraction.registerProvider(provider('first'))
|
||||
|
||||
expect(() => ctx.userInteraction.registerProvider(provider('second')))
|
||||
.toThrow(UserInteractionError)
|
||||
})
|
||||
|
||||
it('fails before reaching the provider when the signal is already aborted', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const p = { ask: vi.fn(async () => ({ answers: [{ id: 'confirm', selected: ['too late'] }] })) }
|
||||
ctx.userInteraction.registerProvider(p)
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], signal: controller.signal }))
|
||||
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
expect(p.ask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects empty question batches before reaching the provider', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const p = { ask: vi.fn(async () => ({ answers: [] })) }
|
||||
ctx.userInteraction.registerProvider(p)
|
||||
|
||||
await expect(ctx.userInteraction.ask({ questions: [] }))
|
||||
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' })
|
||||
expect(p.ask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
24
packages/ui/user-interaction/tsconfig.json
Normal file
24
packages/ui/user-interaction/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user