Merge latest master into tool JSON schema DSL

# Conflicts:
#	examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md
#	examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md
#	examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md
This commit is contained in:
Tianyi Cui
2026-07-22 20:58:17 +08:00
186 changed files with 8053 additions and 582 deletions

View File

@@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
## At a glance
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection and permission presets, and **session modes** (the picker, via `@deepseek-ai/dsh-plan-mode`). The largest **unbuilt** areas are **MCP passthrough** 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,7 +25,7 @@ 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 | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. One request is in flight per session. |
| `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. |
| `session/set_mode` | S | | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#6-session-modes--config-options--models)). |
| `session/set_mode` | S | | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-plan-mode` mounted, `session/new`/`session/load` advertise the fixed `default` / `plan` projection and `session/set_mode` records the boolean pending intent (optimistic `current_mode_update`; logged `plan/mode` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. |
| model selection | S | ✅ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. Values preserve the provider/model pair, catalogs come from `ctx.llm`, selection is per session, and `session/load` restores the last requested pair. Codex also supports the legacy `unstable_setSessionModel` ext method. |
| `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. |
@@ -85,7 +85,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `tool_call_update` | S | ✅ | ✅ | ✅ | From appended `tool/result` via `presentResult`; replacement results rewrite model context and do not duplicate or overwrite execution presentation. |
| `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 | ✅ | ✅ | ✅ | Full effective snapshot after create/load and registry changes; names, descriptions, and unstructured-input hints come from `ctx.commands`. |
| `current_mode_update` | S | | ✅ | ✅ | No session modes. |
| `current_mode_update` | S | | ✅ | ✅ | Echoed optimistically on `session/set_mode` and re-notified when a logged `plan/mode` maps to a different wire id (covers the `exit_plan_mode` tool flipping the session back). |
| `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 Agent Note § Per-session mode switching](../../../.agents/notes/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
Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. Session modes stay deliberately unmodeled because config options replace them in ACP v2. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
Session modes ✅ (the [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md)): ACP owns the fixed `default` / `plan` wire vocabulary and projects it onto `ctx.planMode`'s boolean `{ active, pending? }` state; `session/set_mode` calls `set()` and `current_mode_update` tracks the optimistic selection plus each distinct committed `plan/mode` flip. Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. The division is picker-to-collaboration-state / knobs-to-config-options: individual environment knobs and the provider/model selector are not modes. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
## 7. Content blocks

View File

@@ -38,6 +38,7 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-plan-mode": "^0.0.1",
"@deepseek-ai/dsh-permission": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -61,6 +62,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -36,11 +36,14 @@ import {
type PromptRequest,
type PromptResponse,
type SessionConfigOption,
type SessionModeState,
type SessionConfigSelectGroup,
type SessionConfigSelectOption,
type SessionNotification,
type SetSessionConfigOptionRequest,
type SetSessionConfigOptionResponse,
type SetSessionModeRequest,
type SetSessionModeResponse,
type Stream,
type StopReason,
} from '@agentclientprotocol/sdk'
@@ -64,6 +67,9 @@ import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } f
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
import type {} from '@deepseek-ai/dsh-session-persistence'
// Type-only edge: resolves `ctx.get('planMode')` when dsh-plan-mode is composed;
// the runtime read stays opportunistic.
import type {} from '@deepseek-ai/dsh-plan-mode'
// Side-effect type import: declaration-merges prompt assembly onto Context and
// the scoped waterfall used to keep persona variables aligned with requests.
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -94,6 +100,18 @@ function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
}
const DEFAULT_SESSION_MODE_ID = 'default'
const PLAN_SESSION_MODE_ID = 'plan'
const AVAILABLE_SESSION_MODES = [
{ id: DEFAULT_SESSION_MODE_ID, name: DEFAULT_SESSION_MODE_ID },
{ id: PLAN_SESSION_MODE_ID, name: PLAN_SESSION_MODE_ID },
]
/** Map plan state onto ACP's named collaboration-mode protocol. */
function sessionModeId(active: boolean): string {
return active ? PLAN_SESSION_MODE_ID : DEFAULT_SESSION_MODE_ID
}
/** Render arbitrary thrown values without trusting their string coercion. */
function renderThrown(value: unknown): string {
try {
@@ -186,11 +204,14 @@ function elicitationForQuestion(
options: AskUserQuestionOption[],
): CreateElicitationRequest {
const title = question.header ?? 'Question'
const message = question.detail === undefined
? question.question
: `${question.question}\n\n${question.detail}`
if (options.length === 0) {
return {
sessionId,
mode: 'form',
message: question.question,
message,
requestedSchema: {
type: 'object',
title,
@@ -224,7 +245,7 @@ function elicitationForQuestion(
return {
sessionId,
mode: 'form',
message: question.question,
message,
requestedSchema: {
type: 'object',
title,
@@ -286,6 +307,13 @@ interface SessionRecord {
presenter: ToolPresenter
/** Terminal capability snapshot shared by matching call and result updates. */
terminalEnabled: boolean
/**
* The last mode id this session sent to the client (advertised at
* session/new+load, echoed optimistically on session/set_mode, re-notified on
* each logged `plan/mode` that differs). `undefined` when dsh-plan-mode is
* not composed, so no mode surface is advertised or notified.
*/
lastModeId: string | undefined
/** Session-local provider/model selection and the current step snapshot. */
target: LlmTargetRef
/** In-flight prompt and its captured turn number for exact settlement. */
@@ -542,6 +570,21 @@ export function apply(ctx: Context, config: AcpConfig): void {
// --- Stream the harness event taxonomy to ACP session/update --------------
// --- Session modes (dsh-plan-mode, opportunistic) -------------------------
// ACP's generic mode picker projects the one plan capability as the fixed
// `default` / `plan` vocabulary. A selection is echoed optimistically; the
// logged `plan/mode` follows at the boundary and tool-driven exits are
// re-notified from that event. Environment knobs remain config options.
const modesStateFor = (agent: Agent): SessionModeState | undefined => {
const planMode = ctx.get('planMode')
if (planMode === undefined) return undefined
const { active, pending } = planMode.get(agent)
return {
availableModes: AVAILABLE_SESSION_MODES,
currentModeId: sessionModeId(pending ?? active),
}
}
// All content streaming AND the prompt settle flow through `session/event`,
// the canonical log: every assistant/chunk and tool/call/result is logged, so
// translating from the log makes live streaming and `session/load` replay
@@ -567,6 +610,20 @@ export function apply(ctx: Context, config: AcpConfig): void {
cwd: session.header.cwd,
}, { includeUserMessages: false })
} finally {
// Re-notify from the EVENT's value, not from planMode.get(): the service
// holds one coalesced pending slot (every flush reads the latest
// selection, so a flush can never be stale against the picker), and for
// any other writer — the exit tool, a test, a foreign plugin — the logged
// value IS the truth the picker should track, in log order. Inside the
// containment `finally` like the prompt settlement: a throwing presenter
// must not desync the picker.
if (event.type === 'plan/mode') {
const modeId = sessionModeId(event.data.active)
if (modeId !== rec.lastModeId) {
rec.lastModeId = modeId
notify({ sessionId: rec.agent.session.id, update: { sessionUpdate: 'current_mode_update', currentModeId: modeId } })
}
}
const inflight = rec.inflight
if (inflight !== undefined && event.type === 'turn/start') {
// The first message-triggered turn after prompt installation owns the
@@ -726,11 +783,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
await handle.dispose()
throw internalError('connection closed during session/new')
}
const modes = modesStateFor(handle.agent)
const record: SessionRecord = {
agent: handle.agent,
dispose: () => handle.dispose(),
presenter: makePresenter(handle.agent),
terminalEnabled: terminalOutputCap,
lastModeId: modes?.currentModeId,
target,
inflight: undefined,
commandAbort: undefined,
@@ -739,7 +798,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
sessions.set(sessionId, record)
pendingCommandSnapshots.set(sessionId, record)
const configOptions = configOptionsFor(handle.agent, directory)
return { sessionId, ...configOptions.length > 0 ? { configOptions } : {} }
return {
sessionId,
...modes !== undefined ? { modes } : {},
...configOptions.length > 0 ? { configOptions } : {},
}
},
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
@@ -811,11 +874,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
// the replay below and the post-load live stream) so a later
// `initialize` can't desync the call/result of a tool card.
const terminalEnabled = terminalOutputCap
const modes = modesStateFor(agent)
const record: SessionRecord = {
agent,
dispose: () => handle.dispose(),
presenter: makePresenter(agent),
terminalEnabled,
lastModeId: modes?.currentModeId,
target,
inflight: undefined,
commandAbort: undefined,
@@ -845,12 +910,33 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
notifyCommands(record)
const configOptions = configOptionsFor(agent, directory)
return configOptions.length > 0 ? { configOptions } : {}
return {
...modes !== undefined ? { modes } : {},
...configOptions.length > 0 ? { configOptions } : {},
}
} finally {
loadingIds.delete(sessionId)
}
},
setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
assertOpen()
const rec = requireSession(SessionId(params.sessionId))
const planMode = ctx.get('planMode')
if (planMode === undefined) throw invalidParams('session modes are not composed in this deployment')
if (params.modeId !== DEFAULT_SESSION_MODE_ID && params.modeId !== PLAN_SESSION_MODE_ID) {
throw invalidParams(`unknown session mode ${JSON.stringify(params.modeId)} — available modes: default, plan`)
}
planMode.set(rec.agent, params.modeId === PLAN_SESSION_MODE_ID)
// Optimistic echo: the pending mode IS the user's selection; the logged
// `plan/mode` lands at the next turn boundary and, matching lastModeId,
// is not re-notified. A no-op selection (already current) echoes too —
// cheap, idempotent, and the picker settles regardless.
rec.lastModeId = params.modeId
notify({ sessionId: rec.agent.session.id, update: { sessionUpdate: 'current_mode_update', currentModeId: params.modeId } })
return Promise.resolve({})
},
async prompt(params: PromptRequest): Promise<PromptResponse> {
assertOpen()
const rec = requireSession(SessionId(params.sessionId))

View File

@@ -143,15 +143,18 @@ describe('acp bridge', () => {
questions: [{
id: 'language',
question: 'Which language?',
detail: 'Choose the implementation language for this project.',
options: [{ label: 'TypeScript' }],
}],
})
expect(result).toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] })
expect(harness.elicitationRequests[0]).toMatchObject({
message: 'Which language?\n\nChoose the implementation language for this project.',
requestedSchema: {
properties: {
choice: {
title: 'Which language?',
description: 'Choose one option, or fill a custom answer below.',
oneOf: [{ const: 'TypeScript', title: 'TypeScript' }],
},

View File

@@ -17,6 +17,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
import {
ClientSideConnection,
ndJsonStream,
@@ -191,6 +192,8 @@ export async function makeBridgeHarness(options: {
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
*/
withTodo?: boolean
/** Plug the REAL `dsh-plan-mode` plugin so a test can drive the session-mode picker. */
withModes?: boolean
/**
* Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` +
* `dsh-tool-fs`) so a test can drive `read`/`write`/`edit` through the bridge
@@ -225,6 +228,9 @@ export async function makeBridgeHarness(options: {
if (options.withTodo) {
await ctx.plugin(ToolTodo)
}
if (options.withModes) {
await ctx.plugin(PlanModeService, { section: 'Test plan mode instructions.' })
}
if (options.withFs) {
await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir })
await ctx.plugin(FsPolicy)

View File

@@ -0,0 +1,116 @@
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 { SessionId } from '@deepseek-ai/dsh-session'
import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
/** The `current_mode_update` notifications, in order. */
function modeUpdates(updates: CapturedUpdate[]): string[] {
return updates
.filter(update => update.sessionUpdate === 'current_mode_update')
.map(update => update.currentModeId)
}
describe('acp bridge — plan mode projection', () => {
let storageDir: string
let harness: BridgeHarness | undefined
let loader: BridgeHarness | undefined
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-modes-')) })
afterEach(async () => {
if (harness) await harness.dispose()
if (loader) await loader.dispose()
harness = loader = undefined
await rm(storageDir, { recursive: true, force: true })
})
it('advertises no mode surface and rejects session/set_mode when plan mode is not composed', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const res = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(res.modes).toBeUndefined()
await expect(harness.client.setSessionMode({ sessionId: res.sessionId, modeId: 'plan' }))
.rejects.toMatchObject({ message: expect.stringContaining('session modes are not composed') as string })
})
it('advertises availableModes/currentModeId on session/new', async () => {
harness = await makeBridgeHarness({ storageDir, withModes: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const res = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(res.modes).toEqual({
availableModes: [
{ id: 'default', name: 'default' },
{ id: 'plan', name: 'plan' },
],
currentModeId: 'default',
})
})
it('session/set_mode records the pending intent and echoes one optimistic current_mode_update', async () => {
harness = await makeBridgeHarness({ storageDir, withModes: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
expect(modeUpdates(harness.updates)).toEqual(['plan'])
const agent = harness.ctx.agents.get(SessionId(sessionId))!
expect(harness.ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('rejects an unknown ACP mode id at the adapter boundary', async () => {
harness = await makeBridgeHarness({ storageDir, withModes: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.setSessionMode({ sessionId, modeId: 'nope' }))
.rejects.toMatchObject({ message: expect.stringContaining('unknown session mode "nope"') as string })
expect(modeUpdates(harness.updates)).toEqual([])
})
it('does not re-notify when the boundary flush logs the mode the picker already showed', async () => {
harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(true)
expect(modeUpdates(harness.updates)).toEqual(['plan'])
})
it('re-notifies on a logged flip the picker has not seen (the tool-driven exit shape)', async () => {
harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
// A writer other than the picker (exit_plan_mode's execute) appends the
// flip back; the bridge must re-notify the client off the logged event.
const agent = harness.ctx.agents.get(SessionId(sessionId))!
agent.session.append('plan/mode', { active: false })
// The notification crosses the in-memory JSON-RPC transport asynchronously.
await new Promise(resolve => setTimeout(resolve, 20))
expect(modeUpdates(harness.updates)).toEqual(['plan', 'default'])
})
it('advertises the folded mode on session/load', async () => {
harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
await harness.dispose()
harness = undefined
loader = await makeBridgeHarness({ storageDir, withModes: true, script: [] })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
expect(res.modes).toEqual({
availableModes: [
{ id: 'default', name: 'default' },
{ id: 'plan', name: 'plan' },
],
currentModeId: 'plan',
})
})
})

View File

@@ -41,6 +41,9 @@
{
"path": "../user-interaction"
},
{
"path": "../../plan/plan-mode"
},
{
"path": "../../session-persistence/session-persistence"
},

View File

@@ -10,7 +10,7 @@ Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plu
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
Handlers return `success` or `error` plus optional UI text. Results are rendered directly by the adapter and never enter model history. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it.
Handlers return `success` or `error` plus optional UI text. Results are rendered directly by the adapter and never enter model history. The registry never submits `rawInput` to the agent implicitly; a command producer may explicitly schedule model-visible work through the receiving `Agent`, in which case that producer owns the resulting message contract. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it.
## Composition
@@ -22,11 +22,11 @@ The terminal and ACP app bundles mount this service with their consuming front d
#### What the model sees
Nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt.
The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) submits the optional message in `/plan [message]` after selecting plan mode.
#### Token effect
Command discovery, execution, and UI output add no model tokens. A command plugin may separately mutate a model-visible domain through that domain's durable APIs.
Command discovery, execution, and UI output add no model tokens. Explicit agent work scheduled by a command producer has the same token effect as the corresponding agent input.
#### KV Cache effect

View File

@@ -14,7 +14,7 @@ An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly and unknown commands produce a warning, with no automatic fallthrough to the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
@@ -58,7 +58,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic
#### What the model sees
Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only; command results remain terminal notices.
Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`.
#### Token effect

View File

@@ -949,9 +949,15 @@ class QuestionDialog implements Component, Focusable {
const lines = [
this.palette.muted(header),
...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth),
'',
]
const push = (line: string): void => { lines.push(line) }
// Supporting detail (e.g. the full plan under review) renders between the
// question and the answer surface, kept out of option labels.
if (this.question.detail !== undefined) {
push('')
for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line)
}
push('')
if (this.mode === 'custom') {
for (const line of this.input.render(innerWidth)) push(line)
push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel'))

View File

@@ -1120,12 +1120,13 @@ describe('TUI user-interaction dialogs', () => {
const single = result.ctx.userInteraction.ask({
questions: [{
id: 'mode', header: 'Mode', question: 'Choose a mode',
id: 'mode', header: 'Mode', question: 'Choose a mode', detail: 'This choice controls the next turn.',
options: [{ label: 'Safe', description: 'Use checks' }, { label: 'Fast' }],
}],
})
await tick()
expect(result.terminal.output).toContain('Choose a mode')
expect(result.terminal.output).toContain('This choice controls the next turn.')
expect(result.terminal.output).toContain('Question 1/1 (1 unanswered) · Mode')
expect(result.terminal.output).toContain('1/2')
result.terminal.send('\x1b[B')

View File

@@ -11,7 +11,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
### Key Types
- `AskUserQuestionRequest``{ questions: [{ id, question, header?, options?, multiSelect? }], agent?, signal? }`.
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label.
- `AskUserQuestionOption``{ label, description? }`.
- `AskUserQuestionAnswer``{ answers: [{ id, selected, custom? }] }`.
- `UserInteractionProvider` — UI implementation with `ask(request)`.

View File

@@ -13,12 +13,14 @@ export interface AskUserQuestionOption {
description?: string
}
/** One question in an ask_user_question request. */
/** One question in a user-interaction request. */
export interface AskUserQuestionItem {
/** Stable model-provided question id, echoed in the answer. */
/** Stable caller-provided question id, echoed in the answer. */
id: string
/** The question to display. */
question: string
/** Optional supporting detail rendered with the question but kept out of option labels. */
detail?: string
/** Optional short heading/group label. */
header?: string
/** Optional choices the UI can render as a menu. */