Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output

# Conflicts:
#	docs/config-catalog.md
#	docs/persistence-catalog.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/tests/contract-regressions.spec.ts
#	packages/ui/acp/src/index.ts
#	packages/ui/tui/src/index.ts
This commit is contained in:
Tianyi Cui
2026-07-22 23:50:41 +08:00
159 changed files with 3918 additions and 362 deletions

View File

@@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the terminal `
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
The plugin injects `agents`, [`commands`](../commands/README.md), `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; the command registry backs slash discovery and direct dispatch; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms.
The plugin injects `agents`, [`commands`](../commands/README.md), `sessionPersistence`, `sessionQuery`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; live-preferred session queries back `session/list`; the command registry backs slash discovery and direct dispatch; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms.
### Config
@@ -25,10 +25,11 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
| ACP method | Harness seam | Notes |
|---|---|---|
| `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` |
| `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text), `loadSession: true`, and `sessionCapabilities.list` |
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, tool, and title events, and re-advertises commands |
| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; unsupported content and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC |
| `session/list` | `ctx.sessionQuery` | returns live-preferred newest-first sessions with absolute cwd and optional folded title; supports exact normalized cwd filtering, returns no cursor, and rejects supplied cursors |
| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC |
| `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another |
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, tool render intents, and `session_info_update` title revisions |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
@@ -37,7 +38,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
## Multi-session
One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session Agent Note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md).
One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt or reference-preparation operation; `session/cancel` aborts preparation before it can enqueue. Teardown drains all sessions in parallel. See the [multi-session Agent Note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md).
## Human commands
@@ -57,6 +58,8 @@ ACP updates are append-only, so `llm/retry` emits a visible separator that marks
A log-only `session/title` event maps to ACP `session_info_update` with `title` and the event timestamp as `updatedAt`. The same mapping runs for live events and `session/load` replay, so an asynchronously generated late title and a restored persisted title have one wire representation without entering model history.
`session/list` returns the same latest folded title in standard `SessionInfo.title`. When `ctx.sessionReferences` is mounted, each listed item also carries `_meta["deepseek-harness/sessionReference"].uri`; a title-aware client can render `title ?? sessionId` in its `@` picker and submit that URI as a `resource_link` with the same display name. Sessions without cwd are omitted because ACP requires an absolute `SessionInfo.cwd` and the bridge cannot load them.
## Per-session cwd
`session/new` records the request's absolute cwd in the session header. Before constructing an agent, `session/load` uses persisted metadata to require an absolute request cwd that matches the stored one. Bash defaults to that workspace; an explicit relative workdir resolves against it, and multiple sessions may use different workspaces. `additionalDirectories` remains unsupported.
@@ -106,7 +109,7 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa
#### What the model sees
Each ACP `session/prompt` becomes an agent user message: text passes through verbatim and each `resource_link` becomes exactly a leading newline, `[resource_link name=<JSON-string> uri=<JSON-string>]`, and a trailing newline. Unsupported image, audio, and embedded-resource blocks are rejected rather than silently omitted.
Each ACP `session/prompt` becomes an agent user message: text passes through verbatim and each ordinary `resource_link` becomes exactly a leading newline, `[resource_link name=<JSON-string> uri=<JSON-string>]`, and a trailing newline. When `ctx.sessionReferences` is mounted, a `resource_link` whose URI uses `dsh-session:` or an inline canonical mention becomes readable `@label` text plus one durable untrusted snapshot context; without the capability it is rejected. Unsupported image, audio, and embedded-resource blocks are rejected rather than silently omitted.
#### Token effect
@@ -190,6 +193,7 @@ Loading does not rewrite the stored log, but the next request is reconstructed u
- **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented.
- **Prompt content is `text` + `resource_link` only** — image, audio, and embedded-resource blocks are rejected, as is a non-empty `mcpServers` list at `session/new`.
- **Session picker UI is client-owned** — `session/list` supplies standard title metadata and, when references are available, a canonical URI extension; an ACP client must consume those fields to add an `@` picker. Title/body search remains future metadata or FTS work.
- **Terminal cards render completed output** — live incremental streaming and command classification are named follow-ups of [the terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
- **Permission answers are one-shot only** — the bridge offers `allow_once` / `reject_once`; durable `allow_always` grants and their storage/revocation policy remain deferred to the approval seam.
- **Command output is live-only** — discovery is refreshed after load, but direct command results are not persisted or replayed into a reconnected editor.

View File

@@ -10,13 +10,13 @@ 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, 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).
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)
| Method | Stable | Bridge | Claude | Codex | Notes |
|---|---|---|---|---|---|
| `initialize` | S | ✅ | ✅ | ✅ | Negotiates `PROTOCOL_VERSION`; advertises `loadSession` + baseline prompt caps. Snapshots the Zed `_meta.terminal_output` client cap. |
| `initialize` | S | ✅ | ✅ | ✅ | Negotiates `PROTOCOL_VERSION`; advertises `loadSession`, `sessionCapabilities.list`, and baseline prompt caps. Snapshots the Zed `_meta.terminal_output` client cap. |
| `authenticate` | S | ⚠️ | ✅ | ✅ | No-op stub; the bridge advertises no `authMethods`, so there is nothing to authenticate. |
| `logout` | S | ❌ | ✅ | ✅ | Gated by `agentCapabilities.auth.logout`; not advertised. |
| `session/new` | S | ✅ | ✅ | ✅ | Maps to `agents.create`; requires an absolute `cwd` (becomes the session workspace); rejects non-empty `additionalDirectories` / `mcpServers`. |
@@ -28,7 +28,7 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
| `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. |
| `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. |
| `session/delete` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.delete`. |
| `session/fork` | U | ❌ | ✅ | ❌ | Claude ships `unstable_forkSession`; Codex does not. |
@@ -60,7 +60,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `promptCapabilities.audio` | S | ❌ | ❌ | ❌ | `audio: false`; neither adapter accepts audio either. |
| `promptCapabilities.embeddedContext` | S | ❌ | ✅ | ✅ | `embeddedContext: false`; embedded `resource` blocks rejected. |
| `mcpCapabilities.{http,sse}` | S | ❌ | ✅ | ⚠️ | No MCP passthrough; `mcpServers` is rejected. Claude advertises http+sse, Codex http only. |
| `sessionCapabilities.*` | S | ❌ | ✅ | ✅ | None advertised (list/delete/resume/close/additionalDirectories/fork all off). |
| `sessionCapabilities.*` | S | ⚠️ | ✅ | ✅ | `list` is advertised; delete/resume/close/additionalDirectories/fork remain off. |
| `auth.logout` | S | ❌ | ✅ | ✅ | Not advertised. |
| `authMethods[]` | S | ⚠️ | ✅ | ✅ | Advertised as empty (no auth required to reach the model). |
| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | Fixed literals: `deepseek-harness-acp` / `0.0.1` (not config). |
@@ -88,7 +88,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `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. |
| `session_info_update` | S | ✅ | ⚠️ | ⚠️ | Log-backed title events push title and event time; load replay uses the same mapping. |
## 5. Tool-call rendering
@@ -132,7 +132,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
| `StopReason` mapping | S | ✅ | `turnEndToStopReason` is total over harness turn-end reasons → `end_turn`/`max_tokens`/`cancelled`. |
| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session Agent Note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md). |
| Disconnect / disposal teardown | S | ✅ | Quiesces every live session on client disconnect or Cordis disposal. |
| `_meta` extensibility | S | ⚠️ | Consumed (Zed terminal cap) and emitted (terminal `_meta`); no other custom extensions. |
| `_meta` extensibility | S | ⚠️ | Consumed for the Zed terminal cap and emitted for terminal cards. Listed sessions add `deepseek-harness/sessionReference` with a canonical URI when cross-session references are mounted. |
| Background-task ownership isolation | — | ✅ | Generic `task_output`/`task_kill` reject tasks whose branded owner `SessionId` belongs to another session. |
| stdout-is-the-protocol guarantee | S | ✅ | The bridge runs in an example with no stdout logger. |
@@ -140,7 +140,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
Ranked by how commonly the reference adapters ship them and how much UX they unlock:
1. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
1. **Session lifecycle** — `session/delete`, then `session/resume` / `session/close`.
2. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
3. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
4. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).

View File

@@ -42,6 +42,8 @@
"@deepseek-ai/dsh-permission": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
@@ -66,6 +68,8 @@
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",

View File

@@ -5,6 +5,12 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import {
SESSION_REFERENCE_SCHEME,
decodeSessionReferenceUri,
parseSessionReferenceText,
type SessionReferenceInput,
} from '@deepseek-ai/dsh-session-reference'
import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk'
/**
@@ -81,6 +87,45 @@ export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
.join('')
}
/** ACP prompt text plus structured session references extracted from text and resource links. */
export interface AcpReferencedPrompt {
/** Readable prompt text with opaque session URIs removed. */
text: string
/** Structured session references in ACP block and inline appearance order. */
references: SessionReferenceInput[]
}
/**
* Extract canonical session references while preserving ordinary ACP resource links.
* @param prompt - already-supported ACP prompt blocks.
* @returns readable text and structured references.
* @throws when any observed `dsh-session:` URI is malformed.
*/
export function acpPromptToReferencedPrompt(prompt: readonly AcpContentBlock[]): AcpReferencedPrompt {
const references: SessionReferenceInput[] = []
const text = prompt.flatMap((block): string[] => {
switch (block.type) {
case 'text': {
const parsed = parseSessionReferenceText(block.text)
references.push(...parsed.references)
return [parsed.text]
}
case 'resource_link': {
if (!block.uri.startsWith(SESSION_REFERENCE_SCHEME)) {
return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`]
}
const sessionId = decodeSessionReferenceUri(block.uri)
const label = block.name === '' ? sessionId : block.name
references.push({ sessionId, label })
return [`@${label}`]
}
default:
return []
}
}).join('')
return { text, references }
}
/**
* Whether an ACP prompt contains content the bridge cannot accept. Baseline ACP
* requires `text` and `resource_link`; richer inline payloads (`resource`,

View File

@@ -27,6 +27,8 @@ import {
type EnumOption,
type InitializeRequest,
type InitializeResponse,
type ListSessionsRequest,
type ListSessionsResponse,
type LoadSessionRequest,
type LoadSessionResponse,
type NewSessionRequest,
@@ -57,8 +59,8 @@ import {
type AgentLlmTargetRef as LlmTargetRef,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-commands'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
import { displayPromptContent, SessionId, type JsonValue } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
import type {} from '@deepseek-ai/dsh-permission'
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -68,6 +70,9 @@ import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } f
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
import type {} from '@deepseek-ai/dsh-session-persistence'
// Side-effect type import: declaration-merges 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'
@@ -87,6 +92,7 @@ import {
} from '@deepseek-ai/dsh-user-interaction'
import {
acpPromptToText,
acpPromptToReferencedPrompt,
harnessBlockToAcpContent,
promptHasUnsupportedContent,
turnEndToStopReason,
@@ -94,7 +100,10 @@ import {
export const name = 'acp'
// Interface services back loading, presentation, interaction, and prompt assembly.
export const inject = ['agents', 'commands', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt']
export const inject = ['agents', 'commands', 'sessionPersistence', 'sessionQuery', 'tools', 'userInteraction', 'llm', 'systemPrompt']
/** ACP `SessionInfo._meta` key carrying a ready-to-submit session-reference URI. */
export const ACP_SESSION_REFERENCE_META_KEY = 'deepseek-harness/sessionReference'
/** Preserve invalid-parameter detail in the SDK wire error message. */
function invalidParams(detail: string): RequestError {
@@ -325,6 +334,8 @@ interface SessionRecord {
} | undefined
/** Abort owner for a direct slash-command request, mutually exclusive with `inflight`. */
commandAbort: AbortController | undefined
/** Abort owner while referenced sessions are snapshotted before enqueue. */
promptPreparation: AbortController | undefined
/** Last idle switch per knob, anchored before the next prompt assembles. */
pendingSwitches: { preset?: string }
}
@@ -748,6 +759,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
agentCapabilities: {
loadSession: true,
sessionCapabilities: { list: {} },
// Baseline prompt blocks only: text plus resource_link rendered as
// text. No image/audio/embeddedContext, no mcpCapabilities.
promptCapabilities: { image: false, audio: false, embeddedContext: false },
@@ -762,6 +774,41 @@ export function apply(ctx: Context, config: AcpConfig): void {
return Promise.resolve()
},
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
assertOpen()
if (params.cursor !== undefined && params.cursor !== null) {
throw invalidParams('session/list does not paginate; omit cursor')
}
if (params.cwd !== undefined && params.cwd !== null && !isAbsolute(params.cwd)) {
throw invalidParams('session/list cwd must be absolute')
}
const records = (await ctx.sessionQuery.listSessions()).flatMap((record) => {
const cwd = record.header.cwd
if (cwd === undefined) return []
if (params.cwd !== undefined && params.cwd !== null && !sameWorkspaceCwd(cwd, params.cwd)) return []
return [{ record, cwd }]
})
const titles = await Promise.all(records.map(({ record }) => ctx.sessionQuery.readTitle(record.header.id)))
assertOpen()
const referencesAvailable = ctx.get('sessionReferences') !== undefined
return {
sessions: records.map(({ record, cwd }, index) => ({
sessionId: record.header.id,
cwd,
...titles[index] === undefined ? {} : { title: titles[index].title },
...referencesAvailable
? {
_meta: {
[ACP_SESSION_REFERENCE_META_KEY]: {
uri: encodeSessionReferenceUri(record.header.id),
},
},
}
: {},
})),
}
},
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
assertOpen()
validateWorkspaceParams(params)
@@ -794,6 +841,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
target,
inflight: undefined,
commandAbort: undefined,
promptPreparation: undefined,
pendingSwitches: {},
}
sessions.set(sessionId, record)
@@ -885,6 +933,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
target,
inflight: undefined,
commandAbort: undefined,
promptPreparation: undefined,
pendingSwitches: {},
}
sessions.set(sessionId, record)
@@ -941,23 +990,22 @@ export function apply(ctx: Context, config: AcpConfig): void {
async prompt(params: PromptRequest): Promise<PromptResponse> {
assertOpen()
const rec = requireSession(SessionId(params.sessionId))
if (rec.inflight !== undefined || rec.commandAbort !== undefined) {
if (rec.inflight !== undefined || rec.commandAbort !== undefined || rec.promptPreparation !== undefined) {
throw invalidParams('a prompt is already in flight for this session')
}
if (promptHasUnsupportedContent(params.prompt)) {
throw invalidParams('only text and resource_link prompt content is supported; image/audio/embedded resource blocks are rejected rather than silently dropped')
}
const text = acpPromptToText(params.prompt)
if (text.trim().length === 0) {
const flattenedText = acpPromptToText(params.prompt)
if (flattenedText.trim().length === 0) {
// Reject up front rather than calling send(): an empty prompt would
// queue no work, no turn would start, and the RPC would hang forever
// waiting for a settle that never comes.
throw invalidParams('empty prompt')
}
// ACP command prompts may carry additional supported content blocks.
// The same lossless flattening used for model prompts supplies their
// unstructured command input; unsupported kinds were rejected above.
const commandLine = text.startsWith('/') ? text : undefined
// Direct commands consume ordinary ACP flattening before reference
// extraction, so URI-shaped arguments remain opaque to the bridge.
const commandLine = flattenedText.startsWith('/') ? flattenedText : undefined
if (commandLine !== undefined) {
const controller = new AbortController()
rec.commandAbort = controller
@@ -1000,6 +1048,39 @@ export function apply(ctx: Context, config: AcpConfig): void {
rec.commandAbort = undefined
}
}
let referencedPrompt: ReturnType<typeof acpPromptToReferencedPrompt>
try {
referencedPrompt = acpPromptToReferencedPrompt(params.prompt)
} catch (error: unknown) {
throw invalidParams(`invalid session reference: ${renderThrown(error)}`)
}
const { text } = referencedPrompt
let preparedContent: ContentBlock[] = [{ type: 'text', text }]
let preparedContexts: NonNullable<Parameters<Agent['send']>[1]>['contexts'] = []
if (referencedPrompt.references.length > 0) {
const sessionReferences = ctx.get('sessionReferences')
if (sessionReferences === undefined) {
throw invalidParams('session reference capability unavailable')
}
const controller = new AbortController()
rec.promptPreparation = controller
try {
const prepared = await sessionReferences.prepare(
rec.agent,
preparedContent,
referencedPrompt.references,
controller.signal,
)
preparedContent = prepared.content
preparedContexts = prepared.contexts
} catch (error: unknown) {
if (controller.signal.aborted) return { stopReason: 'cancelled' }
throw invalidParams(`session reference preparation failed: ${renderThrown(error)}`)
} finally {
rec.promptPreparation = undefined
}
assertOpen()
}
// Install the in-flight slot BEFORE send() (send does not synchronously
// flip status to running; the session/event listener records the turn
// number and settle/rejects it). Capture the log length now as the
@@ -1007,7 +1088,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
// produces an error stop reason).
const stopReason = await new Promise<StopReason>((resolve, reject) => {
rec.inflight = { resolve, reject, turn: undefined }
rec.agent.send([{ type: 'text', text }])
rec.agent.send(preparedContent, { contexts: preparedContexts })
})
return { stopReason }
},
@@ -1027,7 +1108,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
// settle it, because cancel() may drop the turn before any turn/end is
// emitted, and removing this direct settle would move the RPC's
// resolution onto a later observer path, changing its timing.
if (rec.commandAbort !== undefined) {
if (rec.promptPreparation !== undefined) {
rec.promptPreparation.abort(new Error('session/cancel'))
} else if (rec.commandAbort !== undefined) {
rec.commandAbort.abort(new Error('session/cancel'))
} else {
rec.agent.cancel({ kind: 'user' })
@@ -1148,6 +1231,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
await Promise.all(recs.map(async (rec) => {
settlePrompt(rec, 'cancelled')
rec.commandAbort?.abort(new Error('ACP connection closed'))
rec.promptPreparation?.abort(new Error('ACP connection closed'))
// Per-agent dispose (the AgentHandle disposer): unregister this agent,
// stop its loop (sets disposed + aborts the in-flight step), await
// quiescence (the loop exit + final flush), and remove its session — so
@@ -1293,7 +1377,7 @@ export function streamSessionEventUpdate(
// Replay the user's prompt so a loaded session shows both sides of each
// turn. Live prompt turns suppress this path to avoid duplicating what
// the client just sent.
for (const block of event.data.content) {
for (const block of displayPromptContent(event.data)) {
const content = harnessBlockToAcpContent(block)
if (content !== undefined) {
notify({ sessionId, update: { sessionUpdate: 'user_message_chunk', content } })

View File

@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } 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 { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness } from './harness.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
import { encodeSessionReferenceUri, formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
/**
* End-to-end bridge specs over an in-memory transport: a real
@@ -329,6 +330,102 @@ describe('acp bridge', () => {
expect(JSON.stringify(user)).toContain('resource_link')
})
it('rejects canonical session references when the optional capability is not mounted', async () => {
harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.prompt({
sessionId,
prompt: [{ type: 'resource_link', uri: encodeSessionReferenceUri(SessionId('source')), name: 'source' }],
})).rejects.toThrow(/session reference capability unavailable/)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('reports malformed inline session references at the ACP request boundary', async () => {
harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.prompt({
sessionId,
prompt: [{ type: 'text', text: 'use dsh-session:IiJ' }],
})).rejects.toThrow(/invalid session reference/)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('prepares ACP session resource links and inline mentions before one atomic send', async () => {
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [textResponse('ok')] })
const source = harness.ctx.sessions.create(SessionId('source'), { meta: { cwd: '/source' } })
source.append('user/message', {
content: [{ type: 'text', text: 'source background' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const mention = formatSessionReferenceMention({ sessionId: source.id, label: 'source-inline' })
const result = await harness.client.prompt({
sessionId,
prompt: [
{ type: 'text', text: `use ${mention} and ` },
{ type: 'resource_link', uri: encodeSessionReferenceUri(source.id), name: 'source-link' },
],
})
expect(result.stopReason).toBe('end_turn')
const target = harness.ctx.agents.get(SessionId(sessionId))!.session
const user = target.events.find(event => event.type === 'user/message')
expect(user?.type === 'user/message' && user.data.envelope).toMatchObject({
displayContent: [{ type: 'text', text: 'use @source-inline and @source-link' }],
prefixContexts: [{
source: { kind: 'plugin', plugin: 'session-reference' },
meta: {
kind: 'session-reference',
references: [{ sessionId: 'source', label: 'source-inline' }],
},
}],
})
expect(target.events.some(event => event.type === 'context/message')).toBe(false)
const request = JSON.stringify(harness.adapter.requests[0]?.messages)
expect(request).toContain('untrusted, read-only snapshot')
expect(request).toContain('source background')
expect(request.indexOf('source background')).toBeLessThan(request.indexOf('## My request:'))
expect(request.indexOf('## My request:')).toBeLessThan(request.indexOf('use @source-inline and @source-link'))
})
it('rejects a failed referenced-session read before starting a turn', async () => {
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.prompt({
sessionId,
prompt: [{ type: 'resource_link', uri: encodeSessionReferenceUri(SessionId('missing')), name: 'missing' }],
})).rejects.toThrow(/preparation failed/)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('cancels reference preparation before a turn is created', async () => {
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [] })
const source = harness.ctx.sessions.create(SessionId('source'))
const snapshot = await harness.ctx.sessionQuery.readSurface(source.id)
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
let releaseRead: (() => void) | undefined
const readSurface = vi.spyOn(harness.ctx.sessionQuery, 'readSurface').mockImplementationOnce(async () => {
await new Promise<void>((resolve) => { releaseRead = resolve })
return snapshot
})
const pending = harness.client.prompt({
sessionId,
prompt: [{ type: 'resource_link', uri: encodeSessionReferenceUri(source.id), name: 'source' }],
})
await vi.waitFor(() => { expect(releaseRead).toBeTypeOf('function') })
await harness.client.cancel({ sessionId })
await expect(pending).resolves.toEqual({ stopReason: 'cancelled' })
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
releaseRead?.()
await Promise.resolve()
readSurface.mockRestore()
})
it('rejects a prompt for an unknown session', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })

View File

@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import { encodeSessionReferenceUri, formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk'
import {
acpPromptToReferencedPrompt,
acpPromptToText,
harnessBlockToAcpContent,
promptHasUnsupportedContent,
@@ -55,6 +58,35 @@ describe('acpPromptToText', () => {
})
})
describe('acpPromptToReferencedPrompt', () => {
it('extracts resource links and inline mentions while preserving ordinary links', () => {
const sessionId = SessionId('source/会话')
const prompt: AcpContentBlock[] = [
{ type: 'text', text: `compare ${formatSessionReferenceMention({ sessionId, label: 'inline' })} with ` },
{ type: 'resource_link', uri: encodeSessionReferenceUri(sessionId), name: 'linked' },
{ type: 'resource_link', uri: 'file:///x', name: 'x' },
]
expect(acpPromptToReferencedPrompt(prompt)).toEqual({
text: 'compare @inline with @linked\n[resource_link name="x" uri="file:///x"]\n',
references: [{ sessionId, label: 'inline' }, { sessionId, label: 'linked' }],
})
})
it('rejects malformed session resource links', () => {
expect(() => acpPromptToReferencedPrompt([
{ type: 'resource_link', uri: 'dsh-session:%%%', name: 'bad' },
])).toThrow(/invalid session reference URI/)
})
it('uses the decoded id for an empty resource name and ignores unsupported direct inputs', () => {
const sessionId = SessionId('source')
expect(acpPromptToReferencedPrompt([
{ type: 'resource_link', uri: encodeSessionReferenceUri(sessionId), name: '' },
{ type: 'image', mimeType: 'image/png', data: 'AA==' },
])).toEqual({ text: '@source', references: [{ sessionId, label: 'source' }] })
})
})
describe('promptHasUnsupportedContent', () => {
it('detects image, audio, and embedded resource blocks', () => {
expect(promptHasUnsupportedContent([{ type: 'image', mimeType: 'image/png', data: 'AA==' }])).toBe(true)

View File

@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
function commandUpdates(harness: BridgeHarness, sessionId: string) {
@@ -195,6 +196,28 @@ describe('ACP plugin commands', () => {
expect(harness.adapter.requests).toHaveLength(0)
})
it('keeps session-reference syntax opaque in direct command arguments', async () => {
harness = await makeBridgeHarness({ storageDir })
const command = vi.fn(() => ({ kind: 'success' as const }))
harness.ctx.commands.register({ name: 'direct', description: 'Direct', handler: command })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const sourceUri = encodeSessionReferenceUri(SessionId('source'))
await expect(harness.client.prompt({
sessionId,
prompt: [
{ type: 'text', text: `/direct valid=${sourceUri} malformed=dsh-session:IiJ` },
{ type: 'resource_link', name: 'source', uri: sourceUri },
],
})).resolves.toEqual({ stopReason: 'end_turn' })
expect(command).toHaveBeenCalledWith(expect.objectContaining({
rawInput: ` valid=${sourceUri} malformed=dsh-session:IiJ\n[resource_link name="source" uri=${JSON.stringify(sourceUri)}]\n`,
}))
expect(harness.adapter.requests).toHaveLength(0)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('maps session cancellation to the in-flight command signal and isolates other sessions', async () => {
harness = await makeBridgeHarness({ storageDir })
let started!: () => void

View File

@@ -31,6 +31,8 @@ import {
type Stream,
} from '@agentclientprotocol/sdk'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionQueryService from '@deepseek-ai/dsh-session-query'
import SessionReferenceService from '@deepseek-ai/dsh-session-reference'
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as AcpPlugin from '../src/index.ts'
import { type AcpConfig } from '../src/index.ts'
@@ -192,6 +194,8 @@ export async function makeBridgeHarness(options: {
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
*/
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
/**
@@ -217,6 +221,10 @@ export async function makeBridgeHarness(options: {
await ctx.plugin(CommandService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
await ctx.plugin(SessionQueryService)
if (options.withSessionReferences) {
await ctx.plugin(SessionReferenceService)
}
await ctx.plugin(UserInteractionService)
if (options.withAskUser) {
await ctx.plugin(ToolAskUser)

View File

@@ -0,0 +1,92 @@
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 type {} from '@deepseek-ai/dsh-session-title'
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
import { ACP_SESSION_REFERENCE_META_KEY } from '../src/index.ts'
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
describe('acp bridge — session/list', () => {
let storageDir: string
let harness: BridgeHarness | undefined
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-list-')) })
afterEach(async () => {
await harness?.dispose()
harness = undefined
await rm(storageDir, { recursive: true, force: true })
})
it('advertises title-aware listing and reference metadata for loadable sessions', async () => {
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true })
const initialized = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
const cwd = process.cwd()
const { sessionId } = await harness.client.newSession({ cwd, mcpServers: [] })
const session = harness.ctx.agents.get(SessionId(sessionId))!.session
await harness.ctx.sessions.appendOutOfBand(session, 'session/title', {
title: 'Reference source title',
messageSeqs: [],
source: { kind: 'fallback' },
}, { kind: 'session-title' })
harness.ctx.sessions.create(SessionId('untitled'), { meta: { cwd: join(storageDir, 'other') } })
harness.ctx.sessions.create(SessionId('missing-cwd'))
const listed = await harness.client.listSessions({})
expect(listed.nextCursor).toBeUndefined()
expect(listed.sessions.map(item => item.sessionId)).toEqual(expect.arrayContaining([sessionId, 'untitled']))
expect(listed.sessions.map(item => item.sessionId)).not.toContain('missing-cwd')
const source = listed.sessions.find(item => item.sessionId === sessionId)
expect(source).toMatchObject({ cwd, title: 'Reference source title' })
expect(source?._meta?.[ACP_SESSION_REFERENCE_META_KEY]).toEqual({
uri: encodeSessionReferenceUri(SessionId(sessionId)),
})
expect(listed.sessions.find(item => item.sessionId === 'untitled')).not.toHaveProperty('title')
})
it('filters by normalized cwd and omits reference metadata without the optional capability', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const firstCwd = join(storageDir, 'first')
const secondCwd = join(storageDir, 'second')
const first = await harness.client.newSession({ cwd: firstCwd, mcpServers: [] })
await harness.client.newSession({ cwd: secondCwd, mcpServers: [] })
const listed = await harness.client.listSessions({ cursor: null, cwd: firstCwd })
expect(listed.sessions).toHaveLength(1)
expect(listed.sessions[0]).toMatchObject({ sessionId: first.sessionId, cwd: firstCwd })
expect(listed.sessions[0]?._meta).toBeUndefined()
await expect(harness.client.listSessions({ cwd: null })).resolves.toHaveProperty('sessions')
})
it('rejects unsupported cursors and relative cwd filters', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await expect(harness.client.listSessions({ cursor: 'next' })).rejects.toThrow('session/list does not paginate')
await expect(harness.client.listSessions({ cwd: 'relative' })).rejects.toThrow('session/list cwd must be absolute')
})
it('folds titles from persisted sessions in a fresh bridge', async () => {
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const cwd = process.cwd()
const { sessionId } = await harness.client.newSession({ cwd, mcpServers: [] })
const session = harness.ctx.agents.get(SessionId(sessionId))!.session
await harness.ctx.sessions.appendOutOfBand(session, 'session/title', {
title: 'Persisted reference title',
messageSeqs: [],
source: { kind: 'fallback' },
}, { kind: 'session-title' })
await harness.dispose()
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await expect(harness.client.listSessions({ cwd })).resolves.toMatchObject({
sessions: [{ sessionId, cwd, title: 'Persisted reference title' }],
})
})
})

View File

@@ -209,6 +209,24 @@ describe('streamSessionEventUpdate', () => {
expect(updatesFor(evt('user/message', { content: [], source: { kind: 'user' } }))).toEqual([])
})
it('replays only the direct prompt from a prefixed user message', () => {
expect(updatesFor(evt('user/message', {
content: [
{ type: 'text', text: 'internal prefix' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'visible request' },
],
source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'visible request' }],
prefixContexts: [{ source: { kind: 'plugin', plugin: 'reference' } }],
},
}))).toEqual([{
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'visible request' },
}])
})
it('can suppress user/message chunks for live prompt turns', () => {
expect(liveUpdatesFor(evt('user/message', {
content: [{ type: 'text', text: 'hi' }],

View File

@@ -26,6 +26,12 @@
{
"path": "../../core/session"
},
{
"path": "../../context/session-reference"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../session-title/session-title"
},