feat(session): add cross-session references

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

View File

@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness } from './harness.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
import { encodeSessionReferenceUri, formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
/**
* End-to-end bridge specs over an in-memory transport: a real
@@ -326,6 +327,97 @@ describe('acp bridge', () => {
expect(JSON.stringify(user)).toContain('resource_link')
})
it('rejects canonical session references when the optional capability is not mounted', async () => {
harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.prompt({
sessionId,
prompt: [{ type: 'resource_link', uri: encodeSessionReferenceUri(SessionId('source')), name: 'source' }],
})).rejects.toThrow(/session reference capability unavailable/)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('reports malformed inline session references at the ACP request boundary', async () => {
harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.prompt({
sessionId,
prompt: [{ type: 'text', text: 'use dsh-session:IiJ' }],
})).rejects.toThrow(/invalid session reference/)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('prepares ACP session resource links and inline mentions before one atomic send', async () => {
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [textResponse('ok')] })
const source = harness.ctx.sessions.create(SessionId('source'), { meta: { cwd: '/source' } })
source.append('user/message', {
content: [{ type: 'text', text: 'source background' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const mention = formatSessionReferenceMention({ sessionId: source.id, label: 'source-inline' })
const result = await harness.client.prompt({
sessionId,
prompt: [
{ type: 'text', text: `use ${mention} and ` },
{ type: 'resource_link', uri: encodeSessionReferenceUri(source.id), name: 'source-link' },
],
})
expect(result.stopReason).toBe('end_turn')
const target = harness.ctx.agents.get(SessionId(sessionId))!.session
const user = target.events.find(event => event.type === 'user/message')
expect(user?.type === 'user/message' && user.data.content).toEqual([
{ type: 'text', text: 'use @source-inline and @source-link' },
])
const context = target.events.find(event => event.type === 'context/message')
expect(context?.type === 'context/message' && context.data.meta).toMatchObject({
kind: 'session-reference',
references: [{ sessionId: 'source', label: 'source-inline' }],
})
const request = JSON.stringify(harness.adapter.requests[0]?.messages)
expect(request).toContain('untrusted, read-only snapshot')
expect(request).toContain('source background')
})
it('rejects a failed referenced-session read before starting a turn', async () => {
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.prompt({
sessionId,
prompt: [{ type: 'resource_link', uri: encodeSessionReferenceUri(SessionId('missing')), name: 'missing' }],
})).rejects.toThrow(/preparation failed/)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('cancels reference preparation before a turn is created', async () => {
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [] })
const source = harness.ctx.sessions.create(SessionId('source'))
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const prepare = vi.spyOn(harness.ctx.sessionReferences, 'prepare').mockImplementation(
(_agent, _content, _references, signal) => new Promise((_resolve, reject) => {
if (signal?.aborted === true) {
reject(new Error('already aborted'))
return
}
signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}),
)
const pending = harness.client.prompt({
sessionId,
prompt: [{ type: 'resource_link', uri: encodeSessionReferenceUri(source.id), name: 'source' }],
})
await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() })
await harness.client.cancel({ sessionId })
await expect(pending).resolves.toEqual({ stopReason: 'cancelled' })
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('rejects a prompt for an unknown session', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })

View File

@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import { encodeSessionReferenceUri, formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk'
import {
acpPromptToReferencedPrompt,
acpPromptToText,
harnessBlockToAcpContent,
promptHasUnsupportedContent,
@@ -55,6 +58,35 @@ describe('acpPromptToText', () => {
})
})
describe('acpPromptToReferencedPrompt', () => {
it('extracts resource links and inline mentions while preserving ordinary links', () => {
const sessionId = SessionId('source/会话')
const prompt: AcpContentBlock[] = [
{ type: 'text', text: `compare ${formatSessionReferenceMention({ sessionId, label: 'inline' })} with ` },
{ type: 'resource_link', uri: encodeSessionReferenceUri(sessionId), name: 'linked' },
{ type: 'resource_link', uri: 'file:///x', name: 'x' },
]
expect(acpPromptToReferencedPrompt(prompt)).toEqual({
text: 'compare @inline with @linked\n[resource_link name="x" uri="file:///x"]\n',
references: [{ sessionId, label: 'inline' }, { sessionId, label: 'linked' }],
})
})
it('rejects malformed session resource links', () => {
expect(() => acpPromptToReferencedPrompt([
{ type: 'resource_link', uri: 'dsh-session:%%%', name: 'bad' },
])).toThrow(/invalid session reference URI/)
})
it('uses the decoded id for an empty resource name and ignores unsupported direct inputs', () => {
const sessionId = SessionId('source')
expect(acpPromptToReferencedPrompt([
{ type: 'resource_link', uri: encodeSessionReferenceUri(sessionId), name: '' },
{ type: 'image', mimeType: 'image/png', data: 'AA==' },
])).toEqual({ text: '@source', references: [{ sessionId, label: 'source' }] })
})
})
describe('promptHasUnsupportedContent', () => {
it('detects image, audio, and embedded resource blocks', () => {
expect(promptHasUnsupportedContent([{ type: 'image', mimeType: 'image/png', data: 'AA==' }])).toBe(true)

View File

@@ -30,6 +30,8 @@ import {
type Stream,
} from '@agentclientprotocol/sdk'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionQueryService from '@deepseek-ai/dsh-session-query'
import SessionReferenceService from '@deepseek-ai/dsh-session-reference'
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as AcpPlugin from '../src/index.ts'
import { type AcpConfig } from '../src/index.ts'
@@ -191,6 +193,8 @@ export async function makeBridgeHarness(options: {
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
*/
withTodo?: boolean
/** Mount exact session reads and cross-session snapshot preparation before ACP. */
withSessionReferences?: boolean
/**
* Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` +
* `dsh-tool-fs`) so a test can drive `read`/`write`/`edit` through the bridge
@@ -214,6 +218,10 @@ export async function makeBridgeHarness(options: {
await ctx.plugin(CommandService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
if (options.withSessionReferences) {
await ctx.plugin(SessionQueryService)
await ctx.plugin(SessionReferenceService)
}
await ctx.plugin(UserInteractionService)
if (options.withAskUser) {
await ctx.plugin(ToolAskUser)