Merge remote-tracking branch 'origin/master' into worktree/pr468-retarget-latest-master
# Conflicts: # docs/architecture.i18n.yaml # docs/capability-seams.md # docs/config-catalog.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/config-options/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/goal-command-status/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/model-switching/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/permission-switching/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/todo-plan/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/workspace-context/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl # packages/ui/acp/acp-feature-support.md # packages/ui/acp/src/index.ts # packages/ui/acp/tests/harness.ts # packages/ui/tui/README.md # scripts/type-equiv.manifest.json
This commit is contained in:
@@ -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/list, 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/list, 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 | ✅ | ✅ | ✅ | Uses live-preferred `ctx.sessionQuery`; returns absolute-cwd sessions newest-first with optional folded title and exact cwd filtering. Pagination is not emitted; supplied cursors are rejected. |
|
||||
@@ -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 | ✅ | ⚠️ | ⚠️ | Log-backed title events push title and event time; load replay uses the same mapping. |
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -63,6 +64,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:^",
|
||||
|
||||
@@ -38,11 +38,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'
|
||||
@@ -70,6 +73,9 @@ import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
// Side-effect type import: declaration-merges the exact-read service used by
|
||||
// session/list for live-preferred title folding.
|
||||
import type {} from '@deepseek-ai/dsh-session-query'
|
||||
// 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'
|
||||
@@ -104,6 +110,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 {
|
||||
@@ -196,11 +214,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,
|
||||
@@ -234,7 +255,7 @@ function elicitationForQuestion(
|
||||
return {
|
||||
sessionId,
|
||||
mode: 'form',
|
||||
message: question.question,
|
||||
message,
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
title,
|
||||
@@ -296,6 +317,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. */
|
||||
@@ -554,6 +582,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
|
||||
@@ -579,6 +622,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
|
||||
@@ -774,11 +831,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,
|
||||
@@ -788,7 +847,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> {
|
||||
@@ -860,11 +923,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,
|
||||
@@ -895,12 +960,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))
|
||||
|
||||
@@ -144,15 +144,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' }],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
@@ -195,6 +196,8 @@ export async function makeBridgeHarness(options: {
|
||||
withTodo?: boolean
|
||||
/** Mount exact session reads and cross-session snapshot preparation before ACP. */
|
||||
withSessionReferences?: 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
|
||||
@@ -233,6 +236,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)
|
||||
|
||||
116
packages/ui/acp/tests/modes.spec.ts
Normal file
116
packages/ui/acp/tests/modes.spec.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -47,6 +47,9 @@
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../plan/plan-mode"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
|
||||
|
||||
export const name = 'commands'
|
||||
|
||||
@@ -68,6 +68,26 @@ interface RegisteredCommand {
|
||||
readonly descriptor: CommandDescriptor
|
||||
}
|
||||
|
||||
/** All command registrations owned by one global or scoped layer. */
|
||||
class CommandLayer implements ScopeLayer {
|
||||
readonly commands: NamedEntries<RegisteredCommand>
|
||||
|
||||
/**
|
||||
* Create one command layer with diagnostics specific to its ownership scope.
|
||||
* @param scope - the scoped owner, or `undefined` for global registrations.
|
||||
*/
|
||||
constructor(scope: ScopeKey | undefined) {
|
||||
this.commands = new NamedEntries(name => new Error(scope === undefined
|
||||
? `command "${name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
|
||||
: `command "${name}" is already registered in this scope`))
|
||||
}
|
||||
|
||||
/** @returns whether this layer owns no command registrations. */
|
||||
isEmpty(): boolean {
|
||||
return this.commands.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
commands: CommandService
|
||||
@@ -205,8 +225,10 @@ function normalizeResult(command: string, value: unknown): CommandResult {
|
||||
* globals for that agent.
|
||||
*/
|
||||
export class CommandService extends Service {
|
||||
private readonly global = new Map<string, RegisteredCommand>()
|
||||
private readonly scoped = new Map<ScopeKey, Map<string, RegisteredCommand>>()
|
||||
private readonly layers = new ScopedLayers(
|
||||
scope => new CommandLayer(scope),
|
||||
() => { this.notifyChange() },
|
||||
)
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'commands')
|
||||
@@ -218,25 +240,12 @@ export class CommandService extends Service {
|
||||
* @returns the exact effect disposer that unregisters this definition.
|
||||
*/
|
||||
register(definition: CommandDefinition): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const registered = normalizeDefinition(definition)
|
||||
const dispose = this.ctx.effect(function* (this: CommandService) {
|
||||
const layer = scope === undefined ? this.global : this.layerFor(scope)
|
||||
if (layer.has(registered.definition.name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `command "${registered.definition.name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
|
||||
: `command "${registered.definition.name}" is already registered in this scope`)
|
||||
}
|
||||
layer.set(registered.definition.name, registered)
|
||||
yield () => {
|
||||
layer.delete(registered.definition.name)
|
||||
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
|
||||
this.notifyChange()
|
||||
}
|
||||
this.notifyChange()
|
||||
}.bind(this), 'commands.register()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order
|
||||
return dispose
|
||||
return this.layers.effect(
|
||||
this.ctx,
|
||||
layer => layer.commands.insert(registered.definition.name, registered),
|
||||
{ label: 'commands.register()' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -285,19 +294,7 @@ export class CommandService extends Service {
|
||||
|
||||
/** Resolve global definitions followed by exact scoped shadows. */
|
||||
private view(agent: Agent): Map<string, RegisteredCommand> {
|
||||
const visible = new Map(this.global)
|
||||
for (const [name, command] of this.scoped.get(agent) ?? []) visible.set(name, command)
|
||||
return visible
|
||||
}
|
||||
|
||||
/** Create the registration layer for one agent scope on demand. */
|
||||
private layerFor(scope: ScopeKey): Map<string, RegisteredCommand> {
|
||||
let layer = this.scoped.get(scope)
|
||||
if (layer === undefined) {
|
||||
layer = new Map()
|
||||
this.scoped.set(scope, layer)
|
||||
}
|
||||
return layer
|
||||
return this.layers.merge(agent, layer => layer.commands)
|
||||
}
|
||||
|
||||
/** Notify every registry observer without making UI refresh load-bearing. */
|
||||
|
||||
@@ -94,6 +94,19 @@ describe('CommandService', () => {
|
||||
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
|
||||
})
|
||||
|
||||
it('removes a registration when its contributing plugin fiber is disposed', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.commands.register(command('temporary'))
|
||||
}, { inject: ['commands'] }))
|
||||
expect(ctx.commands.find(agent, 'temporary')).toBeDefined()
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
expect(ctx.commands.find(agent, 'temporary')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope } = await mintAgentScope(ctx, 'a')
|
||||
|
||||
@@ -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()`. That choice uses the status after optional asynchronous preparation: `send()` dispatches `agent/prompt-submit`, while in-turn `steer()` joins at a steering checkpoint without that hook. When optional `ctx.sessionReferences` is mounted, the existing `@` file menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares its snapshot before dispatch. Preparation disables duplicate submit; failure restores the editor input. 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()`. That choice uses the status after optional asynchronous preparation: `send()` dispatches `agent/prompt-submit`, while in-turn `steer()` joins at a steering checkpoint without that hook. When optional `ctx.sessionReferences` is mounted, the existing `@` file menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares its snapshot before dispatch. Preparation disables duplicate submit; failure restores the editor input. 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. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. 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. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. 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
|
||||
|
||||
|
||||
@@ -963,9 +963,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'))
|
||||
|
||||
@@ -104,10 +104,10 @@ async function tick(): Promise<void> {
|
||||
async function setup(options: TuiHarnessOptions = {}) {
|
||||
const terminal = new FakeTerminal()
|
||||
const exit = vi.fn()
|
||||
const result = await createTuiTestHarness(terminal, exit, {
|
||||
...options,
|
||||
cwd: options.cwd === undefined ? process.cwd() : options.cwd,
|
||||
})
|
||||
// Let the harness default cwd ('/workspace') stand: a checkout-dependent
|
||||
// process.cwd() longer than the 88-column fake terminal pushes the footer
|
||||
// token counters off-screen and fails their assertions by location.
|
||||
const result = await createTuiTestHarness(terminal, exit, options)
|
||||
await tick()
|
||||
return result
|
||||
}
|
||||
@@ -322,7 +322,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
{ inputTokens: 500, outputTokens: 8 },
|
||||
{ turn: 3, step: 1 },
|
||||
)
|
||||
await tick()
|
||||
await vi.waitFor(() => {
|
||||
expect(result.terminal.output).toContain('final live answer')
|
||||
})
|
||||
|
||||
expect(result.terminal.output).toContain('◒ Working · 8s')
|
||||
expect(result.terminal.output).toContain('esc interrupt')
|
||||
@@ -330,7 +332,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
expect(result.terminal.output).toContain('user context')
|
||||
expect(result.terminal.output).toContain('Prompt blocked')
|
||||
expect(result.terminal.output).toContain('Turn cancelled')
|
||||
expect(result.terminal.output).toContain('final live answer')
|
||||
expect(result.terminal.progress).toContain(true)
|
||||
|
||||
result.session.append('assistant/chunk', {
|
||||
@@ -1464,12 +1465,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')
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
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 { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
@@ -69,26 +68,11 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'>
|
||||
import { ApprovalRequestId } from './types.ts'
|
||||
import type { ApprovalOutcome } from './types.ts'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn
|
||||
* request, or unavailable answerer. Callers fail closed on `unavailable`.
|
||||
*/
|
||||
export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
||||
export { ApprovalRequestId } from './types.ts'
|
||||
export type { ApprovalOutcome } from './types.ts'
|
||||
|
||||
/** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */
|
||||
const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cancelled', 'unavailable']
|
||||
|
||||
29
packages/ui/user-approval/src/types.ts
Normal file
29
packages/ui/user-approval/src/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Wire-safe approval identifiers and outcome vocabulary, free of
|
||||
* cordis/service imports so browser type chains (apiproxy api → client) can
|
||||
* consume them without loading this package's Context augmentation.
|
||||
* @module @deepseek-ai/dsh-user-approval/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn
|
||||
* request, or unavailable answerer. Callers fail closed on `unavailable`.
|
||||
*/
|
||||
export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
||||
30
packages/ui/user-approval/tsdown.config.ts
Normal file
30
packages/ui/user-approval/tsdown.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* Build index and the invariant companion as separate single-entry bundles.
|
||||
* Both entries import src/types.ts (the browser-safe subpath), so a
|
||||
* multi-entry build emits a shared chunk the package's exact `files`
|
||||
* whitelist omits; separate builds inline it.
|
||||
*/
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
@@ -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)`.
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
|
||||
@@ -17,27 +17,11 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** One selectable answer offered to the user. */
|
||||
export interface AskUserQuestionOption {
|
||||
/** User-facing label. */
|
||||
label: string
|
||||
/** Optional extra context rendered by capable UIs. */
|
||||
description?: string
|
||||
}
|
||||
import type { AskUserQuestionAnswer, AskUserQuestionItem } from './types.ts'
|
||||
|
||||
/** 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
|
||||
}
|
||||
export type {
|
||||
AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionOption,
|
||||
} from './types.ts'
|
||||
|
||||
/** Request for a human answer. */
|
||||
export interface AskUserQuestionRequest {
|
||||
@@ -49,22 +33,6 @@ export interface AskUserQuestionRequest {
|
||||
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>
|
||||
|
||||
46
packages/ui/user-interaction/src/types.ts
Normal file
46
packages/ui/user-interaction/src/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Wire-safe question/answer shapes, free of cordis/service imports so browser
|
||||
* type chains (apiproxy api → client) can consume them without loading this
|
||||
* package's Context augmentation.
|
||||
* @module @deepseek-ai/dsh-user-interaction/types
|
||||
*/
|
||||
|
||||
/** 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 a user-interaction request. */
|
||||
export interface AskUserQuestionItem {
|
||||
/** 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. */
|
||||
options?: AskUserQuestionOption[]
|
||||
/** Whether more than one option may be selected. Defaults to single-select. */
|
||||
multiSelect?: boolean
|
||||
}
|
||||
|
||||
/** 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[]
|
||||
}
|
||||
Reference in New Issue
Block a user