feat(session): add cross-session references

This commit is contained in:
Yichen Jiang
2026-07-21 16:46:48 +08:00
parent 9a5c81f9e5
commit 32d786c439
81 changed files with 2837 additions and 160 deletions

View File

@@ -28,7 +28,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
| `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` |
| `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, and tool 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/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, and tool render intents |
| `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 +37,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
@@ -104,7 +104,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
@@ -188,6 +188,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.
- **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

@@ -35,6 +35,7 @@
"@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-persistence": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
@@ -57,6 +58,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-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "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

@@ -49,6 +49,7 @@ import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Agent } 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'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
import type {} from '@deepseek-ai/dsh-permission'
@@ -72,7 +73,7 @@ import {
type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import {
acpPromptToText,
acpPromptToReferencedPrompt,
harnessBlockToAcpContent,
promptHasUnsupportedContent,
turnEndToStopReason,
@@ -302,6 +303,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 }
}
@@ -765,6 +768,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
target,
inflight: undefined,
commandAbort: undefined,
promptPreparation: undefined,
pendingSwitches: {},
}
sessions.set(sessionId, record)
@@ -850,6 +854,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
target,
inflight: undefined,
commandAbort: undefined,
promptPreparation: undefined,
pendingSwitches: {},
}
sessions.set(sessionId, record)
@@ -885,13 +890,19 @@ 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)
let referencedPrompt: ReturnType<typeof acpPromptToReferencedPrompt>
try {
referencedPrompt = acpPromptToReferencedPrompt(params.prompt)
} catch (error: unknown) {
throw invalidParams(`invalid session reference: ${renderThrown(error)}`)
}
const { text } = referencedPrompt
if (text.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
@@ -944,6 +955,32 @@ export function apply(ctx: Context, config: AcpConfig): void {
rec.commandAbort = undefined
}
}
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
@@ -951,7 +988,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 }
},
@@ -971,7 +1008,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('session/cancel')
@@ -1092,6 +1131,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

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
@@ -326,6 +327,97 @@ 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.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' }],
})
const request = JSON.stringify(harness.adapter.requests[0]?.messages)
expect(request).toContain('untrusted, read-only snapshot')
expect(request).toContain('source background')
})
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'))
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const prepare = vi.spyOn(harness.ctx.sessionReferences, 'prepare').mockImplementation(
(_agent, _content, _references, signal) => new Promise((_resolve, reject) => {
if (signal?.aborted === true) {
reject(new Error('already aborted'))
return
}
signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}),
)
const pending = harness.client.prompt({
sessionId,
prompt: [{ type: 'resource_link', uri: encodeSessionReferenceUri(source.id), name: 'source' }],
})
await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() })
await harness.client.cancel({ sessionId })
await expect(pending).resolves.toEqual({ stopReason: 'cancelled' })
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
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

@@ -30,6 +30,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'
@@ -191,6 +193,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 filesystem stack (`dsh-fs-local` + `dsh-fs-policy` +
* `dsh-tool-fs`) so a test can drive `read`/`write`/`edit` through the bridge
@@ -214,6 +218,10 @@ export async function makeBridgeHarness(options: {
await ctx.plugin(CommandService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
if (options.withSessionReferences) {
await ctx.plugin(SessionQueryService)
await ctx.plugin(SessionReferenceService)
}
await ctx.plugin(UserInteractionService)
if (options.withAskUser) {
await ctx.plugin(ToolAskUser)

View File

@@ -26,6 +26,9 @@
{
"path": "../../core/session"
},
{
"path": "../../context/session-reference"
},
{
"path": "../../core/agent"
},

View File

@@ -12,7 +12,7 @@ The TUI rebuilds resumed history from the active session surface, renders Markdo
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. That choice uses the status after optional asynchronous preparation: `send()` dispatches `agent/prompt-submit`, while in-turn `steer()` joins at a steering checkpoint without that hook. When optional `ctx.sessionReferences` is mounted, the existing `@` file menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares its snapshot before dispatch. Preparation disables duplicate submit; failure restores the editor input. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
## Config
@@ -51,7 +51,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic
#### What the model sees
Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only; command results remain terminal notices.
Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices.
#### Token effect

View File

@@ -28,6 +28,7 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -44,6 +45,8 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",

View File

@@ -24,6 +24,9 @@ import {
visibleWidth,
wrapTextWithAnsi,
type Component,
type AutocompleteItem,
type AutocompleteProvider,
type AutocompleteSuggestions,
type EditorTheme,
type Focusable,
type MarkdownTheme,
@@ -33,13 +36,18 @@ import {
} from '@earendil-works/pi-tui'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus, HookContext } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-loop'
import type {} from '@deepseek-ai/dsh-commands'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk, 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 {
formatSessionReferenceMention,
parseSessionReferenceText,
type SessionReferenceService,
} from '@deepseek-ai/dsh-session-reference'
import type {
FileDiff,
TerminalCallView,
@@ -797,6 +805,58 @@ interface PendingQuestion {
overlay: OverlayHandle | undefined
}
/** Add metadata-only session candidates to pi-tui's existing command/file provider. */
class SessionAutocompleteProvider implements AutocompleteProvider {
constructor(
private readonly base: CombinedAutocompleteProvider,
private readonly sessions: SessionReferenceService,
private readonly agent: Agent,
) {}
async getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
options: { signal: AbortSignal; force?: boolean },
): Promise<AutocompleteSuggestions | null> {
const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options)
const currentLine = lines[cursorLine]
/* v8 ignore next -- Editor always supplies its current state line. */
if (currentLine === undefined) return basePromise
const token = /(?:^|\s)(@[^\s]*)$/u.exec(currentLine.slice(0, cursorCol))?.[1]
if (token === undefined) return basePromise
let candidates
try {
candidates = await this.sessions.listCandidates(this.agent, token.slice(1))
} catch {
return basePromise
}
const base = await basePromise
if (options.signal.aborted) return base
const items: AutocompleteItem[] = candidates.map(candidate => ({
value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: candidate.label }),
label: `Session · ${candidate.sessionId}`,
description: `${candidate.cwd ?? '(no cwd)'} · ${new Date(candidate.createdAt).toISOString()}`,
}))
if (items.length === 0) return base
return { items: [...items, ...(base?.items ?? [])], prefix: token }
}
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): { lines: string[]; cursorLine: number; cursorCol: number } {
return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix)
}
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
return this.base.shouldTriggerFileCompletion(lines, cursorLine, cursorCol)
}
}
/** Lifecycle handle for a mounted interactive terminal channel. */
export interface TuiController {
/** Stop rendering, restore the terminal, and reject pending questions. */
@@ -807,6 +867,23 @@ function activeSurfaceSeqs(session: Session): Set<number> {
return new Set(session.surface.nodes)
}
function sessionReferenceCard(meta: unknown): string[] | undefined {
if (typeof meta !== 'object' || meta === null) return undefined
const record = meta as Record<string, unknown>
if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined
const references = record['references'] as unknown[]
const labels: string[] = []
for (const reference of references) {
if (typeof reference !== 'object' || reference === null) return undefined
const entry = reference as Record<string, unknown>
const sessionId = entry['sessionId']
const label = entry['label']
if (typeof sessionId !== 'string' || typeof label !== 'string') return undefined
labels.push(label === sessionId ? sessionId : `${label} (${sessionId})`)
}
return labels
}
function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
const ids = new Set<string>()
for (const event of session.events) {
@@ -857,6 +934,7 @@ export function createTuiChat(
const liveErrors = new Set<string>()
const questionQueue: PendingQuestion[] = []
const commandControllers = new Set<AbortController>()
const referenceControllers = new Set<AbortController>()
let activeQuestion: PendingQuestion | undefined
const welcome = config.welcome ?? 'ready.'
@@ -945,6 +1023,12 @@ export function createTuiChat(
break
}
case 'context/message': {
const references = sessionReferenceCard(event.data.meta)
if (references !== undefined) {
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
break
}
const text = displayText(contentText(event.data.content).trim())
if (text) {
const source = event.data.source.kind === 'plugin' ? event.data.source.plugin : event.data.source.kind
@@ -1133,6 +1217,8 @@ export function createTuiChat(
clearStatus()
for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
commandControllers.clear()
for (const controller of referenceControllers) controller.abort(new Error('TUI disposed'))
referenceControllers.clear()
if (activeQuestion !== undefined) {
const pending = activeQuestion
activeQuestion = undefined
@@ -1193,13 +1279,17 @@ export function createTuiChat(
}
const refreshCommandAutocomplete = (): void => {
editor.setAutocompleteProvider(new CombinedAutocompleteProvider(
const base = new CombinedAutocompleteProvider(
ctx.commands.list(agent).map(command => ({
name: command.name,
description: command.description,
})),
agent.session.header.cwd ?? process.cwd(),
))
)
const sessionReferences = ctx.get('sessionReferences')
editor.setAutocompleteProvider(sessionReferences === undefined
? base
: new SessionAutocompleteProvider(base, sessionReferences, agent))
}
const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
refreshCommandAutocomplete()
@@ -1269,24 +1359,73 @@ export function createTuiChat(
).finally(() => { commandControllers.delete(controller) })
}
editor.onSubmit = (value: string) => {
const text = value.trim()
if (text === '') return
editor.addToHistory(text)
editor.setText('')
if (value.startsWith('/')) {
runCommand(value)
return
}
const dispatchMessage = (content: ContentBlock[], contexts: HookContext[]): void => {
if (agent.status === 'disposed') {
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
} else if (agent.status === 'running') {
agent.steer([{ type: 'text', text }])
agent.steer(content, { contexts })
} else {
agent.send([{ type: 'text', text }])
agent.send(content, { contexts })
}
}
editor.onSubmit = (value: string) => {
const text = value.trim()
if (text === '') return
const restoreSubmittedInput = (): void => {
if (editor.getText() === '') editor.setText(value)
}
if (value.startsWith('/')) {
editor.addToHistory(text)
editor.setText('')
runCommand(value)
return
}
let parsed: ReturnType<typeof parseSessionReferenceText>
try {
parsed = parseSessionReferenceText(text)
} catch (error: unknown) {
restoreSubmittedInput()
appendNotice(`Invalid session reference: ${errorChain(error)}`, 'error')
return
}
if (parsed.references.length === 0) {
editor.addToHistory(text)
editor.setText('')
dispatchMessage([{ type: 'text', text: parsed.text }], [])
return
}
const sessionReferences = ctx.get('sessionReferences')
if (sessionReferences === undefined) {
restoreSubmittedInput()
appendNotice('Session reference capability unavailable.', 'error')
return
}
const controller = new AbortController()
referenceControllers.add(controller)
editor.disableSubmit = true
void sessionReferences.prepare(
agent,
[{ type: 'text', text: parsed.text }],
parsed.references,
controller.signal,
).then((prepared) => {
if (disposed) return
editor.addToHistory(text)
if (editor.getText() === value) editor.setText('')
dispatchMessage(prepared.content, prepared.contexts)
}, (error: unknown) => {
if (!disposed && !controller.signal.aborted) {
restoreSubmittedInput()
appendNotice(`Session reference failed: ${errorChain(error)}`, 'error')
}
}).finally(() => {
referenceControllers.delete(controller)
editor.disableSubmit = false
requestRender()
})
}
const removeInputListener = ui.addInputListener((data) => {
if (activeQuestion !== undefined) return undefined
if (matchesKey(data, Key.ctrl('o'))) {

View File

@@ -1,6 +1,6 @@
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent, type AgentStatus, type SendOptions } from '@deepseek-ai/dsh-agent'
import CommandService from '@deepseek-ai/dsh-commands'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
@@ -11,7 +11,9 @@ import { createTuiChat, type Config } from '../src/index.ts'
interface FakeAgent extends Agent {
status: AgentStatus
sent: ContentBlock[][]
sentOptions: (SendOptions | undefined)[]
steered: ContentBlock[][]
steeredOptions: (SendOptions | undefined)[]
cancelled: string[]
}
@@ -68,6 +70,8 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
options.beforeMount?.(session)
const sent: ContentBlock[][] = []
const steered: ContentBlock[][] = []
const sentOptions: (SendOptions | undefined)[] = []
const steeredOptions: (SendOptions | undefined)[] = []
const cancelled: string[] = []
const agent: FakeAgent = {
id: sessionId,
@@ -76,13 +80,17 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
status: options.status ?? 'idle',
ctx,
sent,
sentOptions,
steered,
steeredOptions,
cancelled,
send(content) {
send(content, options) {
sent.push(content)
sentOptions.push(options)
},
steer(content) {
steer(content, options) {
steered.push(content)
steeredOptions.push(options)
},
inject() {},
cancel(reason) {

View File

@@ -0,0 +1,128 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import CommandService from '@deepseek-ai/dsh-commands'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionQueryService from '@deepseek-ai/dsh-session-query'
import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
import { createTuiChat } from '../src/index.ts'
import { HeadlessTerminal } from './headless-terminal.ts'
const EXPECTED = join(dirname(fileURLToPath(import.meta.url)), 'snapshots/session-reference.expected.txt')
const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh'
class SnapshotAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
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: 'finish', reason: { kind: 'stop' } }
}
}
function nextIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject !== agent || status !== 'idle') return
dispose()
resolve()
})
})
}
describe('TUI session-reference snapshot', () => {
it('snapshots compacted current-surface context on send and displays only its reference card', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionQueryService)
await ctx.plugin(SessionReferenceService)
const adapter = new SnapshotAdapter()
ctx.llm.registerAdapter(['mock'], adapter)
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: '/workspace/project', createdAt: 1 } })
const oldUser = source.append('user/message', {
content: [{ type: 'text', text: 'SHADOWED OLD USER' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const oldAssistant = source.append('assistant/message', {
turn: 1,
step: 1,
provenance: { provider: 'mock', model: 'mock' },
content: [{ type: 'text', text: 'SHADOWED OLD ASSISTANT' }],
}, { surfaceOp: 'append' })
source.append('user/message', {
content: [{ type: 'text', text: '<compacted-summary>Retained checkpoint.</compacted-summary>' }],
source: { kind: 'plugin', plugin: 'compact' },
}, {
surfaceOp: { op: 'replace', start: oldUser.seq, end: oldAssistant.seq },
sourceEventSeqs: [oldUser.seq, oldAssistant.seq],
})
source.append('user/message', {
content: [{ type: 'text', text: 'Recent retained question.' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const target = ctx.agentLoop.create(
SessionId('target-session'),
{ provider: 'mock', model: 'mock' },
{ cwd: '/workspace/project' },
)
const terminal = new HeadlessTerminal(96, 24)
const controller = createTuiChat(ctx, {
sessionId: target.id,
welcome: 'Session reference snapshot.',
color: true,
title: 'DSH session reference',
}, { terminal, exit: () => {} })
await terminal.waitForFrame(0)
const mention = formatSessionReferenceMention({ sessionId: source.id, label: 'Source session' })
const idle = nextIdle(ctx, target)
const frame = terminal.frames
terminal.send(`Use ${mention}`)
terminal.send('\r')
await idle
await terminal.waitForFrame(frame)
const request = JSON.stringify(adapter.requests[0]?.messages)
expect(request).toContain('untrusted, read-only snapshot')
expect(request).toContain('Retained checkpoint.')
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 snapshot = await terminal.snapshot({ includeScrollback: true })
if (REFRESHING) {
await mkdir(dirname(EXPECTED), { recursive: true })
await writeFile(EXPECTED, snapshot)
}
await expect(snapshot).toMatchFileSnapshot(EXPECTED)
await controller.dispose()
await ctx.fiber.dispose()
await terminal.dispose()
})
})

View File

@@ -0,0 +1,49 @@
terminal 96x24 buffer=normal length=24 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH session reference"
cursor hidden column=1 viewportRow=16 bufferRow=16
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-95 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 95-95 fg=bright-blue
2| "│ Session reference snapshot. │"
style 0-0 fg=bright-blue
style 2-28 fg=bright-black
style 95-95 fg=bright-blue
3| "│ mock • target-session │"
style 0-0 fg=bright-blue
style 2-24 dim
style 95-95 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-95 fg=bright-blue
5| <blank>
6| "▌ "
style 0-0 fg=bright-blue
7| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
8| "▌ Use @Source session "
style 0-0 fg=bright-blue
9| "▌ "
style 0-0 fg=bright-blue
10| <blank>
11| " Referenced sessions · Source session (source-session) "
style 1-53 dim
12| <blank>
13| " Assistant "
style 1-9 fg=bright-magenta bold
14| " Snapshot reference accepted. "
15| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
16| " "
style 1-1 inverse
17| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
18| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
style 0-24 dim
style 63-95 dim
19-23| <blank>

View File

@@ -44,6 +44,10 @@ const CHECKPOINTS = [
'disposed-terminal',
] as const
// Real-loop scenarios own their assertions in separate snapshot suites but
// share this directory, whose inventory remains exact.
const STANDALONE_CHECKPOINTS = ['session-reference'] as const
type Checkpoint = typeof CHECKPOINTS[number]
type SnapshotHarness = TuiHarness<HeadlessTerminal, (code: number) => void>
@@ -576,5 +580,5 @@ afterAll(async () => {
const files = (await readdir(SNAPSHOTS_DIR))
.filter(file => file.endsWith('.expected.txt'))
.sort()
expect(files).toEqual(CHECKPOINTS.map(name => `${name}.expected.txt`).sort())
expect(files).toEqual([...CHECKPOINTS, ...STANDALONE_CHECKPOINTS].map(name => `${name}.expected.txt`).sort())
})

View File

@@ -5,9 +5,11 @@ import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type JsonValue } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionQueryService from '@deepseek-ai/dsh-session-query'
import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
import type {} from '@deepseek-ai/dsh-llm-retry'
import {
createTuiChat,
@@ -500,6 +502,241 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(disposedAgent)
})
it('combines session autocomplete with files and prepares send/steer references asynchronously', async () => {
let sourceId = SessionId('uninitialized')
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(SessionReferenceService)
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: process.cwd(), createdAt: 1 } })
sourceId = source.id
appendUser(source, 'source background')
ctx.sessions.create(SessionId('no-cwd'), { meta: { createdAt: 2 } })
},
})
result.terminal.send('@no-cwd')
await tick()
expect(result.terminal.output).toContain('Session · no-cwd')
expect(result.terminal.output).toContain('(no cwd)')
result.terminal.send('\x03')
result.terminal.send('@source-session')
await tick()
result.terminal.send('\t')
await tick()
result.terminal.send('\r')
await tick()
expect(result.agent.sent).toEqual([[{ type: 'text', text: '@source-session' }]])
expect(result.agent.sentOptions[0]?.contexts).toHaveLength(1)
const mention = formatSessionReferenceMention({ sessionId: sourceId, label: 'Source chat' })
expect(result.agent.sentOptions[0]?.contexts).toMatchObject([{
source: { kind: 'plugin', plugin: 'session-reference' },
meta: { kind: 'session-reference', references: [{ sessionId: 'source-session' }] },
}])
result.agent.status = 'running'
result.terminal.send(`steer ${mention}`)
result.terminal.send('\r')
await tick()
expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer @Source chat' }]])
expect(result.agent.steeredOptions[0]?.contexts).toHaveLength(1)
await dispose(result)
})
it('falls back cleanly for non-session, empty, failed, and superseded autocomplete requests', async () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(SessionReferenceService)
},
})
const originalListCandidates = result.ctx.sessionReferences.listCandidates.bind(result.ctx.sessionReferences)
const listCandidates = vi.spyOn(result.ctx.sessionReferences, 'listCandidates')
result.terminal.send('plain')
result.terminal.send('\t')
await tick()
result.terminal.send('\x03')
result.terminal.send('/he')
result.terminal.send('\t')
await tick()
result.terminal.send('\x03')
listCandidates.mockRejectedValueOnce(new Error('candidate lookup failed'))
result.terminal.send('@failed')
await vi.waitFor(() => { expect(listCandidates).toHaveBeenCalled() })
result.terminal.send('\x03')
result.terminal.send('@empty')
await tick()
result.terminal.send('\x03')
let releaseFirst: (() => void) | undefined
let delayed = true
listCandidates.mockImplementation(async (...args) => {
if (!delayed) return originalListCandidates(...args)
delayed = false
await new Promise<void>((resolve) => { releaseFirst = resolve })
return []
})
result.terminal.send('@slow')
await vi.waitFor(() => { expect(releaseFirst).toBeTypeOf('function') })
result.terminal.send('x')
releaseFirst?.()
await tick()
await dispose(result)
})
it('keeps failed mention input and renders durable reference contexts as compact cards', async () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(SessionReferenceService)
},
})
const missing = formatSessionReferenceMention({ sessionId: SessionId('missing'), label: 'Missing chat' })
result.terminal.send(`keep ${missing}`)
result.terminal.send('\r')
await tick()
expect(result.agent.sent).toHaveLength(0)
expect(result.terminal.output).toContain('Session reference failed')
expect(result.terminal.output).toContain('keep @[')
result.session.append('context/message', {
content: [{ type: 'text', text: 'secret full snapshot payload' }],
source: { kind: 'plugin', plugin: 'session-reference' },
meta: {
kind: 'session-reference',
version: 1,
references: [{ sessionId: 'source', label: 'Source', capturedThroughSeq: 2 }],
},
}, { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).toContain('Referenced sessions · Source (source)')
expect(result.terminal.output).not.toContain('secret full snapshot payload')
const invalidCards: [JsonValue, string][] = [
[{ kind: 'other' }, 'invalid-kind'],
[{ kind: 'session-reference', references: [null] }, 'invalid-entry'],
[{ kind: 'session-reference', references: [{}] }, 'invalid-fields'],
]
for (const [meta, text] of invalidCards) {
result.session.append('context/message', {
content: [{ type: 'text', text }],
source: { kind: 'plugin', plugin: 'session-reference' },
meta,
}, { surfaceOp: 'append' })
}
result.session.append('context/message', {
content: [{ type: 'text', text: 'same-label snapshot' }],
source: { kind: 'plugin', plugin: 'session-reference' },
meta: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] },
}, { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).toContain('Referenced sessions · same')
await dispose(result)
})
it('reports malformed and unavailable references without enqueueing', async () => {
const malformed = await setup()
malformed.terminal.send('use dsh-session:IiJ')
malformed.terminal.send('\r')
await tick()
expect(malformed.agent.sent).toHaveLength(0)
expect(malformed.terminal.output).toContain('Invalid session reference')
await dispose(malformed)
const unavailable = await setup()
const mention = formatSessionReferenceMention({ sessionId: SessionId('source') })
unavailable.terminal.send(`use ${mention}`)
unavailable.terminal.send('\r')
await tick()
expect(unavailable.agent.sent).toHaveLength(0)
expect(unavailable.terminal.output).toContain('Session reference capability unavailable')
await dispose(unavailable)
})
it('clears a retyped successful mention and aborts pending preparation on disposal', async () => {
const result = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(SessionReferenceService)
ctx.sessions.create(SessionId('source'))
},
})
const mention = formatSessionReferenceMention({ sessionId: SessionId('source') })
const value = `use ${mention}`
let release: (() => void) | undefined
const prepare = vi.spyOn(result.ctx.sessionReferences, 'prepare').mockImplementation(
(_agent, content) => new Promise((resolve) => {
release = () => { resolve({ content, contexts: [] }) }
}),
)
result.terminal.send(value)
result.terminal.send('\r')
await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() })
result.terminal.send(value)
release?.()
await tick()
expect(result.agent.sent).toEqual([[{ type: 'text', text: 'use @source' }]])
let rejectPreparation: (() => void) | undefined
prepare.mockImplementation(() => new Promise((_resolve, reject) => {
rejectPreparation = () => { reject(new Error('delayed failure')) }
}))
result.terminal.send(value)
result.terminal.send('\r')
await vi.waitFor(() => { expect(rejectPreparation).toBeTypeOf('function') })
result.terminal.send('new draft')
rejectPreparation?.()
await tick()
expect(result.terminal.output).toContain('delayed failure')
result.terminal.send('\x03')
let pendingSignal: AbortSignal | undefined
prepare.mockImplementation((_agent, _content, _references, signal) => new Promise((_resolve, reject) => {
pendingSignal = signal
signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}))
result.terminal.send(value)
result.terminal.send('\r')
await vi.waitFor(() => { expect(pendingSignal).toBeDefined() })
await result.controller.dispose()
expect(pendingSignal?.aborted).toBe(true)
await tick()
await result.ctx.fiber.dispose()
const lateSuccess = await setup({
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
await ctx.plugin(SessionQueryService)
await ctx.plugin(SessionReferenceService)
ctx.sessions.create(SessionId('source'))
},
})
let resolveAfterDispose: (() => void) | undefined
const latePrepare = vi.spyOn(lateSuccess.ctx.sessionReferences, 'prepare').mockImplementation(
(_agent, content) => new Promise((resolve) => {
resolveAfterDispose = () => { resolve({ content, contexts: [] }) }
}),
)
lateSuccess.terminal.send(value)
lateSuccess.terminal.send('\r')
await vi.waitFor(() => { expect(latePrepare).toHaveBeenCalledOnce() })
await lateSuccess.controller.dispose()
resolveAfterDispose?.()
await tick()
expect(lateSuccess.agent.sent).toHaveLength(0)
await lateSuccess.ctx.fiber.dispose()
})
it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => {
const result = await setup()
const handler = vi.fn(({ rawInput }: CommandInvocation) => ({

View File

@@ -23,6 +23,9 @@
{
"path": "../../core/session"
},
{
"path": "../../context/session-reference"
},
{
"path": "../../llm/llm"
},