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:
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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: {} })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
92
packages/ui/acp/tests/session-list.spec.ts
Normal file
92
packages/ui/acp/tests/session-list.spec.ts
Normal 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' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' }],
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../context/session-reference"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user