fix(session-reference): bind snapshots to prompts

This commit is contained in:
Yichen Jiang
2026-07-22 17:34:31 +08:00
parent 3fa368854c
commit da8c0ab092
102 changed files with 838 additions and 248 deletions

View File

@@ -1,6 +1,6 @@
# `@deepseek-ai/dsh-session-reference`
`ctx.sessionReferences` prepares bounded, read-only snapshots of other DeepSeek Harness sessions as durable `context/message` input. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. The standard TUI and ACP demo bundles mount it, while other hosts may call the service directly.
`ctx.sessionReferences` prepares bounded, read-only snapshots of other sessions as prompt-prefix context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. The standard TUI and ACP demo bundles mount it, while other hosts may call the service directly.
## Public API
@@ -10,9 +10,9 @@
## Snapshot semantics
Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, direct-user `steering/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text.
Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, direct-user `steering/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. For a source prompt that already contains baked prefix context, projection reads only its model-hidden display content, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text.
The context source is `{ kind: 'plugin', plugin: 'session-reference' }`. Its metadata records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. The target session persists that exact context through the ordinary `context/message` event; later source mutation, compaction, or deletion cannot change target replay.
The context source is `{ kind: 'plugin', plugin: 'session-reference' }` with `placement: 'prompt-prefix'`. Its metadata records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. AgentLoop writes the snapshot, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`; the same event's model-hidden envelope retains the direct prompt and metadata for TUI/ACP replay. Later source mutation, compaction, or deletion cannot change target replay.
## Configuration
@@ -30,7 +30,7 @@ Retention applies `maxReferenceBytes` independently to each source, keeps compac
#### What the model sees
The model sees the current message's readable `@label` plus one same-level user-context message headed `## Referenced sessions`. The context states that its JSON is untrusted, read-only background and forbids following instructions, permission claims, or tool requests unless the current user repeats them. Labels, cwd values, ids, and conversation text are serialized as JSON inside `<referenced-sessions>` tags; every data `<` is emitted as the lossless JSON escape `\u003c`, so source text cannot spell a framing tag.
The model sees one user-role message in this order: the `## Referenced sessions` untrusted snapshot, the `## My request:` delimiter, then the current message with its readable `@label`. The warning forbids following instructions, permission claims, or tool requests from the snapshot unless the current user repeats them. Labels, cwd values, ids, and conversation text are serialized as JSON inside `<referenced-sessions>` tags; every data `<` is emitted as the lossless JSON escape `\u003c`, so source text cannot spell a framing tag.
#### Token effect
@@ -38,7 +38,7 @@ Each referenced message adds the fixed warning plus up to three serialized snaps
#### KV Cache effect
Snapshot context is append-only at the target message boundary and preserves earlier cacheable history. Different references or source capture contents change the new suffix only; later target compaction may invalidate reuse from its replacement boundary.
The combined snapshot and request are append-only at the target message boundary and preserve earlier cacheable history. Different references or source capture contents change the new suffix only; later target compaction may invalidate reuse from its replacement boundary.
## Known Limitations and Deferred Work

View File

@@ -195,6 +195,7 @@ export class SessionReferenceService extends Service {
const context: HookContext = {
source: { kind: 'plugin', plugin: 'session-reference' },
content: [{ type: 'text', text: prompt }],
placement: 'prompt-prefix',
meta,
}
return { content: acceptedContent, contexts: [context] }

View File

@@ -1,6 +1,7 @@
/** Current-surface projection and byte-bounded rendering. */
import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compact'
import { displayPromptContent } from '@deepseek-ai/dsh-session'
import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { TextRetainer } from '@deepseek-ai/dsh-retention'
@@ -40,13 +41,13 @@ function projectSessionConversation(snapshot: SessionSurfaceSnapshot): Projected
case 'user/message': {
const checkpoint = isCompactCheckpointSource(event.data.source)
if (!checkpoint && event.data.source.kind !== 'user') break
const text = textContent(event.data.content)
const text = textContent(displayPromptContent(event.data))
if (text !== '') conversation.push({ role: 'user', text, checkpoint, originalText: text, omittedBytes: 0 })
break
}
case 'steering/message': {
if (event.data.source.kind !== 'user') break
const text = textContent(event.data.content)
const text = textContent(displayPromptContent(event.data))
if (text !== '') conversation.push({ role: 'user', text, checkpoint: false, originalText: text, omittedBytes: 0 })
break
}

View File

@@ -228,6 +228,7 @@ describe('session reference discovery and preparation', () => {
const context = prepared.contexts[0]
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
expect(context.source).toEqual({ kind: 'plugin', plugin: 'session-reference' })
expect(context.placement).toBe('prompt-prefix')
expect(context.content[0].text).toContain('untrusted, read-only snapshot')
expect(promptData(context.content[0].text)).toEqual([{
sessionId: 'source',
@@ -261,6 +262,36 @@ describe('session reference discovery and preparation', () => {
expect(context.content[0].text).not.toContain('later source mutation')
})
it('projects only the direct prompt when a source message contains baked prefix context', async () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'))
const source = ctx.sessions.create(SessionId('source'))
source.append('user/message', {
content: [
{ type: 'text', text: 'nested referenced snapshot must not propagate' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'direct source question' },
],
source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'direct source question' }],
prefixContexts: [{ source: { kind: 'plugin', plugin: 'session-reference' } }],
},
}, { surfaceOp: 'append' })
const prepared = await ctx.sessionReferences.prepare(
fakeAgent(target),
[{ type: 'text', text: 'inspect source' }],
[{ sessionId: source.id }],
)
const context = prepared.contexts[0]
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
expect(promptData(context.content[0].text)).toMatchObject([{
conversation: [{ role: 'user', text: 'direct source question' }],
}])
expect(context.content[0].text).not.toContain('nested referenced snapshot must not propagate')
})
it('keeps source text inside tag-safe JSON framing without changing its value', async () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'))
@@ -447,14 +478,19 @@ describe('session reference discovery and preparation', () => {
[{ type: 'text', text: 'use @source' }],
[{ sessionId: source.id }],
)
target.append(
'user/message',
{ content: prepared.content, source: { kind: 'user' } },
{ surfaceOp: 'append' },
)
for (const context of prepared.contexts) {
target.append('context/message', context, { surfaceOp: 'append' })
}
const context = prepared.contexts[0]
if (context === undefined) throw new Error('expected prepared context')
target.append('user/message', {
content: [...context.content, { type: 'text', text: '\n\n## My request:\n' }, ...prepared.content],
source: { kind: 'user' },
envelope: {
displayContent: prepared.content,
prefixContexts: [{
source: context.source,
...context.meta === undefined ? {} : { meta: context.meta },
}],
},
}, { surfaceOp: 'append' })
const before = target.deriveMessages()
const later = source.append(
@@ -480,6 +516,7 @@ describe('session reference discovery and preparation', () => {
expect(ctx.sessions.get(source.id)).toBeUndefined()
expect(target.deriveMessages()).toEqual(before)
expect(JSON.stringify(before)).toContain('durable referenced fact')
expect(JSON.stringify(before)).toContain('## My request:')
expect(JSON.stringify(before)).not.toContain('later source mutation')
expect(new Session(SessionId('replayed-target'), target.events).deriveMessages()).toEqual(before)
})

View File

@@ -1392,7 +1392,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'HookContext',
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n}',
declaration: 'export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: \'separate\' | \'prompt-prefix\';\n meta?: JsonValue;\n}',
},
{
name: 'InjectOptions',
@@ -1466,6 +1466,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PromptAssembly',
declaration: 'export interface PromptAssembly {\n sections: AssembledSection[];\n tools: ToolSchema[];\n variables: Record<string, string | undefined>;\n}',
},
{
name: 'PromptMessageData',
declaration: 'export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n}',
},
{
name: 'PromptMessageEnvelope',
declaration: 'export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n}',
},
{
name: 'PromptPrefixContext',
declaration: 'export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n}',
},
{
name: 'PromptSection',
declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}',
@@ -1520,7 +1532,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionEventMap',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n /* …truncated — full shape in source */',
declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': PromptMessageData;\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n meta?: JsonValue;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': PromptMessageData & {\n turn: number;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: Req /* …truncated — full shape in source */',
},
{
name: 'SessionEventReadRequest',
@@ -1786,6 +1798,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'TerminalResultView',
declaration: 'export interface TerminalResultView {\n card: \'terminal\';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n}',
},
{
name: 'TodoItem',
declaration: 'export interface TodoItem {\n content: string;\n status: \'pending\' | \'in_progress\' | \'completed\';\n}',
},
{
name: 'TokenMeasurement',
declaration: 'export interface TokenMeasurement {\n readonly logRevision: number;\n readonly baseline: TokenMeasurementBaseline;\n readonly surfaceDeltaTokens: number;\n readonly totalTokens: number;\n readonly surfaceTokens: number;\n readonly nodes: readonly TokenSurfaceNode[];\n}',

View File

@@ -52,7 +52,7 @@ Configured agents start automatically. A model call requires both `provider` and
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
Each concrete `send()` materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; its contexts are the prompt waterfall's default additional contexts and therefore append only after admission. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`: an open turn records the steering message followed by its contexts at the next steering checkpoint before a request or continuation decision, but policy can still stop before another step; steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append.
Each concrete `send()` materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; its contexts are the prompt waterfall's default additional contexts and therefore materialize only after admission. Absent or `separate` placement appends an independent `context/message`; `prompt-prefix` placement bakes the context, the stable `## My request:` delimiter, and effective request into one `user/message`, whose model-hidden envelope retains display content and context descriptors. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream `content` and `additionalContexts` unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running `steer()` enters the same record shape in the steering FIFO without dispatching `agent/prompt-submit`; its next checkpoint applies the same separate-or-prefix placement to `steering/message`, while policy can still stop before another step. Steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append.
### Loop lifecycle (`loop.ts`)

View File

@@ -12,7 +12,7 @@ import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorC
import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import type { PromptMessageData, Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
import type { TransmissionLog } from './request-log.ts'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
@@ -92,6 +92,45 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
/** Internal control-flow sentinel; durable classification comes only from the turn signal. */
const TURN_INTERRUPTED = new Error('turn interrupted')
const PROMPT_PREFIX_REQUEST_DELIMITER: ContentBlock = {
type: 'text',
text: '\n\n## My request:\n',
}
interface PreparedPromptMessage {
data: PromptMessageData
separateContexts: HookContext[]
}
/** Bake declared prefix contexts into one reconstructable prompt message. */
function preparePromptMessage(
content: ContentBlock[],
source: PromptMessageData['source'],
contexts: readonly HookContext[],
): PreparedPromptMessage {
const prefixContexts = contexts.filter(context => context.placement === 'prompt-prefix')
const separateContexts = contexts.filter(context => context.placement !== 'prompt-prefix')
if (prefixContexts.length === 0) return { data: { content, source }, separateContexts }
return {
data: {
content: [
...prefixContexts.flatMap(context => context.content),
PROMPT_PREFIX_REQUEST_DELIMITER,
...content,
],
source,
envelope: {
displayContent: content,
prefixContexts: prefixContexts.map(context => ({
source: context.source,
...context.meta === undefined ? {} : { meta: context.meta },
})),
},
},
separateContexts,
}
}
/** Stop at an explicit cooperative boundary without stringifying the runtime reason. */
function interruptionCheckpoint(signal: AbortSignal): void {
if (signal.aborted) throw TURN_INTERRUPTED
@@ -240,9 +279,14 @@ async function runTurn(
const drainSteering = (): boolean => {
const messages = handle.inbox.drainSteering()
for (const message of messages) {
session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
for (const context of message.contexts) {
session.append('context/message', context, { surfaceOp: 'append' })
const prepared = preparePromptMessage(message.content, message.source, message.contexts)
session.append('steering/message', { turn, ...prepared.data }, { surfaceOp: 'append' })
for (const context of prepared.separateContexts) {
session.append('context/message', {
content: context.content,
source: context.source,
...context.meta === undefined ? {} : { meta: context.meta },
}, { surfaceOp: 'append' })
}
}
return messages.length > 0
@@ -316,11 +360,12 @@ async function runTurn(
} else {
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
const content = promptDecision.content ?? message.content
session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
// Every `allow.additionalContexts` entry is a separate context/message the
// next request also sees. The turn is open, so inject() appends each one
// into THIS turn without flattening provenance or metadata.
for (const context of promptDecision.additionalContexts ?? []) {
const prepared = preparePromptMessage(content, message.source, promptDecision.additionalContexts ?? [])
session.append('user/message', prepared.data, { surfaceOp: 'append' })
// Separate contexts still enter THIS turn through inject(). Prefix
// contexts are already baked into the user/message with their durable
// display envelope, so appending them again would duplicate model input.
for (const context of prepared.separateContexts) {
agent.inject(context.content, {
source: context.source,
...context.meta !== undefined ? { meta: context.meta } : {},

View File

@@ -875,24 +875,51 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
expect(agent.status).toBe('running')
const content = [{ type: 'text' as const, text: 'accepted-steer' }]
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
const contexts: HookContext[] = [{
content: [{ type: 'text', text: 'accepted-steering-context' }],
source: { kind: 'plugin', plugin: 'steering-context' },
}]
const contexts: HookContext[] = [
{
content: [{ type: 'text', text: 'accepted-steering-prefix' }],
source: { kind: 'plugin', plugin: 'steering-prefix' },
placement: 'prompt-prefix',
},
{
content: [{ type: 'text', text: 'accepted-steering-context' }],
source: { kind: 'plugin', plugin: 'steering-context' },
meta: { kind: 'separate-card' },
},
{
content: [{ type: 'text', text: 'accepted-steering-context-without-meta' }],
source: { kind: 'plugin', plugin: 'steering-context-without-meta' },
},
]
agent.steer(content, { source, contexts })
content[0]!.text = 'caller-mutated-steer'
source.plugin = 'caller-mutated-source'
contexts[0]!.content[0] = { type: 'text', text: 'caller-mutated-steering-context' }
contexts[0]!.content[0] = { type: 'text', text: 'caller-mutated-steering-prefix' }
contexts[0]!.placement = 'separate'
contexts[1]!.content[0] = { type: 'text', text: 'caller-mutated-steering-context' }
contexts[2]!.content[0] = { type: 'text', text: 'caller-mutated-steering-context-without-meta' }
const idle = waitForIdle(ctx, agent)
release.resolve(undefined)
await idle
expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-steer' }])
expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' })
expect(notifiedContexts).toEqual([{
content: [{ type: 'text', text: 'accepted-steering-context' }],
source: { kind: 'plugin', plugin: 'steering-context' },
}])
expect(notifiedContexts).toEqual([
{
content: [{ type: 'text', text: 'accepted-steering-prefix' }],
source: { kind: 'plugin', plugin: 'steering-prefix' },
placement: 'prompt-prefix',
},
{
content: [{ type: 'text', text: 'accepted-steering-context' }],
source: { kind: 'plugin', plugin: 'steering-context' },
meta: { kind: 'separate-card' },
},
{
content: [{ type: 'text', text: 'accepted-steering-context-without-meta' }],
source: { kind: 'plugin', plugin: 'steering-context-without-meta' },
},
])
expect(Object.isFrozen(notifiedContent)).toBe(true)
expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
expect(Object.isFrozen(notifiedSource)).toBe(true)
@@ -900,14 +927,28 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
const recorded = agent.session.events.flatMap(event => event.type === 'steering/message' ? [event.data] : [])
expect(recorded).toContainEqual({
turn: 1,
content: [{ type: 'text', text: 'accepted-steer' }],
content: [
{ type: 'text', text: 'accepted-steering-prefix' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'accepted-steer' },
],
source: { kind: 'plugin', plugin: 'accepted-source' },
envelope: {
displayContent: [{ type: 'text', text: 'accepted-steer' }],
prefixContexts: [{
source: { kind: 'plugin', plugin: 'steering-prefix' },
}],
},
})
const request = JSON.stringify(adapter.requests[1]!.messages)
expect(request).toContain('accepted-steer')
expect(request).toContain('accepted-steering-prefix')
expect(request).toContain('accepted-steering-context')
expect(request).toContain('accepted-steering-context-without-meta')
expect(request).not.toContain('caller-mutated-steer')
expect(request).not.toContain('caller-mutated-steering-prefix')
expect(request).not.toContain('caller-mutated-steering-context')
expect(request).not.toContain('caller-mutated-steering-context-without-meta')
const steeringIndex = agent.session.events.findIndex(event => event.type === 'steering/message')
const contextIndex = agent.session.events.findIndex(event => event.type === 'context/message'

View File

@@ -117,6 +117,55 @@ describe('agent/prompt-submit', () => {
expect(sent).toContain('extra ctx')
})
it('bakes prompt-prefix contexts and a request delimiter into one durable user message', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('prefixed'), { provider: 'mock', model: 'mock' })
ctx.on('agent/prompt-submit', async (_agent, _content, _source, _signal, next): Promise<PromptDecision> => {
const downstream = await next()
return downstream.kind === 'block'
? downstream
: { ...downstream, content: [{ type: 'text', text: 'rewritten request' }] }
})
agent.send([{ type: 'text', text: 'original request' }], {
contexts: [{
content: [{ type: 'text', text: 'untrusted prefix' }],
source: { kind: 'plugin', plugin: 'prefix' },
placement: 'prompt-prefix',
meta: { kind: 'prefix-card' },
}],
})
await waitForIdle(ctx, agent)
const log = events(agent)
const user = log.find(event => event.type === 'user/message')
expect(user?.type === 'user/message' && user.data).toEqual({
content: [
{ type: 'text', text: 'untrusted prefix' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'rewritten request' },
],
source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'rewritten request' }],
prefixContexts: [{
source: { kind: 'plugin', plugin: 'prefix' },
meta: { kind: 'prefix-card' },
}],
},
})
expect(log.some(event => event.type === 'context/message')).toBe(false)
expect(adapter.requests[0]?.messages.at(-1)).toEqual({
role: 'user',
content: [
{ type: 'text', text: 'untrusted prefix' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'rewritten request' },
],
})
})
it('runs pre-step after prompt rewrites and injected context become durable', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)

View File

@@ -48,7 +48,7 @@ The lifecycle edges have two important local caveats. `agent/created` runs after
Most interception points are cooperative waterfalls returning seam-specific decisions. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. The signal remains authoritative through terminal policy and is retired immediately before `turn/end` publication, so terminal observers and the following durability flush cannot cancel completed turn work. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
`PromptDecision.additionalContexts` is an array so every injected context keeps its own source and metadata. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source.
`PromptDecision.additionalContexts` is an array so every context keeps its own source, metadata, and placement. `SendOptions.contexts` binds the same shape to one queued message before prompt interception: the default allow decision carries it forward, while a blocked prompt records no context. Absent or `separate` placement writes an independent `context/message`; `prompt-prefix` writes the context, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`, whose model-hidden envelope retains the direct prompt and context descriptors for human replay. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative. A `ContinuationDecision` reason is narrower: it becomes a `steering/message` without attached context metadata.
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
@@ -56,8 +56,8 @@ Turn and step boundaries and the model token stream are durable `session/event`
The handle every plugin programs against:
- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Omitting `options.source` attests direct human input as `{ kind: 'user' }` and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. The contexts become individual `context/message` events after the accepted user message, unless `agent/prompt-submit` blocks or replaces the default additional-context decision. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
- `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to `send()`. Attached contexts remain in the same frozen record, append immediately after that steering message when drained, survive late-steering conversion to queued input, and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it.
- `agent.send(content, options?)` — queue one independent FIFO item. If claimed, that item becomes the sole ordinary message in its turn; a claimed FIFO successor waits for that turn's checkpoint to settle. Broad cancellation, disposal, or a pre-start failure may instead drop it without a turn. Omitting `options.source` attests direct human input as `{ kind: 'user' }` and may authorize policy consumers, so plugins, schedulers, and other non-human producers provide their own source. Content, resolved source, and `options.contexts` become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input. After admission, separate contexts become `context/message` events, while prompt-prefix contexts are baked before the effective request in the same `user/message`; a block or replacement of the default additional-context decision can discard them. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
- `agent.steer(content, options?)` — while running, queue steering for the next checkpoint without dispatching `agent/prompt-submit`; when idle, delegate to `send()`. Attached contexts remain in the same frozen record; separate contexts append immediately after the steering event, while prompt-prefix contexts are baked into that steering event. Both survive late-steering conversion to queued input and disappear with their message on cancellation or terminal discard. Policy can still stop before another step; after turn close and its checkpoint, remaining steering becomes later queued input unless terminal turn policy, cancellation, or disposal discards it.
- `agent.inject(content, options?)` — accept detached in-session context without running the model; the next request sees its `context/message` with `content` rendered verbatim as a user-role message. `options.meta` persists opaque JSON state without rendering it. While a turn is open it joins that turn, deferring FIFO while the current tool batch executes and draining before turn close if execution is interrupted; while idle it is wrapped in a one-shot `injection` turn and durability checkpoint ([the turn-enclosure invariant](../../../.agents/notes/implemented/architecture/2026-06-15-turn-enclosure-invariant.md)).
- `agent.cancel(cause?)` — cancel ALL pending work: an omitted cause means `{ kind: 'user' }`; TypeScript restricts callers to the `user | parent` union, and an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; observers may synchronize state but cannot veto cancellation. The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.

View File

@@ -57,17 +57,24 @@ export type AgentStatus = 'idle' | 'running' | 'disposed'
export interface HookContext {
content: ContentBlock[]
source: MessageSource
/**
* Model placement. Absent or `separate` records an independent
* `context/message`; `prompt-prefix` prepends this context and a stable
* request delimiter to the same user-role message as its attached prompt.
*/
placement?: 'separate' | 'prompt-prefix'
/** Opaque JSON state retained in the session event but hidden from the model. */
meta?: JsonValue
}
/**
* Prompt interception result. `allow.content` replaces the prompt and each
* `additionalContexts` entry becomes a separate context message. `block`
* records a durable `prompt/blocked` and ends the claimed prompt's zero-step
* turn as rejected. An `allow` returned by a listener is authoritative: a
* listener wrapping `next()` preserves downstream `content` and
* `additionalContexts` unless it intentionally replaces them.
* Prompt interception result. `allow.content` replaces the prompt. Each
* `additionalContexts` entry follows its declared placement: separate context
* message by default, or a prefix inside the prompt's user-role message.
* `block` records a durable `prompt/blocked` and ends the claimed prompt's
* zero-step turn as rejected. An `allow` returned by a listener is
* authoritative: a listener wrapping `next()` preserves downstream `content`
* and `additionalContexts` unless it intentionally replaces them.
*/
export type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] }

View File

@@ -60,7 +60,7 @@ Durable values need one accepted representation, not a check followed by a secon
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. `messagePrefix` remains separate from derived history. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`.
`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`. A `user/message` or `steering/message` with prompt-prefix context keeps the exact combined model bytes in `content` and stores a model-hidden `envelope` containing the direct `displayContent` and prefix context source/metadata descriptors. `displayPromptContent()` selects the human-facing prompt without changing derived history.
### Session event vocabulary (`types.ts`)
@@ -93,7 +93,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
#### What the model sees
The model receives projections of `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
The model receives projections of `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` surface entries verbatim: each is a user- or assistant-role message carrying its content blocks unchanged. A prompt envelope changes only human presentation; its prefix context and request delimiter are already present in the event content. Tool calls live inside assistant messages. Chunks, boundaries, usage, hook records, todo records, and other log-only events add no message.
#### Token effect

View File

@@ -11,9 +11,9 @@ import { isAbsolute } from 'node:path'
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Message } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts'
import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, PromptMessageData, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
@@ -27,6 +27,15 @@ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from '
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
* Return the human-facing prompt blocks from a durable prompt message.
* @param data - ordinary or steering prompt event data.
* @returns the effective direct prompt, excluding baked prefix context.
*/
export function displayPromptContent(data: PromptMessageData): ContentBlock[] {
return data.envelope?.displayContent ?? data.content
}
/**
* Find the latest closed message-triggered turn, excluding injection and
* plugin-owned zero-step turns.
@@ -521,9 +530,11 @@ export class Session {
// trace/replay data.
switch (event.type) {
// Injected context and mid-turn steering project identically to a user
// prompt: content verbatim, in user role. context's `source`/`meta` and
// steering's `turn` are log-only and do not reach the model. Do NOT
// Injected context, ordinary prompts, and mid-turn steering project
// identically in user role: the event's model-facing content stays
// verbatim. A prompt envelope is model-hidden display metadata; its
// prefix bytes are already present in content. context's `source`/`meta`
// and steering's `turn` are also log-only. Do NOT
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
// caller-owned — a producer bakes it into `content`, as workspace-context
// does with `<system-reminder>` — or, if reintroduced, must be driven by

View File

@@ -180,6 +180,37 @@ export interface EpochHeader {
*/
export type RequestHeaderReason = 'initial' | 'resume' | 'change'
/** Durable model-hidden annotation for one context baked into a prompt message. */
export interface PromptPrefixContext {
/** Producer provenance retained for transcript presentation and inspection. */
source: MessageSource
/** Opaque JSON state retained in the session event but hidden from the model. */
meta?: JsonValue
}
/**
* Human-facing view of a prompt whose exact model content includes prefixed
* context. `content` on the owning event remains the reconstructable model
* input; this envelope prevents transcript, title, and re-reference consumers
* from treating the baked context as direct human text.
*/
export interface PromptMessageEnvelope {
/** Effective user prompt after interception rewrites, without baked context. */
displayContent: ContentBlock[]
/** Ordered descriptors for contexts already baked into the event content. */
prefixContexts: PromptPrefixContext[]
}
/** Shared payload for ordinary and steering prompt messages. */
export interface PromptMessageData {
/** Exact model-facing blocks, including any baked prompt-prefix contexts. */
content: ContentBlock[]
/** Producer provenance for the direct prompt. */
source: MessageSource
/** Present only when prompt-prefix contexts were baked into `content`. */
envelope?: PromptMessageEnvelope
}
/**
* The merge-extensible, append-only source of truth for an agent interaction.
* Message history is derived from this log. Every event is lossless JSON and
@@ -206,7 +237,7 @@ export interface SessionEventMap {
/** Closes step `step` of turn `turn`. */
'step/end': { turn: number; step: number }
/** A user-visible prompt (the queued message claimed for this turn). */
'user/message': { content: ContentBlock[]; source: MessageSource }
'user/message': PromptMessageData
/**
* Durable record of a prompt veto and its reason. It is log-only: the blocked
* prompt never enters the model-visible surface, and its turn runs zero steps.
@@ -254,7 +285,7 @@ export interface SessionEventMap {
*/
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown }
/** Steering content injected between steps of a running turn. */
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
'steering/message': PromptMessageData & { turn: number }
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
'todo/write': { todos: TodoItem[] }
/**

View File

@@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, {
displayPromptContent,
findLastMessageTurnEnd,
SESSION_FORMAT_VERSION,
Session,
@@ -135,6 +136,35 @@ describe('Session', () => {
expect(steeringMessage!.content).toEqual([{ type: 'text', text: 'focus on tests' }])
})
it('derives baked prompt context while exposing only the direct prompt for display', () => {
const session = new Session(SessionId('prompt-envelope'))
const event = session.append('user/message', {
content: [
{ type: 'text', text: 'background' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'question' },
],
source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'question' }],
prefixContexts: [{ source: { kind: 'plugin', plugin: 'reference' }, meta: { kind: 'card' } }],
},
}, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual([{
role: 'user',
content: [
{ type: 'text', text: 'background' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'question' },
],
}])
expect(displayPromptContent(event.data)).toEqual([{ type: 'text', text: 'question' }])
expect(Object.isFrozen(event.data.envelope?.displayContent)).toBe(true)
expect(new Session(SessionId('prompt-envelope-replay'), session.events).deriveMessages())
.toEqual(session.deriveMessages())
})
it('keeps context meta durable in the event while hiding it from the projection', () => {
const session = new Session(SessionId('s2-raw'))
const meta = {

View File

@@ -18,6 +18,7 @@ import { Readable, Writable } from 'node:stream'
import { promisify } from 'node:util'
import { zstdDecompress } from 'node:zlib'
import { afterEach, describe, expect, it } from 'vitest'
import { ACP_SESSION_REFERENCE_META_KEY } from '@deepseek-ai/dsh-acp'
/**
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and
@@ -35,7 +36,8 @@ const dshPackages = [
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash',
'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence',
'session-persistence/session-persistence-jsonl', 'ui/acp', 'examples/acp-demo', 'util/paths',
'session-persistence/session-persistence-jsonl', 'session-query/session-query',
'context/session-reference', 'ui/acp', 'examples/acp-demo', 'util/paths',
]
const vendorPackages = [
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
@@ -165,10 +167,30 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n
// regression would exit before answering); loadSession proves the real app
// mounted, not a collapsed export shape.
expect(init.agentCapabilities?.loadSession).toBe(true)
const { sessionId } = await client.newSession({ cwd: consumer, mcpServers: [] })
expect(init.agentCapabilities?.sessionCapabilities?.list).toEqual({})
const sessionCwd = consumer
const { sessionId } = await client.newSession({ cwd: sessionCwd, mcpServers: [] })
const result = await client.prompt({ sessionId, prompt: [{ type: 'text', text: 'reply' }] })
expect(result.stopReason).toBe('end_turn')
const sessionsRoot = join(consumer, '.sessions')
await expect.poll(async () => {
return (await client.listSessions({ cwd: sessionCwd })).sessions.find(candidate => candidate.sessionId === sessionId)
}).toMatchObject({
sessionId,
cwd: sessionCwd,
title: 'reply',
})
const listed = await client.listSessions({ cwd: sessionCwd })
const reference = listed.sessions.find(candidate => candidate.sessionId === sessionId)
?._meta?.[ACP_SESSION_REFERENCE_META_KEY]
expect(reference).toBeTypeOf('object')
expect(reference).not.toBeNull()
expect(reference).toHaveProperty('uri')
if (typeof reference !== 'object' || reference === null || !('uri' in reference)) {
throw new Error('expected session reference metadata')
}
expect(reference.uri).toBeTypeOf('string')
expect(reference.uri).toMatch(/^dsh-session:[A-Za-z0-9_-]+$/u)
const sessionsRoot = join(sessionCwd, '.sessions')
let log: string | undefined
await expect.poll(async () => {
log = (await readdir(sessionsRoot, { recursive: true })).find(file => file.endsWith('.jsonl.zstd'))

View File

@@ -14,6 +14,7 @@ import type {
SessionEvent,
SessionEventMap,
} from '@deepseek-ai/dsh-session'
import { displayPromptContent } from '@deepseek-ai/dsh-session'
import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts'
export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts'
@@ -201,8 +202,9 @@ export function collectSessionTitleMessages(
for (const event of events) {
if (throughSeq !== undefined && event.seq > throughSeq) break
if (event.type !== 'user/message' || event.data.source.kind !== 'user') continue
const text = event.data.content
.filter((block): block is Extract<(typeof event.data.content)[number], { type: 'text' }> => block.type === 'text')
const content = displayPromptContent(event.data)
const text = content
.filter((block): block is Extract<(typeof content)[number], { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join('\n')
if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) continue

View File

@@ -72,6 +72,33 @@ describe('SessionTitleService', () => {
expect(session.surface.nodes).toEqual([message.seq])
})
it('derives a fallback title from the direct prompt instead of baked prefix context', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('prefixed-title'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('user/message', {
content: [
{ type: 'text', text: 'referenced snapshot title must stay hidden' },
{ type: 'text', text: '\n\n## My request:\n' },
{ type: 'text', text: 'Explain this referenced session' },
],
source: { kind: 'user' },
envelope: {
displayContent: [{ type: 'text', text: 'Explain this referenced session' }],
prefixContexts: [{ source: { kind: 'plugin', plugin: 'session-reference' } }],
},
}, { surfaceOp: 'append' })
await settleTitles()
expect(ctx.sessionTitle.get(session)?.title).toBe('Explain this referenced session')
})
it('waits through synthetic, empty, and non-text messages, then keeps the first fallback', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)

View File

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

View File

@@ -10,13 +10,13 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
## At a glance
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load/list, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
## 1. Agent methods (client → agent)
| Method | Stable | Bridge | Claude | Codex | Notes |
|---|---|---|---|---|---|
| `initialize` | S | ✅ | ✅ | ✅ | Negotiates `PROTOCOL_VERSION`; advertises `loadSession` + baseline prompt caps. Snapshots the Zed `_meta.terminal_output` client cap. |
| `initialize` | S | ✅ | ✅ | ✅ | Negotiates `PROTOCOL_VERSION`; advertises `loadSession`, `sessionCapabilities.list`, and baseline prompt caps. Snapshots the Zed `_meta.terminal_output` client cap. |
| `authenticate` | S | ⚠️ | ✅ | ✅ | No-op stub; the bridge advertises no `authMethods`, so there is nothing to authenticate. |
| `logout` | S | ❌ | ✅ | ✅ | Gated by `agentCapabilities.auth.logout`; not advertised. |
| `session/new` | S | ✅ | ✅ | ✅ | Maps to `agents.create`; requires an absolute `cwd` (becomes the session workspace); rejects non-empty `additionalDirectories` / `mcpServers`. |
@@ -28,7 +28,7 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
| `session/set_mode` | S | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. |
| model selection | S | ✅ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. Values preserve the provider/model pair, catalogs come from `ctx.llm`, selection is per session, and `session/load` restores the last requested pair. Codex also supports the legacy `unstable_setSessionModel` ext method. |
| `session/list` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.list`. The harness HAS `sessionPersistence.list()` (used internally for load-cwd validation) but does not expose it over ACP. |
| `session/list` | S | ✅ | ✅ | ✅ | Uses live-preferred `ctx.sessionQuery`; returns absolute-cwd sessions newest-first with optional folded title and exact cwd filtering. Pagination is not emitted; supplied cursors are rejected. |
| `session/delete` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.delete`. |
| `session/fork` | U | ❌ | ✅ | ❌ | Claude ships `unstable_forkSession`; Codex does not. |
@@ -60,7 +60,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `promptCapabilities.audio` | S | ❌ | ❌ | ❌ | `audio: false`; neither adapter accepts audio either. |
| `promptCapabilities.embeddedContext` | S | ❌ | ✅ | ✅ | `embeddedContext: false`; embedded `resource` blocks rejected. |
| `mcpCapabilities.{http,sse}` | S | ❌ | ✅ | ⚠️ | No MCP passthrough; `mcpServers` is rejected. Claude advertises http+sse, Codex http only. |
| `sessionCapabilities.*` | S | ❌ | ✅ | ✅ | None advertised (list/delete/resume/close/additionalDirectories/fork all off). |
| `sessionCapabilities.*` | S | ⚠️ | ✅ | ✅ | `list` is advertised; delete/resume/close/additionalDirectories/fork remain off. |
| `auth.logout` | S | ❌ | ✅ | ✅ | Not advertised. |
| `authMethods[]` | S | ⚠️ | ✅ | ✅ | Advertised as empty (no auth required to reach the model). |
| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | Fixed literals: `deepseek-harness-acp` / `0.0.1` (not config). |
@@ -88,7 +88,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. |
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox Agent Note § Per-session mode switching](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). |
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). |
| `session_info_update` | S | ❌ | ⚠️ | ⚠️ | Session title/metadata not pushed. |
| `session_info_update` | S | ✅ | ⚠️ | ⚠️ | Log-backed title events push title and event time; load replay uses the same mapping. |
## 5. Tool-call rendering
@@ -132,7 +132,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
| `StopReason` mapping | S | ✅ | `turnEndToStopReason` is total over harness turn-end reasons → `end_turn`/`max_tokens`/`cancelled`. |
| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session Agent Note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md). |
| Disconnect / disposal teardown | S | ✅ | Quiesces every live session on client disconnect or Cordis disposal. |
| `_meta` extensibility | S | ⚠️ | Consumed (Zed terminal cap) and emitted (terminal `_meta`); no other custom extensions. |
| `_meta` extensibility | S | ⚠️ | Consumed for the Zed terminal cap and emitted for terminal cards. Listed sessions add `deepseek-harness/sessionReference` with a canonical URI when cross-session references are mounted. |
| Background-task ownership isolation | — | ✅ | Generic `task_output`/`task_kill` reject tasks whose branded owner `SessionId` belongs to another session. |
| stdout-is-the-protocol guarantee | S | ✅ | The bridge runs in an example with no stdout logger. |
@@ -140,7 +140,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
Ranked by how commonly the reference adapters ship them and how much UX they unlock:
1. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
1. **Session lifecycle** — `session/delete`, then `session/resume` / `session/close`.
2. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
3. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
4. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).

View File

@@ -42,6 +42,7 @@
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",

View File

@@ -27,6 +27,8 @@ import {
type EnumOption,
type InitializeRequest,
type InitializeResponse,
type ListSessionsRequest,
type ListSessionsResponse,
type LoadSessionRequest,
type LoadSessionResponse,
type NewSessionRequest,
@@ -54,8 +56,8 @@ import {
type AgentLlmTargetRef as LlmTargetRef,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-commands'
import type {} from '@deepseek-ai/dsh-session-reference'
import { SessionId } from '@deepseek-ai/dsh-session'
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
import { displayPromptContent, SessionId } from '@deepseek-ai/dsh-session'
// Side-effect type import: resolves `ctx.get('permission')` to the service.
import type {} from '@deepseek-ai/dsh-permission'
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -65,6 +67,9 @@ import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } f
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
import type {} from '@deepseek-ai/dsh-session-persistence'
// Side-effect type import: declaration-merges the exact-read service used by
// session/list for live-preferred title folding.
import type {} from '@deepseek-ai/dsh-session-query'
// Side-effect type import: declaration-merges prompt assembly onto Context and
// the scoped waterfall used to keep persona variables aligned with requests.
import type {} from '@deepseek-ai/dsh-system-prompt'
@@ -89,7 +94,10 @@ import {
export const name = 'acp'
// Interface services back loading, presentation, interaction, and prompt assembly.
export const inject = ['agents', 'commands', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt']
export const inject = ['agents', 'commands', 'sessionPersistence', 'sessionQuery', 'tools', 'userInteraction', 'llm', 'systemPrompt']
/** ACP `SessionInfo._meta` key carrying a ready-to-submit session-reference URI. */
export const ACP_SESSION_REFERENCE_META_KEY = 'deepseek-harness/sessionReference'
/** Preserve invalid-parameter detail in the SDK wire error message. */
function invalidParams(detail: string): RequestError {
@@ -694,6 +702,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
agentCapabilities: {
loadSession: true,
sessionCapabilities: { list: {} },
// Baseline prompt blocks only: text plus resource_link rendered as
// text. No image/audio/embeddedContext, no mcpCapabilities.
promptCapabilities: { image: false, audio: false, embeddedContext: false },
@@ -708,6 +717,41 @@ export function apply(ctx: Context, config: AcpConfig): void {
return Promise.resolve()
},
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
assertOpen()
if (params.cursor !== undefined && params.cursor !== null) {
throw invalidParams('session/list does not paginate; omit cursor')
}
if (params.cwd !== undefined && params.cwd !== null && !isAbsolute(params.cwd)) {
throw invalidParams('session/list cwd must be absolute')
}
const records = (await ctx.sessionQuery.listSessions()).flatMap((record) => {
const cwd = record.header.cwd
if (cwd === undefined) return []
if (params.cwd !== undefined && params.cwd !== null && !sameWorkspaceCwd(cwd, params.cwd)) return []
return [{ record, cwd }]
})
const titles = await Promise.all(records.map(({ record }) => ctx.sessionQuery.readTitle(record.header.id)))
assertOpen()
const referencesAvailable = ctx.get('sessionReferences') !== undefined
return {
sessions: records.map(({ record, cwd }, index) => ({
sessionId: record.header.id,
cwd,
...titles[index] === undefined ? {} : { title: titles[index].title },
...referencesAvailable
? {
_meta: {
[ACP_SESSION_REFERENCE_META_KEY]: {
uri: encodeSessionReferenceUri(record.header.id),
},
},
}
: {},
})),
}
},
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
assertOpen()
validateWorkspaceParams(params)
@@ -1247,7 +1291,7 @@ export function streamSessionEventUpdate(
// Replay the user's prompt so a loaded session shows both sides of each
// turn. Live prompt turns suppress this path to avoid duplicating what
// the client just sent.
for (const block of event.data.content) {
for (const block of displayPromptContent(event.data)) {
const content = harnessBlockToAcpContent(block)
if (content !== undefined) {
notify({ sessionId, update: { sessionUpdate: 'user_message_chunk', content } })

View File

@@ -370,17 +370,22 @@ describe('acp bridge', () => {
const target = harness.ctx.agents.get(SessionId(sessionId))!.session
const user = target.events.find(event => event.type === 'user/message')
expect(user?.type === 'user/message' && user.data.content).toEqual([
{ type: 'text', text: 'use @source-inline and @source-link' },
])
const context = target.events.find(event => event.type === 'context/message')
expect(context?.type === 'context/message' && context.data.meta).toMatchObject({
kind: 'session-reference',
references: [{ sessionId: 'source', label: 'source-inline' }],
expect(user?.type === 'user/message' && user.data.envelope).toMatchObject({
displayContent: [{ type: 'text', text: 'use @source-inline and @source-link' }],
prefixContexts: [{
source: { kind: 'plugin', plugin: 'session-reference' },
meta: {
kind: 'session-reference',
references: [{ sessionId: 'source', label: 'source-inline' }],
},
}],
})
expect(target.events.some(event => event.type === 'context/message')).toBe(false)
const request = JSON.stringify(harness.adapter.requests[0]?.messages)
expect(request).toContain('untrusted, read-only snapshot')
expect(request).toContain('source background')
expect(request.indexOf('source background')).toBeLessThan(request.indexOf('## My request:'))
expect(request.indexOf('## My request:')).toBeLessThan(request.indexOf('use @source-inline and @source-link'))
})
it('rejects a failed referenced-session read before starting a turn', async () => {

View File

@@ -218,8 +218,8 @@ export async function makeBridgeHarness(options: {
await ctx.plugin(CommandService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
await ctx.plugin(SessionQueryService)
if (options.withSessionReferences) {
await ctx.plugin(SessionQueryService)
await ctx.plugin(SessionReferenceService)
}
await ctx.plugin(UserInteractionService)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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