feat(session): add cross-session references
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness } from './harness.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { encodeSessionReferenceUri, formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
|
||||
|
||||
/**
|
||||
* End-to-end bridge specs over an in-memory transport: a real
|
||||
@@ -326,6 +327,97 @@ describe('acp bridge', () => {
|
||||
expect(JSON.stringify(user)).toContain('resource_link')
|
||||
})
|
||||
|
||||
it('rejects canonical session references when the optional capability is not mounted', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'resource_link', uri: encodeSessionReferenceUri(SessionId('source')), name: 'source' }],
|
||||
})).rejects.toThrow(/session reference capability unavailable/)
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports malformed inline session references at the ACP request boundary', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'use dsh-session:IiJ' }],
|
||||
})).rejects.toThrow(/invalid session reference/)
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('prepares ACP session resource links and inline mentions before one atomic send', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [textResponse('ok')] })
|
||||
const source = harness.ctx.sessions.create(SessionId('source'), { meta: { cwd: '/source' } })
|
||||
source.append('user/message', {
|
||||
content: [{ type: 'text', text: 'source background' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const mention = formatSessionReferenceMention({ sessionId: source.id, label: 'source-inline' })
|
||||
const result = await harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [
|
||||
{ type: 'text', text: `use ${mention} and ` },
|
||||
{ type: 'resource_link', uri: encodeSessionReferenceUri(source.id), name: 'source-link' },
|
||||
],
|
||||
})
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
|
||||
const target = harness.ctx.agents.get(SessionId(sessionId))!.session
|
||||
const user = target.events.find(event => event.type === 'user/message')
|
||||
expect(user?.type === 'user/message' && user.data.content).toEqual([
|
||||
{ type: 'text', text: 'use @source-inline and @source-link' },
|
||||
])
|
||||
const context = target.events.find(event => event.type === 'context/message')
|
||||
expect(context?.type === 'context/message' && context.data.meta).toMatchObject({
|
||||
kind: 'session-reference',
|
||||
references: [{ sessionId: 'source', label: 'source-inline' }],
|
||||
})
|
||||
const request = JSON.stringify(harness.adapter.requests[0]?.messages)
|
||||
expect(request).toContain('untrusted, read-only snapshot')
|
||||
expect(request).toContain('source background')
|
||||
})
|
||||
|
||||
it('rejects a failed referenced-session read before starting a turn', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'resource_link', uri: encodeSessionReferenceUri(SessionId('missing')), name: 'missing' }],
|
||||
})).rejects.toThrow(/preparation failed/)
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('cancels reference preparation before a turn is created', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [] })
|
||||
const source = harness.ctx.sessions.create(SessionId('source'))
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const prepare = vi.spyOn(harness.ctx.sessionReferences, 'prepare').mockImplementation(
|
||||
(_agent, _content, _references, signal) => new Promise((_resolve, reject) => {
|
||||
if (signal?.aborted === true) {
|
||||
reject(new Error('already aborted'))
|
||||
return
|
||||
}
|
||||
signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
}),
|
||||
)
|
||||
const pending = harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'resource_link', uri: encodeSessionReferenceUri(source.id), name: 'source' }],
|
||||
})
|
||||
await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() })
|
||||
await harness.client.cancel({ sessionId })
|
||||
await expect(pending).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects a prompt for an unknown session', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { encodeSessionReferenceUri, formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
|
||||
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk'
|
||||
import {
|
||||
acpPromptToReferencedPrompt,
|
||||
acpPromptToText,
|
||||
harnessBlockToAcpContent,
|
||||
promptHasUnsupportedContent,
|
||||
@@ -55,6 +58,35 @@ describe('acpPromptToText', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('acpPromptToReferencedPrompt', () => {
|
||||
it('extracts resource links and inline mentions while preserving ordinary links', () => {
|
||||
const sessionId = SessionId('source/会话')
|
||||
const prompt: AcpContentBlock[] = [
|
||||
{ type: 'text', text: `compare ${formatSessionReferenceMention({ sessionId, label: 'inline' })} with ` },
|
||||
{ type: 'resource_link', uri: encodeSessionReferenceUri(sessionId), name: 'linked' },
|
||||
{ type: 'resource_link', uri: 'file:///x', name: 'x' },
|
||||
]
|
||||
expect(acpPromptToReferencedPrompt(prompt)).toEqual({
|
||||
text: 'compare @inline with @linked\n[resource_link name="x" uri="file:///x"]\n',
|
||||
references: [{ sessionId, label: 'inline' }, { sessionId, label: 'linked' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed session resource links', () => {
|
||||
expect(() => acpPromptToReferencedPrompt([
|
||||
{ type: 'resource_link', uri: 'dsh-session:%%%', name: 'bad' },
|
||||
])).toThrow(/invalid session reference URI/)
|
||||
})
|
||||
|
||||
it('uses the decoded id for an empty resource name and ignores unsupported direct inputs', () => {
|
||||
const sessionId = SessionId('source')
|
||||
expect(acpPromptToReferencedPrompt([
|
||||
{ type: 'resource_link', uri: encodeSessionReferenceUri(sessionId), name: '' },
|
||||
{ type: 'image', mimeType: 'image/png', data: 'AA==' },
|
||||
])).toEqual({ text: '@source', references: [{ sessionId, label: 'source' }] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptHasUnsupportedContent', () => {
|
||||
it('detects image, audio, and embedded resource blocks', () => {
|
||||
expect(promptHasUnsupportedContent([{ type: 'image', mimeType: 'image/png', data: 'AA==' }])).toBe(true)
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
type Stream,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import SessionQueryService from '@deepseek-ai/dsh-session-query'
|
||||
import SessionReferenceService from '@deepseek-ai/dsh-session-reference'
|
||||
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as AcpPlugin from '../src/index.ts'
|
||||
import { type AcpConfig } from '../src/index.ts'
|
||||
@@ -191,6 +193,8 @@ export async function makeBridgeHarness(options: {
|
||||
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
|
||||
*/
|
||||
withTodo?: boolean
|
||||
/** Mount exact session reads and cross-session snapshot preparation before ACP. */
|
||||
withSessionReferences?: boolean
|
||||
/**
|
||||
* Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` +
|
||||
* `dsh-tool-fs`) so a test can drive `read`/`write`/`edit` through the bridge
|
||||
@@ -214,6 +218,10 @@ export async function makeBridgeHarness(options: {
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
|
||||
if (options.withSessionReferences) {
|
||||
await ctx.plugin(SessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
}
|
||||
await ctx.plugin(UserInteractionService)
|
||||
if (options.withAskUser) {
|
||||
await ctx.plugin(ToolAskUser)
|
||||
|
||||
Reference in New Issue
Block a user