fix(session-reference): bind snapshots to prompts

This commit is contained in:
Yichen Jiang
2026-07-22 17:34:31 +08:00
parent 3fa368854c
commit da8c0ab092
102 changed files with 838 additions and 248 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,9 +25,10 @@ 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/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 |
@@ -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.
@@ -190,7 +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** — the server accepts canonical resource links and inline mentions, but does not add a picker to ACP clients; title/full-text discovery remains future metadata or FTS work.
- **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. 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. 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 | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#6-session-modes--config-options--models)). |
| `session/set_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 | ❌ | ✅ | ✅ | No session modes. |
| `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,7 @@
"@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",

View File

@@ -27,6 +27,8 @@ import {
type EnumOption,
type InitializeRequest,
type InitializeResponse,
type ListSessionsRequest,
type ListSessionsResponse,
type LoadSessionRequest,
type LoadSessionResponse,
type NewSessionRequest,
@@ -54,8 +56,8 @@ import {
type AgentLlmTargetRef as LlmTargetRef,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-commands'
import type {} from '@deepseek-ai/dsh-session-reference'
import { SessionId } from '@deepseek-ai/dsh-session'
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
import { displayPromptContent, SessionId } 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'
@@ -65,6 +67,9 @@ import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } f
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
import type {} from '@deepseek-ai/dsh-session-persistence'
// 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'
// Side-effect type import: declaration-merges prompt assembly onto Context and
// the scoped waterfall used to keep persona variables aligned with requests.
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -89,7 +94,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 {
@@ -694,6 +702,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 },
@@ -708,6 +717,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)
@@ -1247,7 +1291,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

@@ -370,17 +370,22 @@ describe('acp bridge', () => {
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.content).toEqual([
{ type: 'text', text: 'use @source-inline and @source-link' },
])
const context = target.events.find(event => event.type === 'context/message')
expect(context?.type === 'context/message' && context.data.meta).toMatchObject({
kind: 'session-reference',
references: [{ sessionId: 'source', label: 'source-inline' }],
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 () => {

View File

@@ -218,8 +218,8 @@ 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(SessionQueryService)
await ctx.plugin(SessionReferenceService)
}
await ctx.plugin(UserInteractionService)

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

@@ -204,6 +204,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

@@ -29,6 +29,9 @@
{
"path": "../../context/session-reference"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../session-title/session-title"
},

View File

@@ -56,7 +56,7 @@ import type {
TokenUsage,
} from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session'
import { displayPromptContent, SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session'
import {
formatSessionReferenceMention,
parseSessionReferenceText,
@@ -1113,6 +1113,13 @@ function sessionReferenceCard(meta: unknown): string[] | undefined {
return labels
}
function promptReferenceCards(event: Extract<SessionEvent, { type: 'user/message' | 'steering/message' }>): string[][] {
return event.data.envelope?.prefixContexts.flatMap((context) => {
const card = sessionReferenceCard(context.meta)
return card === undefined ? [] : [card]
}) ?? []
}
function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
const ids = new Set<string>()
for (const event of session.events) {
@@ -1372,20 +1379,28 @@ export function createTuiChat(
const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => {
switch (event.type) {
case 'user/message': {
const text = displayText(contentText(event.data.content).trim())
const text = displayText(contentText(displayPromptContent(event.data)).trim())
if (text) {
chat.addChild(new Spacer(1))
chat.addChild(new UserMessageComponent(text, palette, mdTheme))
if (options.addHistory) editor.addToHistory(text)
}
for (const references of promptReferenceCards(event)) {
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
}
break
}
case 'steering/message': {
const text = displayText(contentText(event.data.content).trim())
const text = displayText(contentText(displayPromptContent(event.data)).trim())
if (text) {
chat.addChild(new Spacer(1))
chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering'))
}
for (const references of promptReferenceCards(event)) {
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
}
break
}
case 'context/message': {

View File

@@ -24,9 +24,14 @@ class SnapshotAdapter extends LlmAdapter {
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const prompt = options.messages.at(-1)
if (prompt?.role !== 'user' || prompt.content.length !== 3
|| prompt.content[1]?.type !== 'text' || prompt.content[1].text !== '\n\n## My request:\n') {
throw new Error('session reference did not reach the model as one prefixed user message')
}
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'Snapshot reference accepted.' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Snapshot reference accepted.' } }
yield { type: 'text-delta', index: 0, text: 'Combined reference request accepted.' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Combined reference request accepted.' } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
@@ -108,11 +113,22 @@ describe('TUI session-reference snapshot', () => {
expect(request).toContain('Recent retained question.')
expect(request).not.toContain('SHADOWED OLD USER')
expect(request).not.toContain('SHADOWED OLD ASSISTANT')
const context = target.session.events.find(event => event.type === 'context/message')
expect(context?.type === 'context/message' && context.data.meta).toMatchObject({
kind: 'session-reference',
references: [{ sessionId: 'source-session', compacted: true }],
const user = target.session.events.find(event => event.type === 'user/message')
expect(user?.type === 'user/message' && user.data.envelope).toMatchObject({
displayContent: [{ type: 'text', text: 'Use @Source session' }],
prefixContexts: [{
source: { kind: 'plugin', plugin: 'session-reference' },
meta: {
kind: 'session-reference',
references: [{ sessionId: 'source-session', compacted: true }],
},
}],
})
expect(user?.type === 'user/message' && user.data.content[1]).toEqual({
type: 'text',
text: '\n\n## My request:\n',
})
expect(target.session.events.some(event => event.type === 'context/message')).toBe(false)
const snapshot = await terminal.snapshot({ includeScrollback: true })
if (REFRESHING) {

View File

@@ -36,7 +36,7 @@ buffer
12| <blank>
13| " Assistant "
style 1-9 fg=bright-magenta bold
14| " Snapshot reference accepted. "
14| " Combined reference request accepted. "
15| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
16| " "

View File

@@ -750,6 +750,56 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('Session reference failed')
expect(result.terminal.output).toContain('keep @[')
result.session.append('user/message', {
content: [
{ type: 'text', text: 'hidden baked snapshot payload' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'visible referenced question' },
],
source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'visible referenced question' }],
prefixContexts: [{
source: { kind: 'plugin', plugin: 'session-reference' },
meta: {
kind: 'session-reference',
references: [{ sessionId: 'prefixed', label: 'Prefixed source' }],
},
}],
},
}, { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).toContain('visible referenced question')
expect(result.terminal.output).toContain('Referenced sessions · Prefixed source (prefixed)')
expect(result.terminal.output).not.toContain('hidden baked snapshot payload')
result.session.append('steering/message', {
turn: 1,
content: [
{ type: 'text', text: 'hidden non-reference prefix' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'visible steering prompt' },
],
source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'visible steering prompt' }],
prefixContexts: [
{ source: { kind: 'plugin', plugin: 'other' }, meta: { kind: 'other' } },
{
source: { kind: 'plugin', plugin: 'session-reference' },
meta: {
kind: 'session-reference',
references: [{ sessionId: 'steering-source', label: 'Steering source' }],
},
},
],
},
}, { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).toContain('visible steering prompt')
expect(result.terminal.output).toContain('Referenced sessions · Steering source (steering-source)')
expect(result.terminal.output).not.toContain('hidden non-reference prefix')
result.session.append('context/message', {
content: [{ type: 'text', text: 'secret full snapshot payload' }],
source: { kind: 'plugin', plugin: 'session-reference' },