diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index 617dbcc419..665d29bcb0 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-cross-session-references.md: aa02b5657634dca5b8c108b5c923feeed83d7de5 -2026-07-21-cross-session-references.zh.md: 21f40f35ed2693ec0e9761173b6985c18db18500 +2026-07-21-cross-session-references.md: d640bce919af159329320415c45c395961ea4ddf +2026-07-21-cross-session-references.zh.md: b1b3ed021f808b64a6d403ce1049a4e019dc4a53 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index aa02b56576..d640bce919 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -34,7 +34,7 @@ This preserves host driving semantics: TUI decides `send()` versus `steer()` fro TUI combines session candidates with the existing `@` file provider. Candidate lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, and renders persisted session-reference context as a compact source list instead of exposing the complete JSON in the terminal. -ACP extracts `dsh-session:` resource links and canonical inline mentions while preserving ordinary resource-link rendering. A valid reference without the optional service returns a capability-unavailable RPC error, and preparation failure occurs before the in-flight turn slot and agent send. A preparation-specific abort owner makes `session/cancel` and bridge teardown stop pending reads. Picker UI remains an ACP client responsibility. +ACP detects direct slash commands from ordinary prompt flattening before extracting `dsh-session:` resource links and canonical inline mentions, so URI-shaped command arguments remain opaque while ordinary resource-link rendering is preserved. A valid reference without the optional service returns a capability-unavailable RPC error, and preparation failure occurs before the in-flight turn slot and agent send. A preparation-specific abort owner makes `session/cancel` and bridge teardown stop pending reads. Picker UI remains an ACP client responsibility. ## Budget and retention @@ -51,7 +51,7 @@ The defaults cap one serialized reference at 65,536 UTF-8 bytes and the complete ## Verification -Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, candidate ranking, terminal-control escaping, projection exclusions, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, byte retention, frozen message ownership, prompt blocking, send/steer ordering, missing capability, ordinary ACP resource links, and compact TUI rendering. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains the checkpoint and retained tail but not either shadowed string. +Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, candidate ranking, terminal-control escaping, projection exclusions, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, byte retention, frozen message ownership, prompt blocking, send/steer ordering, missing capability, ordinary ACP resource links, opaque ACP command arguments, and compact TUI rendering. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains the checkpoint and retained tail but not either shadowed string. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index 21f40f35ed..b1b3ed021f 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -34,7 +34,7 @@ TUI 与 ACP(Agent Client Protocol)用户需要把另一场对话中的相关 TUI 把会话候选与现有 `@` 文件提供方组合在一起。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;渲染持久化的会话引用上下文时只显示精简的来源列表,不在终端中暴露完整 JSON。 -ACP 提取 `dsh-session:` 资源链接和规范的行内提及标记,同时保留普通资源链接的渲染方式。若引用有效但可选服务未挂载,系统会返回「功能不可用」RPC 错误;准备失败会发生在占用进行中轮次槽位并调用 agent send 之前。引用准备过程单独拥有中止控制权,因此 `session/cancel` 和桥接释放都能停止待处理的读取。选择器 UI 仍由 ACP 客户端负责。 +ACP 先从普通提示词扁平化结果中检测直接斜杠命令,再提取 `dsh-session:` 资源链接和规范的行内提及标记,因此形如 URI 的命令参数保持不透明,同时保留普通资源链接的渲染方式。若引用有效但可选服务未挂载,系统会返回「功能不可用」RPC 错误;准备失败会发生在占用进行中轮次槽位并调用 agent send 之前。引用准备过程单独拥有中止控制权,因此 `session/cancel` 和桥接释放都能停止待处理的读取。选择器 UI 仍由 ACP 客户端负责。 ## 预算与保留策略 @@ -51,7 +51,7 @@ ACP 提取 `dsh-session:` 资源链接和规范的行内提及标记,同时保 ## 验证 -单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、候选排序、终端控制字符转义、投影排除规则、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、字节保留、冻结的消息所有权、提示词阻止、send/steer 顺序、功能缺失、普通 ACP 资源链接,以及 TUI 精简渲染。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求包含该检查点和保留的尾部消息,但不包含任一被遮蔽的字符串。 +单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、候选排序、终端控制字符转义、投影排除规则、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、字节保留、冻结的消息所有权、提示词阻止、send/steer 顺序、功能缺失、普通 ACP 资源链接、不透明的 ACP 命令参数,以及 TUI 精简渲染。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求包含该检查点和保留的尾部消息,但不包含任一被遮蔽的字符串。 ## 后果 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c18f2a13d8..1ccbb6ff7c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -27,7 +27,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:248`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:249`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 3e5a1924f5..6b410d21c9 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -73,6 +73,7 @@ import { type AskUserQuestionRequest, } from '@deepseek-ai/dsh-user-interaction' import { + acpPromptToText, acpPromptToReferencedPrompt, harnessBlockToAcpContent, promptHasUnsupportedContent, @@ -896,23 +897,16 @@ export function apply(ctx: Context, config: AcpConfig): void { if (promptHasUnsupportedContent(params.prompt)) { throw invalidParams('only text and resource_link prompt content is supported; image/audio/embedded resource blocks are rejected rather than silently dropped') } - let referencedPrompt: ReturnType - try { - referencedPrompt = acpPromptToReferencedPrompt(params.prompt) - } catch (error: unknown) { - throw invalidParams(`invalid session reference: ${renderThrown(error)}`) - } - const { text } = referencedPrompt - if (text.trim().length === 0) { + const flattenedText = acpPromptToText(params.prompt) + if (flattenedText.trim().length === 0) { // Reject up front rather than calling send(): an empty prompt would // queue no work, no turn would start, and the RPC would hang forever // waiting for a settle that never comes. throw invalidParams('empty prompt') } - // ACP command prompts may carry additional supported content blocks. - // The same lossless flattening used for model prompts supplies their - // unstructured command input; unsupported kinds were rejected above. - const commandLine = text.startsWith('/') ? text : undefined + // Direct commands consume ordinary ACP flattening before reference + // extraction, so URI-shaped arguments remain opaque to the bridge. + const commandLine = flattenedText.startsWith('/') ? flattenedText : undefined if (commandLine !== undefined) { const controller = new AbortController() rec.commandAbort = controller @@ -955,6 +949,13 @@ export function apply(ctx: Context, config: AcpConfig): void { rec.commandAbort = undefined } } + let referencedPrompt: ReturnType + try { + referencedPrompt = acpPromptToReferencedPrompt(params.prompt) + } catch (error: unknown) { + throw invalidParams(`invalid session reference: ${renderThrown(error)}`) + } + const { text } = referencedPrompt let preparedContent: ContentBlock[] = [{ type: 'text', text }] let preparedContexts: NonNullable[1]>['contexts'] = [] if (referencedPrompt.references.length > 0) { diff --git a/packages/ui/acp/tests/commands.spec.ts b/packages/ui/acp/tests/commands.spec.ts index 71aae1ea64..45926e2b57 100644 --- a/packages/ui/acp/tests/commands.spec.ts +++ b/packages/ui/acp/tests/commands.spec.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SessionId } from '@deepseek-ai/dsh-session' +import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' function commandUpdates(harness: BridgeHarness, sessionId: string) { @@ -195,6 +196,28 @@ describe('ACP plugin commands', () => { expect(harness.adapter.requests).toHaveLength(0) }) + it('keeps session-reference syntax opaque in direct command arguments', async () => { + harness = await makeBridgeHarness({ storageDir }) + const command = vi.fn(() => ({ kind: 'success' as const })) + harness.ctx.commands.register({ name: 'direct', description: 'Direct', handler: command }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const sourceUri = encodeSessionReferenceUri(SessionId('source')) + + await expect(harness.client.prompt({ + sessionId, + prompt: [ + { type: 'text', text: `/direct valid=${sourceUri} malformed=dsh-session:IiJ` }, + { type: 'resource_link', name: 'source', uri: sourceUri }, + ], + })).resolves.toEqual({ stopReason: 'end_turn' }) + expect(command).toHaveBeenCalledWith(expect.objectContaining({ + rawInput: ` valid=${sourceUri} malformed=dsh-session:IiJ\n[resource_link name="source" uri=${JSON.stringify(sourceUri)}]\n`, + })) + expect(harness.adapter.requests).toHaveLength(0) + expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0) + }) + it('maps session cancellation to the in-flight command signal and isolates other sessions', async () => { harness = await makeBridgeHarness({ storageDir }) let started!: () => void