feat(session): add cross-session references
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {} })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../context/session-reference"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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'))) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
128
packages/ui/tui/tests/session-reference.snapshot.ts
Normal file
128
packages/ui/tui/tests/session-reference.snapshot.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../context/session-reference"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user