diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index f8786470c7..2c9391bc96 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -334,6 +334,9 @@ "simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml": "sha256:eef600eafd70a576b2ac16a74f5dd5010ee601376008a90be10f1da56d746cee", "simplification/2026-07-02-remove-stream-chunk-mirror.md": "sha256:0c22a896260c6eb8991cc8babd8172f7b6889fc41bb891d748f34650b3eee5ec", "simplification/2026-07-02-remove-stream-chunk-mirror.zh.md": "sha256:ae7a3c2450b16fdf2f8da9e1f83cd987bf387671ecc8ebb76d2e7541695e7ee1", + "simplification/2026-07-04-drop-image-content-block.i18n.yaml": "sha256:6af8bc85be1fc23b445b17ff96df69488afa82c7194fcb47beb7a6b7b5a1de6a", + "simplification/2026-07-04-drop-image-content-block.md": "sha256:31e619d09405044a4db4951084f8d12417868b8f255ca26a44af0a5a13d43e6b", + "simplification/2026-07-04-drop-image-content-block.zh.md": "sha256:8fa6558bbb014a38853796102099ffbd5d8151371d68d40b2bf262ad07f4c976", "simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml": "sha256:e4c992a27ae0e37e5ef663c2cddf55eefe20387fd6103bebf655834d8e75e9db", "simplification/2026-07-04-drop-inert-request-knobs.md": "sha256:8735c2b868a85b13235e0491a0fa7b9570dd090eef5170324fc5e93782687b67", "simplification/2026-07-04-drop-inert-request-knobs.zh.md": "sha256:78b243f5d580f2a6fbbdb7d26574295d6ed74feb8d9bba34bbcdf4aa87624b5c", diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.i18n.yaml similarity index 69% rename from .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml rename to .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.i18n.yaml index f51aaf86b4..f718de7c8a 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml +++ b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.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 .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md -2026-07-04-drop-image-content-block.md: 5d0357270973ac20234d8ce72c403f27ce2ad0e0 -2026-07-04-drop-image-content-block.zh.md: a13133059cdf4f6e6e8fe44e313a9795a57c10c9 +2026-07-04-drop-image-content-block.md: 362afbbe7c5c1a3b46b1e8abda3b0fec77d1a0e6 +2026-07-04-drop-image-content-block.zh.md: 07fbbd1f165887bf3305ccfa7ba137656ce0a99c diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md rename to .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md index 5d03572709..362afbbe7c 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md +++ b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md @@ -1,6 +1,7 @@ # Agent Note: Drop the `image` content block until a path can honor it Status: implemented +Archived: 2026-08-19 English | [中文](2026-07-04-drop-image-content-block.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md rename to .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md index a13133059c..07fbbd1f16 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md +++ b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md @@ -1,6 +1,7 @@ # Agent Note: 移除 `image` 内容块,直到有路径能真正处理它 Status: implemented +Archived: 2026-08-19 [English](2026-07-04-drop-image-content-block.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml index 0b150d8090..0c0e629d26 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.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 .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md -2026-06-11-content-block-vocabulary.md: 5228724bb9101307db9929aaf7831b477c2a6022 -2026-06-11-content-block-vocabulary.zh.md: b43de335917c6b5304a94b296fcb8253827b0600 +2026-06-11-content-block-vocabulary.md: a31df6a7d16ea7cba649702fdb474dab34533c1b +2026-06-11-content-block-vocabulary.zh.md: 5ac882e9de7dea02cc6534aee99c46869cc9363f diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md index 5228724bb9..a31df6a7d1 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -22,7 +22,7 @@ In-session context injection (`context/message`) and mid-turn steering originall ## Consequences - Reasoning has a core home without provider-specific shapes. -- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md). +- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md). - Cache hints and assistant prefill remain absent until a shipping adapter can honor them; see the [producer-less variants](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) and [inert request knobs](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Notes. - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. - IDs that cross package boundaries are branded (`CallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost. diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md index b43de33591..5ac882e9de 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md @@ -22,7 +22,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 ## 后果 - 推理(reasoning)在核心层有了归属,无需依赖提供方特有的结构。 -- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md)。 +- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md)。 - 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的词汇变体](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)与[无端到端可用路径的请求旋钮](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Note。 - 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。 - 跨包边界的 ID 使用品牌类型(`CallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml new file mode 100644 index 0000000000..50cdedbe5b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md +2026-08-10-cancelled-stream-prefix-finalize.md: 0cae25b786922fba8204d68ca9c0a669e43d76a0 +2026-08-10-cancelled-stream-prefix-finalize.zh.md: e961ea6a51f74dcc244e4ad8970eae4cbe4c9a6c diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md new file mode 100644 index 0000000000..0cae25b786 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md @@ -0,0 +1,39 @@ +# Agent Note: Cancelled streams finalize their delivered prefix + +Status: implemented + +English | [中文](2026-08-10-cancelled-stream-prefix-finalize.zh.md) + +## Problem + +A cancelled stream can leave `assistant/chunk` events that clients continue rendering while `deriveMessages()` excludes them because no `assistant/message` records the delivered prefix. A follow-up such as "expand on your second point" then lacks text the user read, and a fork at the cancelled turn inherits the same gap. + +The model history must contain assistant content that remains visible to the user after cancellation. + +## Decision + +`ReactLoopAgent.step()` catches cancellation while consuming a model stream, when its `BlockAssembler`, logged chunk seqs, and provider route identify the delivered prefix. It appends that prefix as the step's `assistant/message` with `interrupted: true`, `surfaceOp: 'append'`, and `sourceEventSeqs` containing exactly the logged chunks. The append precedes `step/end` and the aborted `turn/end`. + +`BlockAssembler.interruptedBlocks()` returns closed and open `text` and `reasoning` blocks with non-whitespace content in stream order. It omits tool calls because interruption precedes dispatch and no real result exists; it also omits empty blocks and open unknown block types. An empty result appends no assistant message. Provider `error` and `aborted` finishes leave the stream-consumption scope before `agent/request-error`, so provider failures and cancellation during recovery commit no content from the failed request. + +Chat and Trajectory Conversation Definitions read `interrupted` from the durable message. Chat renders the Stopped marker, while Trajectory keeps the provider request in the error lifecycle after `step/end` and retains the durable result seq and provenance. Cancellation during tool execution follows the tool scheduler contract because the assistant message has already committed: started calls produce real results, and undispatched calls receive `ABORTED_BEFORE_DISPATCH` results. + +## Alternatives considered + +**Always discard the prefix.** This avoids a new durable marker but makes every cancel-then-follow-up and fork omit assistant content that remains visible to the user. + +**Assemble the prefix from chunks during projection.** `deriveMessages()` and client Conversation Definitions would each need interruption assembly rules, and the log would have no authoritative assistant message for the prefix. This also expands model history beyond the three `SurfaceEventType` events. + +**Retain complete tool calls with synthetic aborted results.** These calls never dispatched, so synthetic results would claim an execution outcome that did not occur and add content the user did not receive as a tool result. + +**Append a model-visible interruption message such as `[interrupted by user]`.** This can tell the model that the prefix is incomplete, but it requires a separate source type, projection rule, UI treatment, and localized wording. The durable aborted `turn/end` preserves the fact needed for that later decision. + +## Consequences + +Post-cancel follow-ups and forks include the delivered prefix. The ACP bridge drains ordered assistant output before settling the prompt, so the final `agent_message_chunk` update precedes the cancelled stop reason. + +Terminal provider errors still discard their streamed prefix. That asymmetry remains because an error turn ends without the user's cancellation decision and requires its own retention policy. + +## Testing + +`packages/core/agent-loop/tests/cancel.spec.ts` covers content, cited seqs, event order, next-request parity, reasoning-only output, tool-call omission, recovery cancellation, and the empty-prefix case. `packages/llm/llm/tests/assembler.spec.ts` covers `interruptedBlocks()`. `packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` and `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` cover both client projections. The keyless `cancel` ACP snapshot and `goal-round-driver` goal snapshot cover assembled applications. diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md new file mode 100644 index 0000000000..e961ea6a51 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 被取消的流定稿其已送达前缀 + +Status: implemented + +[English](2026-08-10-cancelled-stream-prefix-finalize.md) | 中文 + +## Problem + +被取消的流可能留下客户端继续渲染的 `assistant/chunk` 事件,但如果没有 `assistant/message` 记录已送达前缀,`deriveMessages()` 就会排除这部分内容。后续的「第二点展开讲讲」之类追问会缺少用户已读到的文本,在该轮次上创建的分支也会继承这个缺口。 + +模型历史必须包含取消后仍对用户可见的 assistant 内容。 + +## Decision + +`ReactLoopAgent.step()` 在消费模型流期间捕捉取消,此时 `BlockAssembler`、已记录的分片 seq 和提供方路由可以确定已送达前缀。循环把该前缀追加为 step 的 `assistant/message`,并设置 `interrupted: true`、`surfaceOp: 'append'` 以及恰好包含已记录分片的 `sourceEventSeqs`。该追加先于 `step/end` 和记录 aborted 的 `turn/end`。 + +`BlockAssembler.interruptedBlocks()` 按流顺序返回内容非空白的已闭合和未闭合 `text` 与 `reasoning` 块。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空块和未闭合的未知块类型。返回结果为空时不追加 assistant 消息。提供方的 `error` 和 `aborted` finish 会在 `agent/request-error` 前离开流消费范围,因此提供方故障和恢复期间的取消都不会提交失败请求的内容。 + +Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted`。Chat 渲染 Stopped 标记,Trajectory 则在 `step/end` 后把提供方请求保持在 error 生命周期,并保留持久结果 seq 和提供方信息。工具执行期间的取消遵循工具调度器约定,因为 assistant 消息已提交:已启动的调用生成真实结果,未分派的调用获得 `ABORTED_BEFORE_DISPATCH` 结果。 + +## Alternatives considered + +**始终丢弃前缀。** 这能避免新增持久标记,但每次取消后的追问和分支都会缺少仍对用户可见的 assistant 内容。 + +**在投影时从分片组装前缀。** `deriveMessages()` 和客户端 Conversation Definition 都需要实现打断组装规则,日志中也没有该前缀的权威 assistant 消息。这还会让模型历史超出三类 `SurfaceEventType` 事件。 + +**保留完整工具调用并合成 aborted 结果。** 这些调用从未分派,合成结果会声称一个并未发生的执行结果,还会增加用户未收到的工具结果内容。 + +**追加 `[interrupted by user]` 之类模型可见的打断消息。** 这可以告诉模型前缀并不完整,但需要独立的来源类型、投影规则、UI 处理和本地化文案。持久的 aborted `turn/end` 保留了该后续决策所需的事实。 + +## Consequences + +取消后的追问和分支会包含已送达前缀。ACP 桥会在结算 prompt 前排空按序传送的 assistant 输出,因此最后一条 `agent_message_chunk` 更新先于 cancelled stop reason。 + +终局提供方错误仍会丢弃已流出前缀。该不对称保留,因为 error 轮次的结束不来自用户的取消决定,需要独立的保留策略。 + +## Testing + +`packages/core/agent-loop/tests/cancel.spec.ts` 覆盖内容、引用的 seq、事件顺序、下一请求的一致性、仅 reasoning 的输出、工具调用省略、恢复期间的取消和空前缀情形。`packages/llm/llm/tests/assembler.spec.ts` 覆盖 `interruptedBlocks()`。`packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts` 和 `packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts` 覆盖两种客户端投影。keyless 的 `cancel` ACP 快照和 `goal-round-driver` goal 快照覆盖完整应用。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.i18n.yaml new file mode 100644 index 0000000000..15e78356f5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md +2026-08-18-rail-search-outside-click-self-dismissal.md: 9893b3a2456b9a592e1feb107d21404e043dee78 +2026-08-18-rail-search-outside-click-self-dismissal.zh.md: 91e343b8843dc02ca9c1be2b145e79beb84e17b7 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md new file mode 100644 index 0000000000..9893b3a245 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md @@ -0,0 +1,27 @@ +# Agent Note: Rail search keeps its expansion when the opening click reaches document + +Status: implemented + +English | [中文](2026-08-18-rail-search-outside-click-self-dismissal.zh.md) + +## Problem + +The collapsed sidebar's rail search button arms the rail gesture (`searchOnExpand`), expands the search affordance (`searchExpanded`), and requests sidebar expansion — designed to land the user in a focused search input once the column slides open. In a real browser the gesture never completed: the sidebar expanded but the search box stayed closed and unfocused. + +The initiating click destroys its own effect. React dispatches the rail button's handler mid-bubble; the state flip renders the wide header and mounts the WorkspaceBrowser's outside-click dismissal listener on `document` during that same dispatch. The click then keeps bubbling and reaches `document` with the now-unmounted rail button as its target — outside `searchRoot` — so the freshly mounted listener immediately collapses the search it was opening. The package test missed this because `fireEvent.click` on the button does not re-bubble through listeners mounted during dispatch the way a real browser event does. + +## Decision + +The outside-click dismissal listener does not mount while the rail gesture is in flight: its effect returns early while `searchOnExpand` is set, and `searchOnExpand` already ends exactly when the gesture settles (focus lands in the input after the column slide). After settle, outside clicks dismiss the search as before. A regression test replays the real-browser order — rail click, wide flip, then the same click arriving at `document` — and requires the search to stay expanded through it and to dismiss on the next genuine outside click. + +## Alternatives considered + +**Stop propagation on the rail button's click.** Suppressing bubbling at the initiator couples the rail button to a listener it cannot see, and every other expansion path — a future keyboard shortcut, another rail entry — would reintroduce the bug. The listener owns dismissal, so the listener carries the guard. + +**Defer listener attachment by a frame or timeout.** A raw delay encodes the symptom (the click arrives "too early") instead of the cause (a gesture is in flight). `searchOnExpand` is already the explicit in-flight state with the correct end point; a frame boundary is neither. + +**Dismiss on `pointerdown` instead of `click`.** The initiating gesture's `pointerdown` precedes the listener mount, so it cannot self-dismiss. Rejected because it changes dismissal semantics for every interaction — a drag or a press-and-slide-away would dismiss where a completed click today does not — to fix a problem scoped to one gesture. + +## Consequences + +The rail search gesture works end to end in the assembled application, pinned by an `apps/web` real-browser scenario: a real click travels through the collapsed rail, the wide flip, and the document-level bubble, and the search stays expanded with focus landing in the input. During the in-flight window (~300 ms column slide) an outside click does not dismiss the search; that window ends the moment focus lands. The package-level regression test additionally pins the guard's timing at the unit level. diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.zh.md new file mode 100644 index 0000000000..91e343b884 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 轨道搜索在展开点击到达 document 时保持展开 + +Status: implemented + +[English](2026-08-18-rail-search-outside-click-self-dismissal.md) | 中文 + +## 问题 + +收起侧边栏的轨道搜索按钮会置位轨道手势标志(`searchOnExpand`)、展开搜索控件(`searchExpanded`)并请求侧边栏展开——设计意图是列滑开后让用户直接落在已聚焦的搜索输入框里。但在真实浏览器中这个手势从未完成:侧边栏展开了,搜索框却保持关闭且未聚焦。 + +发起手势的那次点击摧毁了它自己的效果。React 在冒泡中途派发轨道按钮的处理器;状态翻转渲染出宽态头部,并在同一次派发期间把 WorkspaceBrowser 的"点击外部收起搜索"监听器挂到 `document` 上。随后这次点击继续冒泡到达 `document`,其 target 是已卸载的轨道按钮——位于 `searchRoot` 之外——于是刚挂上的监听器立刻收起了它正要打开的搜索。包级测试没有抓到这个问题,因为 `fireEvent.click` 在按钮上触发时,不会像真实浏览器事件那样继续冒泡穿过派发期间新挂载的监听器。 + +## 决策 + +轨道手势进行期间不挂载"点击外部收起"监听器:其 effect 在 `searchOnExpand` 置位期间提前返回,而 `searchOnExpand` 本就精确终止于手势落定之时(列滑动结束、焦点落入输入框)。落定之后,外部点击照旧收起搜索。一个回归测试重放真实浏览器的顺序——轨道点击、宽态翻转、同一次点击到达 `document`——要求搜索在此过程中保持展开,并在下一次真正的外部点击时收起。 + +## 备选方案 + +**在轨道按钮的点击上阻止冒泡。** 在发起方抑制冒泡会让轨道按钮耦合到一个它看不见的监听器,而且其他每条展开路径——未来的键盘快捷键、另一个轨道入口——都会重新引入此缺陷。收起由监听器负责,守卫就应由监听器承载。 + +**将监听器挂载延迟一帧或一个定时器。** 裸延迟编码的是症状(点击来得"太早")而非成因(手势正在进行)。`searchOnExpand` 已经是带有正确终点的显式进行中状态;帧边界两者都不是。 + +**改在 `pointerdown` 上收起而非 `click`。** 发起手势的 `pointerdown` 先于监听器挂载,因而不会自我收起。被否决是因为它改变了所有交互的收起语义——拖拽或按下后滑走会触发收起,而如今完成的点击才会——只为修复一个局限于单个手势的问题。 + +## 影响 + +轨道搜索手势在组装后的应用中端到端可用,由 `apps/web` 的真实浏览器场景钉住:真实点击穿过收起轨道、宽态翻转与 document 级冒泡,搜索保持展开且焦点落入输入框。在手势进行窗口内(约 300 ms 列滑动)外部点击不会收起搜索;该窗口在焦点落定的瞬间结束。包级回归测试另外钉住了单元层面的守卫时序。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml index f797c08086..892a732be0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.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 .agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md -2026-08-18-request-image-payload-bound.md: df10df39d18c7da4660b566e8f0b6a5a60ff8dc1 -2026-08-18-request-image-payload-bound.zh.md: 070f2d194f1459f3f2728fdf9d5d2db2c3a24385 +2026-08-18-request-image-payload-bound.md: 0ec4594888db6157fb8cfd3e7bdb231b842d53c1 +2026-08-18-request-image-payload-bound.zh.md: 17b3fbb09be1aff0036c1cec389d8dd75966e010 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md index df10df39d1..0ec4594888 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md @@ -10,7 +10,7 @@ Every image in session history is base64-inlined into every model request by the ## Decision -The pi-ai provider profile carries `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, per route, changeable from cordis.yml and the `llm-pi-ai` settings section). At request conversion, `toPiContext` sums the base64 length of every image in history (derived from `ImageAttachmentRef.bytes` without reading data) and, while the sum exceeds the bound, replaces the oldest images with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Offload locations use message and nested block indexes rather than object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. `classifyPiAiError` classifies 413 and specific request-body-cap wording as `INVALID_REQUEST` (resending the same body cannot succeed). Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB request-image default therefore retains four such images and reserves the rest of a 32MiB request for system prompts, history, tools, and JSON. Deployments behind stricter gateways lower the value per route. +The pi-ai provider profile and direct DeepSeek adapter carry `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, changeable from cordis.yml and settings). The provider-neutral `offloadRequestImages` conversion sums the base64 length of every image in history from `ImageAttachmentRef.bytes` without reading data and, while the sum exceeds the bound, replaces the oldest image occurrences with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Occurrence-order replacement does not depend on object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. Both adapters classify 413 as `INVALID_REQUEST`; pi-ai also recognizes specific request-body-cap wording. Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB default therefore retains four such images and leaves headroom under the direct API's 30MiB request limit, while deployments behind stricter gateways lower the value per route. ## Offload is conversion, not history @@ -26,6 +26,7 @@ The placeholder is model-visible but not logged as a session event. It stays wit ## Related - [Per-side image dimension admission limit](2026-08-17-image-dimension-admission-limit.md) — the admission-layer companion fix; together they close the two observed session-poisoning failures (400 dimension, 413 body size). +- [Direct DeepSeek vision input](../feature/2026-08-19-direct-deepseek-vision-input.md) — applies this provider-neutral conversion to the official multimodal route. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md index 070f2d194f..17b3fbb09b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md @@ -10,7 +10,7 @@ pi-ai 适配器把会话历史中的每张图片 base64 内联进每一个模型 ## Decision -pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,按路由生效,可从 cordis.yml 与 `llm-pi-ai` settings 段修改)。请求转换时,`toPiContext` 由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。offload 位置用消息与嵌套块的索引表示,不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。`classifyPiAiError` 把 413 与明确的请求体上限措辞归类为 `INVALID_REQUEST`(原样重发不可能成功)。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 请求图片默认上限因此可保留四张这样的图片,并在 32MiB 请求内为系统提示词、历史、工具与 JSON 保留其余容量。网关更严格的部署按路由调低该值。 +pi-ai provider profile 与直接 DeepSeek 适配器都提供 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,可从 cordis.yml 与 settings 修改)。提供方无关的 `offloadRequestImages` 转换由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片出现位置开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。按出现顺序替换不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。两个适配器都把 413 归类为 `INVALID_REQUEST`;pi-ai 还会识别明确的请求体上限措辞。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 默认上限因此可保留四张这样的图片,并在直接 API 的 30MiB 请求上限下留出余量;网关更严格的部署则按路由调低该值。 ## offload 是转换而非历史 @@ -26,6 +26,7 @@ pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUE ## Related - [图片单边尺寸准入上限](2026-08-17-image-dimension-admission-limit.md),准入层的配套修复;两者合起来封住已观测到的两类会话毒化故障(400 尺寸、413 请求体)。 +- [直接 DeepSeek 视觉输入](../feature/2026-08-19-direct-deepseek-vision-input.md)把这项提供方无关转换应用于官方多模态路由。 ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml new file mode 100644 index 0000000000..711034b02c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md +2026-08-18-tool-row-file-open-failure.md: e36552395b992e688fad35b3163b92c9f6189e43 +2026-08-18-tool-row-file-open-failure.zh.md: 72b6026d9eb44c74f13c987996e652008f35a78e diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md new file mode 100644 index 0000000000..e36552395b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md @@ -0,0 +1,33 @@ +# Agent Note: Tool-row file-open failures stay visible + +Status: implemented + +English | [中文](2026-08-18-tool-row-file-open-failure.zh.md) + +## Problem + +Tool-row path clicks already call `host.openPath` through the chat view's injected `openFile`. The inject swallowed every Host or OS refusal, so a missing desktop opener, a remote or non-loopback carrier, or a path the Host cannot hand off left the row looking successful. The reader had no reason and no second try. + +The [file-open-in-OS decision](../feature/2026-07-28-tool-call-file-open-in-os.md) still owns the link gesture and the Host handoff. This note owns only the refusal. + +## Decision + +The inject returns the `workspaces.openPath` promise. The chat view wraps that opener: a rejection opens an in-page Modal with the thrown text (or the unknown-open fallback when that text is empty) and a Retry that repeats the same path; Cancel, Escape, the close control, and a mask click dismiss it. A later settlement after dismiss is ignored, so a cancelled in-flight refusal cannot reopen the dialog. + +The dialog lives on the view that owns the Host call, not on each tool row. Produced-file chips and closing-message mentions use the same wrapper because they already share that opener. The produced-files folder action opens `.`, and that refusal uses the folder title and unknown-open copy. + +The Host message is shown as thrown. `WorkspaceRuntime.openPath` prefixes `path open failed: ` onto the wire error; the dialog does not unwrap that prefix. + +## Alternatives considered + +- **Per-row inline error.** The Host call is conversation-owned and several entries share one opener; a row-local banner would duplicate the same refusal next to every click target. +- **Toast without retry.** The product ask is the reason *and* a retry entry. The workspace folder-adoption dialog already pairs those two. +- **Chat-store remount persistence.** A failed open is transient view state. The chat store survives view remounts, so a leftover dialog would return after a tab switch that cannot usefully retry the original gesture. + +## Consequences + +A silent Host refusal is no longer a success from the reader's seat. Headless or remote deployments that click a path now see why the desktop handoff did not happen. The view holds one extra request-generation counter so dismiss and retry stay race-safe. + +## Testing + +Package specs cover inject rejection, the dialog copy (Error, non-Error, empty, workspace folder), retry of the same path, cancel, and a settlement that arrives after dismiss. `apps/web/tests/seeded-history.e2e.ts` stubs `host.openPath` to fail over a cold-resumed read row, pins the assembled dialog in `file-open-failure.expected.md`, and asserts the English reason plus a second call with the same payload. diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md new file mode 100644 index 0000000000..72b6026d9e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Tool-row file-open failures stay visible + +Status: implemented + +[English](2026-08-18-tool-row-file-open-failure.md) | 中文 + +## 问题 + +工具行路径点击已经通过聊天视图注入的 `openFile` 调用 `host.openPath`。inject 吞掉了每一次 Host 或操作系统拒绝,因此缺少桌面打开器、远程或非回环载体、或 Host 无法交接的路径,都会让该行看起来像成功。读者看不到原因,也无法再试一次。 + +[用系统应用打开文件的决策](../feature/2026-07-28-tool-call-file-open-in-os.md) 仍然拥有链接手势和 Host 交接。本 Agent Note 只拥有拒绝路径。 + +## 决策 + +inject 返回 `workspaces.openPath` 的 promise。聊天视图包装该打开器:拒绝时打开页面内 Modal,展示抛出的文本(文本为空时用未知打开回退文案),并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框。关闭之后才落到的结果会被忽略,因此已取消的进行中拒绝不能再次打开对话框。 + +对话框位于 chat 视图(拥有 Host 调用),而不是每个工具行。产物文件标签和收尾消息中的提及已经共用该打开器,因此走同一包装。产物文件的文件夹操作打开 `.`,该拒绝使用文件夹标题和未知打开回退文案。 + +Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错误前加上 `path open failed: ` 前缀;对话框不拆掉该前缀。 + +## 考虑过的替代方案 + +- **按行内联错误。** Host 调用由会话拥有,多个入口共用一个打开器;行内横幅会在每个点击目标旁重复同一拒绝。 +- **没有重试的 toast。** 产品要求同时给出原因和重试入口。工作区文件夹采纳对话框已经把这两者配对。 +- **写入 chat store 并跨 remount 保留。** 打开失败是瞬时视图状态。chat store 会在视图 remount 后存活,于是残留对话框会在无法有效重试原手势的页签切换之后回来。 + +## 后果 + +从读者一侧看,静默的 Host 拒绝不再等同于成功。无头或远程部署点击路径时,能看到桌面交接为何没有发生。视图多持有一个请求世代计数器,使关闭与重试在竞态下仍然安全。 + +## 测试 + +包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本、工作区文件夹)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `host.openPath` stub 为失败,用 `file-open-failure.expected.md` 钉住组装后的对话框,并断言英文原因以及对同一 payload 的第二次调用。 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 f4d510ce7e..40f4c554ef 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 .agents/notes/implemented/feature/2026-07-21-cross-session-references.md -2026-07-21-cross-session-references.md: f49c75a9d4e3304b44d12a3df85430ffb494b8a9 -2026-07-21-cross-session-references.zh.md: 81a1970497bf175948a70bb1982f336f19d4d19e +2026-07-21-cross-session-references.md: 774a948345e3d45adbba47ef6a7edd3e6f0740b2 +2026-07-21-cross-session-references.zh.md: fd864203d17954167646edf3b1a62946f8fa3f23 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 f49c75a9d4..774a948345 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 @@ -6,33 +6,35 @@ English | [中文](2026-07-21-cross-session-references.zh.md) ## Problem -TUI users need to bring relevant work from another conversation into one new message without resuming, forking, or granting the source transcript authority over the current session. The harness already exposes exact session enumeration and raw event inspection, but every host independently parsing logs would duplicate compaction folding, filtering by cited source-event seqs, size limits, error behavior, and persistence. Encoding host markup directly into the agent message contract would also bind the core loop to one UI syntax. +Web users need to bring relevant work from another conversation into one new message without resuming, forking, or granting the source transcript authority over the current session. The harness already exposes exact session enumeration and raw event inspection, but every host independently parsing logs would duplicate compaction folding, provenance filtering, size limits, error behavior, and persistence. Encoding host markup directly into the agent message contract would also bind the core loop to one UI syntax. ## Decision -`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferenceResolver`. Hosts normalize their protocol into `SessionReferenceInput[]` and call `prepare()` before delivery. The service returns detached readable content plus an optional identified, frozen `UserMessage` snapshot; core agent packages do not parse session URIs or read another log. +`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferenceResolver`. Its outer `agent/pre-step` listener parses canonical mentions in accepted direct user messages and calls `prepare()` without adding reference behavior to a host gateway. The service returns detached readable content plus an optional identified, frozen `UserMessage` snapshot; core agent packages do not parse session URIs or read another log. -`dsh-session:` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. TUI renders that URI inside `@[label](uri)`; text-only clients may use the same inline mention. Explicit Markdown mentions reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text. +`dsh-session:` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. Web receives that URI inside the Host-produced `@[label](uri)` mention and keeps it behind an atomic session chip; text-only clients may use the same inline mention. Explicit Markdown mentions reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text. The service uses `ctx.sessionQuery.readSurface(sessionId)`, which loads one live-preferred corpus observation, folds it with the session package's canonical surface algorithm, and returns a detached header, capture seq, and current nodes. FTS is not a dependency: discovery matches id, cwd, or the latest folded title, while message bodies remain outside the candidate layer. Non-empty queries batch title observations across the visible corpus with bounded persisted-log concurrency and cancellation; a dedicated title index can replace that discovery path without changing reference identity or preparation. ## Snapshot and projection -Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partially prepared context: any read, cancellation, validation, or budget error rejects the operation before `followup()` or `steer()`. Cancellation races in-flight discovery and exact reads, so a host settles promptly even when a persistence backend cannot interrupt its pending operation; any late backend settlement is observed but cannot enqueue the message. A source is read before enqueue, so later source messages, compaction, deletion, or persistence replacement cannot change the target session. +Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partial context: any read, cancellation, validation, or budget error ends the turn before the accepted messages enter model-visible history. Cancellation races in-flight discovery and exact reads, so the listener settles promptly even when a persistence backend cannot interrupt its pending operation. A queued message captures each source when it reaches `agent/pre-step`; later source messages, compaction, deletion, or persistence replacement cannot change the context recorded in the target session. -Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compaction`. That marker is part of the compaction capability contract rather than a backend package name. When a source prompt already contains baked prefix context, projection reads only its model-hidden display content, so referencing that target later does not recursively propagate an earlier snapshot. Projection excludes shadowed pre-compaction nodes, tools and results, reasoning, injected context, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery. +Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compaction`. That marker is part of the compaction capability contract rather than a backend package name. Reference snapshots remain separate sourced `user/message` events, so projection excludes them as injected context and never recursively propagates an earlier snapshot. Projection also excludes shadowed pre-compaction nodes, tools and results, reasoning, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery. One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags or escape the data region. The same serializer drives each source's independent byte accounting. AgentLoop persists the snapshot as a sourced `user/message` immediately before the direct `user/message`; target replay therefore satisfies the model-visible/log-reconstructable invariant without a new event type, placement mode, or prompt envelope. ## Message ownership -TUI owns the snapshot/direct-message transaction without extending the generic inbox record. While the agent is idle, it installs a one-shot outer `agent/pre-step` listener before `followup()`; an enter decision receives the snapshot as another message, while rejection or an earlier ordinary discard releases the listener and writes neither message. While the agent is running, TUI calls `inject(snapshot)` then `steer(prompt)`, placing both in the next-step inbox for the same later claim. A rejecting or failed pre-step leaves that claimed pair removed; messages inserted after the claim remain pending. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this generic delivery boundary. +The service's outer `agent/pre-step` listener calls downstream listeners first and processes only an `enter` decision. It parses each accepted direct user message, preserves that message's id while replacing canonical mentions with readable labels, and inserts the frozen snapshot immediately before that message. Queue edits and queue-to-steer relocation need no reference-specific state because the final claimed messages are the input to preparation. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this context ordering. -Reference preparation is not a new steering protocol and does not create a turn by itself. Idle delivery uses `followup()` and pre-step entry; running delivery uses the shared next-step inbox while retaining snapshot order. +Reference preparation is not a new delivery protocol and does not create a turn by itself. A preparation failure terminates the already accepted turn through the agent loop's existing plugin-failure path. ## Host adapters -TUI combines session candidates with the existing `@` file provider. Candidate lookup matches case-insensitive substrings of the session id, cwd, or latest folded title, displays that title, and falls back to the session id when a title observation is absent or fails. 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, renders the readable direct content as the user message, and renders session-reference source metadata as a compact source list instead of exposing the complete JSON in the terminal. +The unified Web `@` source combines session candidates with Host-backed file discovery. Session candidate lookup matches case-insensitive substrings of the session id, cwd, or latest folded title, displays that title, and falls back to the session id when a title observation is absent or fails. Lookup follows the request's cancellation signal, and session id, cwd, and mention labels escape external control characters while the canonical URI retains the original id. + +Web exposes file and session discovery through generated Remote methods on their owning services, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. Ordinary `session.prompt` delivery carries that mention without a reference-specific API Proxy route. Replay associates the separate session-reference context with its neighboring direct message and renders a compact source summary instead of exposing the snapshot JSON. The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services. @@ -45,16 +47,16 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b - **Wait for SQLite FTS5** — rejected because snapshot correctness requires exact id reads and canonical surface folding, not content search. FTS improves discovery only. - **Put mention syntax in agent delivery methods** — rejected because it would make the core protocol parse one host's presentation syntax and prevent typed non-text hosts from sharing the semantic layer. - **Implement references separately in each host** — rejected because projection, security warning, retention, and persistence would drift across hosts. -- **Attach context to `SendOptions` and the direct prompt's inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. A domain-specific admission wrapper and the existing next-step inbox preserve the required pairing without enlarging every direct prompt. -- **Bake the prefix host-side before `followup()`** — rejected because `agent/pre-step` must inspect and rewrite only the direct prompt. Keeping the snapshot as a separate sourced message preserves that boundary and lets TUI hide background bytes from the direct user bubble. +- **Attach context to `SendOptions` and the direct prompt's inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. The domain listener can prepare the final claimed message without enlarging every direct prompt. +- **Bake the prefix host-side before `followup()`** — rejected because `agent/pre-step` must inspect and rewrite only the direct prompt. Keeping the snapshot as a separate sourced message preserves that boundary and lets Web hide background bytes from the direct user bubble. - **Replay the raw source log or restore shadowed events** — rejected because compact defines the current model surface and may intentionally retire sensitive or expensive history. - **Resume or fork the source** — rejected because the feature supplies read-only background for one target message, not identity or lifecycle continuity. -- **Inject at request time by rereading the source** — rejected because the reference would become nondeterministic, cancellation races could alter its bytes, and target replay would depend on external mutable state. +- **Reread the source after the model step enters** — rejected because target replay would depend on external mutable state instead of the logged snapshot. ## Verification -Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, terminal-control escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, prompt blocking, admission-time staging, send/steer placement, title isolation, missing capability, and compact TUI replay. One keyless terminal snapshot types a title-only substring against an opaque session id and pins the rendered candidate. Another 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 a sourced snapshot message followed by the readable current prompt, without either shadowed string. +Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, control-character escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, pre-step parsing and insertion, downstream rejection, node-owned replay association, title isolation, and the generated Remote discovery faces. A keyless Web snapshot pins the assembled reference selection path. ## Consequences -The new plugin is the stable semantic boundary and adds no persistence schema, event type, FTS dependency, source subscription, or compact shadow access. The standard TUI demo bundle mounts it explicitly and exposes its count and per-source byte limits in its config; custom hosts remain unchanged until they mount the service and adapt their input. Reference contexts increase target history size within configured bounds and can later be summarized by ordinary target compaction, after which the source session is irrelevant. +The new plugin is the stable semantic boundary and adds no persistence schema, event type, FTS dependency, source subscription, or compact shadow access. The standard CLI composition mounts it explicitly for Web and exposes its count and per-source byte limits in config; custom hosts remain unchanged until they mount the service and adapt their input. Reference contexts increase target history size within configured bounds and can later be summarized by ordinary target compaction, after which the source session is irrelevant. 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 81a1970497..fd864203d1 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 @@ -6,33 +6,35 @@ Status: implemented ## 问题 -TUI 用户需要把另一场对话中的相关工作带入一条新消息,但不恢复、不 fork,也不让源 transcript(文本记录)对当前会话拥有权威性。harness 已经提供准确的会话枚举与原始事件检查,但若每个宿主都独立解析日志,就会重复实现压缩(compaction)折叠、按被引用来源事件 seq 过滤、大小限制、错误行为和持久化。把宿主标记直接编码进 agent(智能体)消息约定,还会让核心循环绑定某一种 UI 语法。 +Web 用户需要把另一场对话中的相关工作带入一条新消息,但不恢复、不 fork,也不让源 transcript(文本记录)对当前会话拥有权威性。harness 已经提供准确的会话枚举与原始事件检查,但若每个宿主都独立解析日志,就会重复实现压缩(compaction)折叠、来源过滤、大小限制、错误行为和持久化。把宿主标记直接编码进 agent(智能体)消息约定,还会让核心循环绑定某一种 UI 语法。 ## 决策 -`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferenceResolver` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,并在交付前调用 `prepare()`。该服务返回分离的可读内容和一份可选的、带标识且冻结的 `UserMessage` 快照;核心 agent 包既不解析会话 URI,也不读取其他日志。 +`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferenceResolver` 上的单一上下文消费服务。它的外层 `agent/pre-step` 监听器会解析已接受直接用户消息中的规范 mention,并调用 `prepare()`,宿主网关无需添加引用行为。该服务返回分离的可读内容和一份可选的、带标识且冻结的 `UserMessage` 快照;核心 agent 包既不解析会话 URI,也不读取其他日志。 -`dsh-session:` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。TUI 把该 URI 渲染到 `@[label](uri)` 中;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。 +`dsh-session:` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。Web 接收由 Host 生成、包含该 URI 的 `@[label](uri)` 提及标记,并把它封装为原子 session chip;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。 该服务使用 `ctx.sessionQuery.readSurface(sessionId)`:它优先从实时会话加载一次语料观察结果,使用会话包的规范表层算法执行折叠,并返回与源数据分离的会话头、捕获序号和当前节点。FTS 不是依赖项:候选发现会匹配 id、cwd 或最新折叠后的标题,而消息主体不进入候选层。非空查询会对可见语料中的标题观察结果执行批处理,以有界并发读取持久化日志,并支持取消;专用标题索引可以替换这条发现路径,而无需改变引用标识或准备过程。 ## 快照与投影 -准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分完成的上下文:任何读取、取消、校验或预算错误都会在调用 `followup()` 或 `steer()` 前拒绝本次操作。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,宿主也能及时结束等待;后端迟到的完成结果仍会被观察,但不能让消息入队。源会话在入队前完成读取,因此源会话后续新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中的快照。 +准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分上下文:任何读取、取消、校验或预算错误都会在已接受消息进入面向模型的历史之前结束该轮次。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,监听器也能及时结束等待。queued 消息到达 `agent/pre-step` 时会捕获每个源;此后源会话新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中记录的上下文。 -投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compaction` 导出的规范来源标记的检查点用户消息。该标记属于压缩能力约定的一部分,而非某个后端包名称。当源提示词已包含合并写入的前缀上下文时,投影只读取其模型不可见的显示内容,因此后续引用该目标不会递归传播先前的快照。投影会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、注入的上下文、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。 +投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compaction` 导出的规范来源标记的检查点用户消息。该标记属于压缩能力约定的一部分,而非某个后端包名称。引用快照始终是独立且带来源的 `user/message` 事件,因此投影会把它们作为注入上下文排除,绝不递归传播早先的快照。投影还会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。 系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签,也无法逃逸数据区域。同一个序列化器会独立核算每个源的字节数。AgentLoop 会把快照持久化为一条带来源信息的 `user/message`,紧接在直接 `user/message` 之前。因此,目标回放无需新增事件类型、放置模式或提示词封套,也能满足「模型可见/日志可重建」不变量。 ## 消息所有权 -TUI 负责快照/直接消息事务,不扩展通用收件箱记录。agent 空闲时,它会在调用 `followup()` 前安装一次性的外层 `agent/pre-step` 监听器;enter 决策会把快照作为另一条消息接收,而 reject 或更早的普通丢弃会释放监听器,并且不写入任何消息。agent 运行时,TUI 会依次调用 `inject(snapshot)` 和 `steer(prompt)`,把两者放入 next-step inbox,等待后续同一次领取。pre-step reject 或失败会使这对已领取消息维持已移除状态;领取后插入的消息继续等待。这一通用交付边界由[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定。 +该服务的外层 `agent/pre-step` 监听器会先调用下游监听器,并且只处理 `enter` 决策。它会解析每条已接受的直接用户消息,在把规范 mention 替换为可读标签时保留消息 id,并把冻结快照插入到该消息紧前。最终领取的消息是准备过程的输入,因此队列编辑和从 queue 移动到 steer 不需要引用专用状态。[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定了这一上下文顺序。 -引用准备过程不是新的 steering 协议,本身也不会创建轮次。空闲交付使用 `followup()` 和 pre-step 进入决策;运行期间的交付使用共享 next-step inbox,并保持快照顺序。 +引用准备过程不是新的投递协议,本身也不会创建轮次。准备失败会通过 agent loop 的现有插件失败路径终止已经接受的轮次。 ## 宿主适配器 -TUI 把会话候选与现有 `@` 文件提供方组合在一起。候选查询会对 session id、cwd 或最新折叠后的标题执行不区分大小写的子串匹配,显示该标题,并在没有标题观察结果或标题观察失败时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把可读的直接内容渲染为用户消息,并把会话引用来源元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。 +统一的 Web `@` source 把会话候选与 Host 支持的文件发现组合在一起。会话候选查询会对 session id、cwd 或最新折叠后的标题执行不区分大小写的子串匹配,显示该标题,并在没有标题观察结果或标题观察失败时回退到 session id。查询遵循请求的取消信号;session id、cwd 和提及标签中的外部控制字符会被转义,但规范 URI 仍保留原始 id。 + +Web 通过所属服务上的生成 Remote 方法提供文件与会话发现,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范 mention 支撑的原子 chip。普通 `session.prompt` 投递会携带该 mention,无需引用专用 API Proxy 路由。回放会把独立的 session-reference 上下文与相邻直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。 [仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。 @@ -45,16 +47,16 @@ TUI 把会话候选与现有 `@` 文件提供方组合在一起。候选查询 - **等待 SQLite FTS5**:不予采纳,因为快照正确性依赖按准确 id 读取和规范表层折叠,而不是内容搜索。FTS 只改进候选发现。 - **把提及标记语法放入 agent 投递方法**:不予采纳,因为这会迫使核心协议解析某个宿主的展示语法,并阻止带类型的非文本宿主复用同一语义层。 - **在每个宿主中分别实现引用**:不予采纳,因为投影、安全警告、保留策略和持久化会在不同宿主之间逐渐偏离。 -- **把上下文附加到 `SendOptions` 和直接提示词的收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域专用的准入包装层和现有 next-step inbox 可以保持所需配对,而无需扩大每条直接提示词。 -- **在调用 `followup()` 前由宿主合并前缀**:不予采纳,因为 `agent/pre-step` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 TUI 从直接用户气泡中隐藏背景字节。 +- **把上下文附加到 `SendOptions` 和直接提示词的收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域监听器可以准备最终领取的消息,无需扩大每条直接提示词。 +- **在调用 `followup()` 前由宿主合并前缀**:不予采纳,因为 `agent/pre-step` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 Web 从直接用户气泡中隐藏背景字节。 - **回放原始源日志或恢复被遮蔽的事件**:不予采纳,因为压缩定义了当前模型表层,并且可能有意淘汰敏感或开销高昂的历史内容。 - **恢复或 fork 源会话**:不予采纳,因为本功能只为一条目标消息提供只读背景,不提供身份或生命周期连续性。 -- **在请求时重新读取源会话并注入**:不予采纳,因为这会让引用变得不确定,取消竞态可能改变其字节内容,目标回放也会依赖可变的外部状态。 +- **模型步骤进入后重新读取源会话**:不予采纳,因为目标回放会依赖可变的外部状态,而不是已记录的快照。 ## 验证 -单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、终端控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、提示词阻止、准入期间的暂存、send/steer 放置方式、标题隔离、能力缺失和压缩场景下的 TUI 回放。一个无密钥终端快照会在会话 id 不透明的情况下输入一个只与标题匹配的子串,并固定渲染出的候选项。另一个无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求包含一条带来源的快照消息,后面跟随可读的当前提示词,并且不包含任一被遮蔽的字符串。 +单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时的取消、逐源独立字节保留、冻结消息所有权、pre-step 解析和插入、下游拒绝、节点负责的回放关联、标题隔离,以及生成的 Remote 发现接口。一个无密钥 Web 快照会固定组装后的引用选择路径。 ## 后果 -新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 TUI 演示组合包会显式挂载它,并在自身配置中暴露引用数量和逐源字节上限;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。 +新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 CLI 组合会为 Web 显式挂载它,并在配置中暴露引用数量和逐源字节上限;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 842f2c9cd3..73b81c1686 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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 .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 6a12380992c3205d6bb3f5701f093b3335fae2f9 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: a8e94154e435a5359e7928d3b11eba7d1aee92f7 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 83293fbe9bd4e8a7b4422dbbe8495cf9f48bcdb5 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 5c524da9cbed7b330c3823812c8a71aed7aa74b0 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 6a12380992..83293fbe9b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -8,7 +8,7 @@ English | [中文](2026-07-22-web-multimodal-image-input-and-durable-attachments Before this change, the Web composer accepted only text: `InputBar` received a string draft, `ConversationController.send()` created text content, and the host forwarded that content to the agent. Users could not paste an image, inspect it before sending, submit an image-only prompt, or recover sent images from history. -This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../implemented/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content. +This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../archived/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content. The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [GUI layering and RPC protocol](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle. @@ -124,7 +124,7 @@ Model catalog entries gain optional merge-extensible input modality declarations The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection announces through the composer's transient toast. -The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. +Pi-AI and the direct DeepSeek adapter resolve `ctx.attachments` at request time, recursively convert each durable image reference including references nested inside tool results, and emit native image content only for models that declare image input. The direct route accepts configured image-capable catalog entries but does not advertise a vision model until its endpoint is ready; its Flash, Pro, custom models without an image declaration, and unlisted pass-through ids remain text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. No adapter may flatten or skip a retained image; unsupported roles and models fail with typed `UNSUPPORTED_CONTENT`. Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. @@ -152,7 +152,7 @@ Malformed base64, unsupported or mismatched media, truncated image payloads, exc | `packages/attachment/attachment-local` | Private content-addressed storage, complete raster decoding, integrity verification, and configuration. | | `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. | | `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. | -| `packages/llm/llm-deepseek` | Reject image content explicitly. | +| `packages/llm/llm-deepseek` | Resolve declared official vision input and reject images for text-only models. | | `packages/compaction/compaction-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. | | `packages/host/apiproxy` and `packages/bundle/base` | Narrow upload wire, shared batch admission, limits and routed-model preflight, persist-before-event ordering, session-authorized reads, and default profile composition. | | `packages/client/connection` and `packages/client/runtime` | Bounded request buffering, wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. | @@ -165,7 +165,7 @@ The attachment packages form the interface/implementation side of one capability ### Implementation -The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage. +The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI and direct DeepSeek input conversion, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage. No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index a8e94154e4..5c524da9cb 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -8,7 +8,7 @@ Status: implemented 在此变更之前,Web 输入区仅接受文本:`InputBar` 接收字符串草稿,`ConversationController.send()` 创建文本内容,宿主再把该内容转发给 agent(智能体)。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词,也无法从历史记录中恢复已发送图片。 -这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../implemented/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 +这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../archived/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 [Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[GUI 分层与 RPC 协议](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。 @@ -124,7 +124,7 @@ Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都 宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 的短时 toast 播报。 -Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 +Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。直接路由接受已配置且支持图片的 catalog 配置项,但在视觉模型端点就绪前不会公布视觉模型;其 Flash、Pro、未声明图片能力的自定义模型和未列出原样传递 id 仍仅支持文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。任何适配器都不得将保留的图片展平或跳过;不支持的角色与模型会以类型化的 `UNSUPPORTED_CONTENT` 失败。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 @@ -152,7 +152,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme | `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 | | `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 | | `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 | -| `packages/llm/llm-deepseek` | 明确拒绝图片内容。 | +| `packages/llm/llm-deepseek` | 解析已声明的官方视觉输入,并拒绝纯文本模型的图片。 | | `packages/compaction/compaction-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 | | `packages/host/apiproxy` 和 `packages/bundle/base` | 范围狭窄的上传协议、共享批量准入、限制和路由模型前置检查、先持久化再追加事件的顺序、会话授权读取,以及默认 profile 组合。 | | `packages/client/connection` 和 `packages/client/runtime` | 有界请求缓冲、协议类型、fixture(测试前置数据)图片、提示词上传、附件读取和持久引用折叠。 | @@ -165,7 +165,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme ### 实现 -已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。 +已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 与直接 DeepSeek 输入转换、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml new file mode 100644 index 0000000000..46f6c117eb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md +2026-07-27-web-file-and-session-references.md: ad8e5c53832a567bd38d1d1e560122cb8b630daa +2026-07-27-web-file-and-session-references.zh.md: acb016866efc42ef3ea9f661cb10ee1459cf1a6b diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md new file mode 100644 index 0000000000..ad8e5c5383 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md @@ -0,0 +1,49 @@ +# Agent Note: Web file and session references + +Status: implemented + +English | [中文](2026-07-27-web-file-and-session-references.zh.md) + +## Problem + +The Web composer had a reusable slash/reference trigger pipeline, but its `@` source was inert subagent-label text. Web needed Host-backed workspace-path discovery and structured cross-session snapshots without scanning the Host filesystem in the browser or binding session identity to a display label. + +## Decision + +Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-client-ui-reference`. For each unquoted query it starts both Remote discovery calls concurrently and deterministically orders files before sessions with locale-registered labels; non-selectable file and session section headings distinguish the two contiguous candidate sections without entering the keyboard-selection index. An open quoted token searches files only. Either candidate domain may fail independently without hiding successful rows from the other. + +The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared `@path` token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the generated Remote namespaces and shared grammar. A file pick remains path-only prompt text and a directory pick retriggers completion below its trailing slash. + +A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. Ordinary `session.prompt` delivery carries that mention unchanged. The session-reference service parses accepted direct user messages at `agent/pre-step`, captures every source, replaces the canonical mention with readable text while preserving the direct message id, and inserts the frozen snapshot immediately before that message. The API Proxy contains no reference-specific route, dependency, or error code. + +The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or prompt transport failure returns the same draft to editing. After acceptance, reference preparation belongs to the agent turn; a malformed mention, failed source read, cancellation, or budget failure terminates that turn. The logged prompt remains the replay authority. The concrete user and steering chat-node definition associates labels from an immediately preceding session-reference context, so the renderer receives the association from its own node data and shows a compact source summary instead of snapshot JSON. + +## Reference transaction + +```text +type @ → parallel file/session Remote calls → pick path text or canonical session chip + → serialize draft → ordinary session.prompt enqueue + → agent/pre-step parses mentions → capture sources → context + readable prompt +``` + +File lookup is advisory and cancellable; selection itself performs no read. Session preparation is all-or-nothing for one accepted model step. A queued message captures each source when the message is claimed, so queue edits and queue-to-steer relocation use the same path without gateway coordination. + +## Alternatives considered + +**Implement file discovery and grammar inside the Web client.** Rejected because browser-side code cannot safely access the Host workspace, while duplicating grammar, ranking, bounds, and invalidation would drift from the Host provider. + +**Scan files through ordinary filesystem-tool RPCs.** Rejected because recursive fuzzy discovery is editor latency work, not a model-facing exact filesystem operation, and would couple the menu to tool policy and provider round trips. + +**Eagerly attach selected file contents.** Rejected because selection would spend context before relevance is known and bypass the logged, auditable `read` call/result sequence. + +**Represent sessions as plain `@label` text.** Rejected because labels are neither stable nor unique and cannot identify the source snapshot. Canonical Host-produced mentions preserve opaque session identity while keeping a readable display. + +**Clear the composer before prompt admission settles.** Rejected because a transport or admission failure would lose the only editable copy of the request and visually claim acceptance that never occurred. + +## Verification + +Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-reference and adjacent-text reference projection, codec round-trip, generated Remote type inference, pre-step preparation, downstream rejection, and chat-node-owned label association. The keyless assembled Web snapshot renders the available reference sections, selects a file, then selects a session reference through the real client composition. + +## Consequences + +Web now uses the shared `@file` discovery seam and structured session-reference identity, while Host services remain the authority for filesystem and session access. File and session discovery are unary Remote contracts on the owning services, so generated client types replace handwritten RPC interfaces and browser bundles remain free of Node APIs. Candidate lookup failures remain quiet menu degradation. Reference preparation failures occur after prompt acceptance and end the agent turn. File references cost only path text plus stable conditional guidance, whereas session references retain the bounded snapshot cost and trust framing owned by `dsh-session-reference`. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md new file mode 100644 index 0000000000..acb016866e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md @@ -0,0 +1,49 @@ +# Agent Note: Web 文件与会话引用 + +Status: implemented + +[English](2026-07-27-web-file-and-session-references.md) | 中文 + +## 问题 + +Web 输入框已有可复用的斜杠命令/引用触发流水线,但它的 `@` source 只是不会产生实际作用的 subagent 标签文本。Web 需要由宿主提供工作区路径发现和结构化跨会话快照,同时避免在浏览器中扫描宿主文件系统或把会话身份绑定到显示标签。 + +## 决策 + +Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 与 `@session` 菜单。每次处理未加引号的查询时,它会并发启动两项 Remote 发现调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的标签;不可选择的文件与会话分组标题会区分两个连续的候选分组,且不会进入键盘选择索引。尚未闭合的带引号 token 只搜索文件。任一候选领域都可以独立失败,不会隐藏另一领域成功返回的行。 + +文件功能遵循由三个包构成的 seam:`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 `@path` token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费生成的 Remote 命名空间与共享语法。选择文件后仍只会把路径文本写入提示词,选择目录则会在其尾部斜杠后重新触发补全。 + +选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` mention。普通 `session.prompt` 投递会原样携带该 mention。session-reference 服务会在 `agent/pre-step` 解析已接受的直接用户消息,捕获每个源,在保留直接消息 id 的同时把规范 mention 替换为可读文本,并把冻结快照插入到该消息紧前。API Proxy 不包含引用专用路由、依赖或错误码。 + +输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或提示词传输失败后,同一草稿会回到可编辑状态。接受后,引用准备属于 agent 轮次;格式错误的 mention、源读取失败、取消或预算失败会终止该轮次。已记录的提示词仍是回放权威。具体的 user 和 steering chat-node 定义会关联紧邻前一条 session-reference 上下文中的标签,因此渲染器会从自身节点数据接收关联信息,并显示精简的来源摘要,而不是快照 JSON。 + +## 引用事务 + +```text +type @ → parallel file/session Remote calls → pick path text or canonical session chip + → serialize draft → ordinary session.prompt enqueue + → agent/pre-step parses mentions → capture sources → context + readable prompt +``` + +文件查询仅供参考且可取消;选择操作本身不会读取文件。会话准备针对一个已接受的模型步骤保持全有或全无。queued 消息被领取时会捕获每个源,因此队列编辑和从 queue 移动到 steer 使用同一路径,无需网关协调。 + +## 备选方案 + +**在 Web 客户端内部实现文件发现与语法。** 不予采纳,因为浏览器侧代码无法安全访问宿主工作区,而且重复的语法、排序、边界和失效处理会与宿主提供方产生偏差。 + +**通过普通文件系统工具 RPC 扫描文件。** 不予采纳,因为递归模糊发现属于编辑器低延迟工作,而不是面向模型的精确文件系统操作;该方案还会把菜单与工具策略及提供方往返绑定。 + +**选择文件时立即附加其内容。** 不予采纳,因为该方案会在尚未确定相关性时消耗上下文,并绕过可从日志重建、可审计的 `read` 调用/结果序列。 + +**用普通 `@label` 文本表示会话。** 不予采纳,因为标签既不稳定也不唯一,无法标识源快照。宿主生成的规范提及标记既能保留不透明会话身份,也能保持显示内容易读。 + +**提示词准入结算前清空输入框。** 不予采纳,因为传输或准入失败会丢失请求唯一可编辑的副本,并在视觉上错误表示一个从未成功的接受操作。 + +## 验证 + +包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻引用及相邻文本条件下的引用投影、codec 无损往返、生成的 Remote 类型推断、pre-step 准备、下游拒绝,以及 chat node 自有的标签关联。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择文件和会话引用。 + +## 后果 + +Web 现在使用共享的 `@file` 发现 seam 和结构化会话引用身份,宿主服务仍然是文件系统与会话访问的权威来源。文件和会话发现都是所属服务上的一元 Remote 契约,因此生成的客户端类型会替代手写 RPC 接口,浏览器 bundle 中也不包含 Node API。候选查询失败仍会让菜单静默降级。引用准备失败发生在提示词已接受之后,并会结束 agent 轮次。文件引用只产生路径文本和稳定的条件式指引成本,而会话引用仍保留 `dsh-session-reference` 所拥有的有界快照开销与信任限定文本。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 398f01b94a..480efde65e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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 .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: cdf2b12e69b359b8ab00d4b107aa78a21691ac4e -2026-07-27-web-subagent-conversations.zh.md: 697bfeef86e2ef456aed6ee6ad8eae1173c96a6f +2026-07-27-web-subagent-conversations.md: d20e55429d04d4308ae26d001575e664058c7dea +2026-07-27-web-subagent-conversations.zh.md: 67c9b5224abea6c0710303ae56e315ba34c7ac09 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index cdf2b12e69..d20e55429d 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -85,7 +85,7 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence **Put the adapter in the webserver.** Rejected because catalog and continuation are channel-independent client capabilities; the webserver only carries validated messages. -**Create a new UI package.** Rejected because `ui-subagent` already owns Web subagent references and is the coherent owner for catalog and addressed-child presentation. +**Put Host-backed file and session references in this package.** Rejected because catalog and addressed-child presentation depend on subagent lineage, while combined reference discovery is a separate Host capability consumed by [`ui-reference`](../../../../packages/client/ui-reference/README.md). **Auto-resume an absent parent.** Rejected because continuation requires the exact live direct parent. Child navigation must not mutate the parent lifecycle. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 697bfeef86..67c9b5224a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -85,7 +85,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 **将适配器放入 webserver。** 不予采纳,因为目录与继续执行是通道无关的客户端能力;webserver 只承载已校验的消息。 -**新建 UI 包。** 不予采纳,因为 `ui-subagent` 已经负责 Web subagent 引用,也是目录与已寻址 child 呈现的统一 owner。 +**把由 Host 支撑的文件与会话引用放进本包。** 不予采纳,因为目录与已寻址 child 呈现依赖 subagent 谱系,而组合引用发现是独立的 Host 功能,由 [`ui-reference`](../../../../packages/client/ui-reference/README.md) 消费。 **自动恢复缺失的 parent。** 不予采纳,因为继续执行要求确切的存活直接 parent。child 导航不得改变 parent 生命周期。 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml index 5e378099c9..393f7c17b9 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.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 .agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md -2026-07-28-tool-call-file-open-in-os.md: d78cf4da2c1861a66a0cefb24dba785c7cb1279d -2026-07-28-tool-call-file-open-in-os.zh.md: c13f6e60c0c70a3036f50c678c7b348c91f874c3 +2026-07-28-tool-call-file-open-in-os.md: 08fc51cc3a5d2fb43b67dc158fd1ee789fafdb68 +2026-07-28-tool-call-file-open-in-os.zh.md: 59079533b502d234bafb0b53c123e5895e105833 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md index d78cf4da2c..08fc51cc3a 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -23,9 +23,9 @@ File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `fil ## Consequences -Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`. +Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`. A Host or OS refusal is owned by the chat view: it shows the thrown reason and retries the same path ([file-open failure](../bug-fix/2026-08-18-tool-row-file-open-failure.md)). ## Risks -- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat row stays silent while the host returns an internal error. +- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat view shows that Host error and offers retry. - Relative paths without a session cwd are forwarded verbatim and may fail on the host. diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md index c13f6e60c0..59079533b5 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -23,9 +23,9 @@ Status: implemented ## 后果 -点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。 +点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。Host 或操作系统拒绝由聊天视图拥有:它展示抛出的原因,并对同一路径提供重试([打开失败](../bug-fix/2026-08-18-tool-row-file-open-failure.md))。 ## 风险 -- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天行保持静默,宿主返回内部错误。 +- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天视图展示该 Host 错误并提供重试。 - 没有会话 cwd 时相对路径会原样转发,可能在宿主侧失败。 diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml index 3639c8da6b..b7f4e2e031 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.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 .agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md -2026-08-08-pi-ai-per-model-reasoning-declarations.md: b6264feeb724e3693078fa3fc3e3fc16ed01aacb -2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: 1b30f7e0c42974c777a535e133a47caa217e2e5e +2026-08-08-pi-ai-per-model-reasoning-declarations.md: 0e8d5c3ca4017e89332f6b22e4eb0a06062918e6 +2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: c4c060b21c8b7ee5b11b5e98c8d7da0ba6c032fc diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md index b6264feeb7..0e8d5c3ca4 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md @@ -14,7 +14,7 @@ Two adjacent gaps compounded this. pi-ai decides the reasoning *wire dialect* (` `PiAiModelProfile` gains `reasoningEfforts`: **each key is a level selectors offer, its value the spelling dispatch sends on the wire**. The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with all seven levels decided explicitly — declared levels carry their wire value, undeclared levels are pinned `null` — so the profile author never needs pi-ai's asymmetric defaulting rule (absent means "supported" for the five base levels but "unsupported" for `xhigh`/`max`). `off` is the one three-state key: left out, no Off is offered and an explicit Off request is refused (an effortless request still goes out bare, leaving the provider its default); declared valueless, Off is offered and dispatch sends nothing (the `deepseek` dialect sends `thinking: {type: "disabled"}`); declared with a value, that value goes on the wire. `false` declares a non-reasoning model; an empty declaration is refused rather than guessed at. The spelling for "disable" is `false` rather than `{}` because schemastery materializes an absent dict as `{}` — only a `z.union([z.const(false), dict])` keeps absent, disabled, and declared distinguishable, and a bare `reasoningEfforts:` (YAML null) slips through that union unvalidated, so resolution refuses it explicitly. -`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. They exist only on `openai-completions` (pi-ai types them nowhere else): a model-level switch on another protocol fails resolution, a route-level default skips such models, and a route with no completions model at all is refused. The two `chat-template` formats stay withheld for want of `chatTemplateKwargs`. Both enums are pinned to pi-ai's types through `Record` drift gates, so a pi-ai upgrade that adds a format fails compilation until the new member is classified (verified against the published 0.84.1 tarball, whose `thinkingFormat` union adds `baseten` over the pinned 0.82.1). +`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. `thinkingFormat` is pinned to pi-ai's union through a `Record` drift gate, so a pi-ai upgrade that adds a format fails compilation until the new member is classified (verified against the published 0.84.1 tarball, whose `thinkingFormat` union adds `baseten` over the pinned 0.82.1). Which fields `compat` carries, which protocols take each of them, and how an unreadable key is refused are owned by [[2026-08-18-pi-ai-wire-compat-surface]]; the two-level resolution order above is what that surface generalizes. `modelOverrides` reshapes individual catalog models without replacing the served set: key = catalog model id, value = a `models` entry minus `id`, materialized by handing the override to the existing entry path so capacities, efforts, compat, and request-default semantics stay identical. Unlike Pi's own config layer, which ignores unknown ids, every override that lands nowhere is refused — beside a `models` list, on a hand-declared route, naming an unknown model, or smuggling an `id` in the value (the schema passes unknown keys through, and a smuggled id would quietly rename the model). diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md index 1b30f7e0c4..c4c060b21c 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md @@ -14,7 +14,7 @@ Status: implemented `PiAiModelProfile` 新增 `reasoningEfforts`:**每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写**。该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,七个档位全部显式决定——已声明的档位携带自己的协议值,未声明的档位一律固定为 `null`——因此 profile 作者永远不需要了解 pi-ai 那条不对称的默认规则(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」)。`off` 是唯一的三态键:不写,选择器不提供 Off,显式请求 Off 会被拒绝(不点名档位的请求仍会不带参数地发出,提供方保留自己的默认行为);声明而不给值,则提供 Off,分派什么也不发送(`deepseek` 方言发送 `thinking: {type: "disabled"}`);声明并给值,该值就在协议中发送。`false` 声明一个不具备推理能力的模型;空声明会被拒绝,而不是去猜。「禁用」的拼写取 `false` 而非 `{}`,因为 schemastery 会把缺席的字典物化成 `{}`——只有 `z.union([z.const(false), dict])` 才能让缺席、禁用与已声明三态保持可区分;而裸写的 `reasoningEfforts:`(YAML null)会不经校验地从该 union 溜过去,因此解析对它显式拒绝。 -`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。两者只存在于 `openai-completions` 上(pi-ai 也只在这一协议上为它们建了类型):在其他协议的模型上设模型级开关会使解析失败,路由级默认值会跳过这类模型,而完全没有 completions 模型的路由则被拒绝。两个 `chat-template` 格式因缺 `chatTemplateKwargs` 而继续保持不开放。两个枚举都经 `Record` 漂移门禁钉在 pi-ai 的类型上,因此新增格式的 pi-ai 升级会编译失败,直到新成员被归类(对照已发布的 0.84.1 tarball 验证过:其 `thinkingFormat` 联合类型相对钉住的 0.82.1 新增了 `baseten`)。 +`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。`thinkingFormat` 经 `Record` 漂移门禁钉在 pi-ai 的联合类型上,因此新增格式的 pi-ai 升级会编译失败,直到新成员被归类(对照已发布的 0.84.1 tarball 验证过:其 `thinkingFormat` 联合类型相对钉住的 0.82.1 新增了 `baseten`)。`compat` 承载哪些字段、每个字段由哪些协议接受、以及无法读取的键如何被拒绝,归 [[2026-08-18-pi-ai-wire-compat-surface]] 所有;上面这条两级解析顺序正是该面所推广的东西。 `modelOverrides` 就地重塑单个 catalog 模型而不替换所服务的集合:键 = catalog 模型 id,值 = 去掉 `id` 的 `models` 条目,物化时把覆盖交给既有的条目路径,因此容量、档位、compat 与请求默认值语义完全一致。与忽略未知 id 的 Pi 自有配置层不同,凡是落不到任何地方的覆盖都会被拒绝——与 `models` 列表并存、写在手工声明的路由上、点名未知模型,或在值里夹带 `id`(schema 会放行未知键,被夹带的 id 会悄悄把模型改名)。 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml index fcad94d796..1020c5a243 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.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 .agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md -2026-08-11-workspace-sidebar-order-and-folding.md: 3a88a61ca25550f1ad803a79e171ae2a7b8d4820 -2026-08-11-workspace-sidebar-order-and-folding.zh.md: e3e710bb9f38bcefcc9eeb50983c866ec5bc2619 +2026-08-11-workspace-sidebar-order-and-folding.md: d683d782454bb9fe1fad1fdc1d1a5fc3184a697b +2026-08-11-workspace-sidebar-order-and-folding.zh.md: 99e1991cfbb7b1540235ab0190defb31ad0ed6d7 diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md index 3a88a61ca2..d683d78245 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md @@ -28,7 +28,7 @@ The combined view menu offers **Manual** and **Last updated** in grouped and fla Workspace hit testing uses the complete rendered group section, including visible Session rows. One insertion boundary is shared by the preceding group's lower half and the following group's upper half, and the indicator is an absolutely positioned line with a joined right-facing chevron that does not affect layout. A tree-body overlay draws the first boundary at the same negative offset outside the scrolling clip, so the leading chevron remains visible without moving the list. During a Workspace or Session drag, document-level `dragover` and `drop` handlers accept the native operation; if release occurs outside the Workspace list, `dragend` commits the last valid marker. -Search is a header action while collapsed and expands across the title and trailing actions. An outside click collapses a query that is empty after trimming but retains a non-empty query. Compact Workspace and Session rows, a 24px bottom fade, and the absence of per-Workspace Session counts preserve vertical space without removing navigation affordances. +Search is a header action while collapsed and expands across the title and trailing actions. An outside click collapses a query that is empty after trimming but retains a non-empty query; while the rail search gesture is still in flight the outside-click listener stays unmounted ([rail-search self-dismissal](../bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md)). Compact Workspace and Session rows, a 24px bottom fade, and the absence of per-Workspace Session counts preserve vertical space without removing navigation affordances. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md index e3e710bb9f..99e1991cfb 100644 --- a/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md @@ -28,7 +28,7 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行。前一分组的下半部与后一分组的上半部共享同一条插入边界,指示器是一条带有相连右向尖角且不影响布局的绝对定位横线。树主体覆盖层会在滚动裁切区外以相同的负偏移绘制第一条边界,因此左侧尖角保持可见,列表位置也不会改变。Workspace 或 Session 拖拽期间,文档级 `dragover` 与 `drop` 处理器会接受原生操作;若在 Workspace 列表外松手,`dragend` 会提交最后一个有效标记。 -搜索在折叠时是区头操作,展开后占据标题与尾部操作的空间。查询经清除首尾空白后为空时,点击外部会收起搜索;非空查询则会保留。紧凑的 Workspace 与 Session 行、24px 底部渐隐以及取消每个 Workspace 的 Session 数量共同节省纵向空间,同时保留导航入口。 +搜索在折叠时是区头操作,展开后占据标题与尾部操作的空间。查询经清除首尾空白后为空时,点击外部会收起搜索;非空查询则会保留;轨道搜索手势仍在进行期间,外部点击监听器保持未挂载([轨道搜索自我收起](../bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md))。紧凑的 Workspace 与 Session 行、24px 底部渐隐以及取消每个 Workspace 的 Session 数量共同节省纵向空间,同时保留导航入口。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml new file mode 100644 index 0000000000..9a9f1614fd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md +2026-08-18-pi-ai-wire-compat-surface.md: 3da2db1ebdf67bcfaf8c872491356b0ef7d0ca89 +2026-08-18-pi-ai-wire-compat-surface.zh.md: ff9870f5863fb96ee026dfab1b96b4e1f3e6e238 diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md new file mode 100644 index 0000000000..3da2db1ebd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md @@ -0,0 +1,52 @@ +# Agent Note: pi-ai Wire-Compatibility Surface in llm-pi-ai + +Status: implemented + +English | [中文](2026-08-18-pi-ai-wire-compat-surface.zh.md) + +## Problem + +pi-ai shapes every request from the provider id and the baseURL — which role carries the system prompt, which field caps output, whether `store` and `stream_options` go out, whether tool definitions carry `strict`. For an endpoint its detection does not recognize, the answer is "this is OpenAI itself": `detectCompat` returns `supportsDeveloperRole: true`, `maxTokensField: "max_completion_tokens"`, `supportsStore: true`. A hand-declared route is by construction an endpoint pi-ai does not ship, so every such route received OpenAI's own request shape. + +The adapter offered two of pi-ai's thirty compat fields ([[2026-08-08-pi-ai-per-model-reasoning-declarations]] scoped them to "the switches pi-ai's reasoning dispatch reads"), and `supportsDeveloperRole` fell inside that scope while being absent from it: its send site is `model.reasoning && compat.supportsDeveloperRole`. A hand-declared model declaring `reasoningEfforts` therefore sent its system prompt as `role: "developer"`, which most OpenAI-compatible gateways reject, and no configuration could say otherwise — the gateway could not be connected at all. + +Writing the field anyway was worse than unsupported. schemastery passes unknown keys through, and resolution read only two names, so `compat: {supportsDeveloperRole: false}` validated, persisted, and was then dropped: the operator saw an accepted write and an unchanged failure. `maxTokensField` carried the same defect over a wider blast radius, since it shapes every request rather than only a reasoning model's. + +## Decision + +One drift gate per pi-ai compat type — keyed `Record` — classifies every upstream field as `offer` or `withhold`. Thirty distinct fields, twenty offered. The line is what a private URL can imply: a deployment must be able to state what nothing can infer from an unrecognized endpoint, while a field pi-ai's installed catalog sets for a named vendor stays withheld, because a route reaching for `openRouterRouting` or `deferredToolsMode` is a catalog route that should be named as such and inherit the value. + +`PiAiCompatProfile` stays an explicit interface with per-field JSDoc — it is what a configuration surface renders and what `docs/config-catalog.md` pastes — and a type-level `AssertNever` over the symmetric difference proves it names exactly the offered set. The schemastery schema is declared `z`, and `exactOptionalPropertyTypes` is what makes that annotation load-bearing in both directions, so the four faces lock together: an upstream field added, a gate entry missing, an interface field forgotten, or a schema key omitted each fails compilation. Field *types* are derived from upstream rather than restated, and a second proof pins the profile assignable to the upstream compat types, so a widened value union cannot silently narrow what configuration accepts — the cast to `ModelCompat` at materialization would otherwise hide it. + +Protocol applicability is per field, and grouping follows the compat *type* rather than the protocol name: pi-ai gives `openai-responses`, `azure-openai-responses`, and `openai-codex-responses` one `OpenAIResponsesCompat`, so a switch settable on one is settable on all three. Keying by protocol name alone refused two shipped catalog routes the fields their own models declare. The protocol set is derived from `Model.compat`'s own conditional, so a release that gives a further protocol a compat type fails the gate list by name. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it. `chatTemplateKwargs` is offered, which is what makes the two `chat-template` thinking formats nameable; nothing cross-checks that pairing, because the format in force may come from the catalog entry or from pi-ai's detection, neither of which resolution can read. + +Three kinds of `compat` key are refused where they are written rather than dropped: one no protocol declares, one a gate withholds, and one written with no value. The check runs over every key before any protocol resolves, so a misspelling fails even on a route whose models never reach the protocol that would have taken it. It reads raw keys deliberately: a withheld or undeclared name is absent from the schema, so schemastery cannot have materialized it and a person wrote it. The valueless case is the one that has to fail rather than be ignored — schemastery passes a YAML bare key through as null, and carrying it forward writes null over the installed catalog's value, leaving pi-ai's `??` reaching for its baseURL detection with the catalog layer skipped entirely. Fields carrying a value are then filtered separately, because schemastery materializes an absent dict as `{}` and `chatTemplateKwargs` is present on every parsed profile whether or not anyone wrote one. + +## Where a refusal lands + +Every check runs in `resolveProfiles`, which no request path re-enters: the adapter memoizes by raw-snapshot identity and `apply` resolves once eagerly. A refusal therefore reaches `settings.mutate` as `settings-rejected` before persistence, a `cordis.yml` `config:` block as a failed plugin mount, and a stored section as a failed `settings.register` at startup. + +An external edit to the settings file is the one path that cannot report: the provider watcher calls `publish()`, which catches a failing section, logs `settings: keeping last good "%s"`, and leaves the namespace serving its previous value. That is the settings seam's behavior for every schema and validator failure, not something this surface introduces, and closing it belongs to that seam rather than here. What changes for compat is the failure model, not the reporting: a key that formerly stayed inert forever now stops the next start. + +## Alternatives considered + +**Add `supportsDeveloperRole` alone.** It fixes the reported gateway and leaves `maxTokensField` — which shapes every request, not only a reasoning model's — breaking a whole class of endpoints, with the next upstream addition free to lag silently again. + +**Offer every upstream field.** pi-ai's own custom-provider documentation converges on a far smaller set, its flagship example naming six, and the remainder are vendor-bound switches its catalog already sets. Exposing `zaiToolStream` or `vercelGatewayRouting` on a hand-declared route offers a knob whose correct use is to not be a hand-declared route. + +**Key `compat` by protocol** (`compat: {openai-completions: {…}}`). A hand-declared route has exactly one `api`, so the nesting states what the route already said, and it breaks every profile written against the flat shape for nothing. + +**Accept an opaque passthrough dict.** The schema is also the shape a configuration surface renders and the declaration `verify-config-catalog` cross-checks, both of which an unstructured dict defeats; it would also let a responses-only field land on a completions model, which per-field applicability exists to refuse. + +**Warn instead of refusing an unknown key.** That is the posture that hid this defect for the life of the surface: an accepted write and an unchanged failure teaches the operator that the switch does not work, not that the name is wrong. + +**Suggest a near spelling on an unknown key.** No repository utility computes edit distance, and adding a dependency or hand-rolling one under the per-file coverage gate is disproportionate for a diagnostic. Naming the offered fields answers the same question deterministically: the vocabulary check runs before any protocol resolves, so it names the whole offered set, while the per-protocol refusal narrows to what that protocol takes. + +## Consequences + +- An OpenAI-compatible gateway that rejects the `developer` role, `max_completion_tokens`, `store`, `stream_options`, or `strict` is now configuration rather than an unreachable provider, and the same holds for an Anthropic-compatible gateway rejecting `temperature` or tool `cache_control`. +- A pi-ai upgrade that adds a compat field fails the build until someone classifies it, which is how `chatTemplateKwargs` and the `chat-template` formats stopped being a standing exception. +- Unknown compat keys join every other configuration error's failure model. The improvement over the previous silent drop is bounded by the settings seam: an external file edit still keeps its last good value and warns, so the operator's signal is a restart rather than the write. +- **Deferred, not closed:** a route that repoints `api` and configures no compat at all keeps the installed entry's `compat` through the model literal's `...base` spread, in the *other* protocol's shape. Fields several compat types share (`supportsLongCacheRetention`, `sendSessionAffinityHeaders`) therefore cross protocols. It predates this surface — the early return it rides existed before — and is left for its own change. +- **Deferred, not closed:** `publish()` reports a rejected stored section only through `ctx.logger.warn`, with no user-visible channel. It affects every settings namespace and is owned by `dsh-settings`. +- [[2026-08-08-pi-ai-per-model-reasoning-declarations]] is partially superseded: its compat-scope statements are restated here, while its `reasoningEfforts` shape, the alternatives that shape beat, and `modelOverrides` remain the current authority. diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md new file mode 100644 index 0000000000..ff9870f586 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md @@ -0,0 +1,52 @@ +# Agent Note: pi-ai Wire-Compatibility Surface in llm-pi-ai + +Status: implemented + +[English](2026-08-18-pi-ai-wire-compat-surface.md) | 中文 + +## Problem + +pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状——系统提示词由哪个角色承载、输出上限写在哪个字段、是否发出 `store` 与 `stream_options`、工具定义是否携带 `strict`。对于其检测无法识别的端点,答案是「这就是 OpenAI 本身」:`detectCompat` 返回 `supportsDeveloperRole: true`、`maxTokensField: "max_completion_tokens"`、`supportsStore: true`。而手工声明的路由按其构造就是 pi-ai 未随附的端点,于是每一条这样的路由都收到了 OpenAI 自己的请求形状。 + +适配器只开放了 pi-ai 三十个 compat 字段中的两个([[2026-08-08-pi-ai-per-model-reasoning-declarations]] 把它们限定为「pi-ai 推理分派读取的那些开关」),而 `supportsDeveloperRole` 恰恰落在该作用域之内却不在其中:它的发送点是 `model.reasoning && compat.supportsDeveloperRole`。因此一个声明了 `reasoningEfforts` 的手工声明模型会把系统提示词以 `role: "developer"` 发出——多数 OpenAI 兼容网关会拒绝该角色——而没有任何配置能够更正,该网关根本接不进来。 + +硬写这个字段比不支持更糟。schemastery 会放行未知键,而解析只读取两个名字,于是 `compat: {supportsDeveloperRole: false}` 通过校验、落盘,随后被丢弃:运维看到的是一次被接受的写入和一个毫无变化的故障。`maxTokensField` 带着同一缺陷、却有更大的波及面,因为它塑造每一个请求,而不只是推理模型的请求。 + +## Decision + +每个 pi-ai compat 类型一张漂移门禁——以 `Record` 为键——把每一个上游字段分类为 `offer` 或 `withhold`。去重后三十个字段,开放二十个。分界线在于私有 URL 能推出什么:凡是无法从未识别端点推断的,部署方必须能够说出口;而 pi-ai 已安装 catalog 为具名厂商设定的字段保持扣留,因为伸手去够 `openRouterRouting` 或 `deferredToolsMode` 的路由,本就是一条应当以该厂商命名、并继承其值的 catalog 路由。 + +`PiAiCompatProfile` 保持为带逐字段 JSDoc 的显式 interface——它是配置界面所渲染、也是 `docs/config-catalog.md` 所粘贴的东西——并由一个作用在对称差上的类型级 `AssertNever` 证明它恰好命名了开放集。schemastery schema 声明为 `z`,而使这条标注在两个方向上都真正吃劲的是 `exactOptionalPropertyTypes`,于是四个面互锁:上游新增字段、门禁漏一条、interface 忘记一个字段、schema 少一个键,都会在编译期失败。字段的**类型**派生自上游而非重述,另有一条证明把 profile 钉为可赋值给上游 compat 类型,因此被拓宽的值并集不会悄悄收窄配置所接受的范围——否则物化处对 `ModelCompat` 的强转会把它洗掉。 + +协议适用性逐字段判断,且归组依据是 compat **类型**而非协议名:pi-ai 让 `openai-responses`、`azure-openai-responses` 与 `openai-codex-responses` 共用同一个 `OpenAIResponsesCompat`,因此可设在其中之一的开关,三者皆可设。仅按协议名归组曾使两条随附的 catalog 路由拿不到其自身模型所声明的字段。协议集派生自 `Model.compat` 自身的条件类型,因此某个版本若给别的协议加上 compat 类型,门禁列表会以点名的方式失败。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。`chatTemplateKwargs` 予以开放,这正是两个 `chat-template` 思考格式得以命名的前提;两者的配对不做交叉校验,因为实际生效的格式可能来自 catalog 条目或 pi-ai 的检测,而解析读不到那两层。 + +三类 `compat` 键在其被写下之处遭到拒绝而非丢弃:没有任何协议声明的键、被门禁扣留的键,以及完全没有写值的键。该检查在任何协议解析之前遍历全部键,因此即便路由上的模型永远不会走到那个本会接受它的协议,笔误同样失败。它刻意读取原始键:被扣留或未声明的名字不在 schema 中,所以 schemastery 不可能物化它,写下它的必然是人。无值那一类是必须失败而不能忽略的:schemastery 会把 YAML 裸键放行为 null,照单收下就会用 null 写覆盖已安装 catalog 的值,随后 pi-ai 的 `??` 转而去够它的 baseURL 检测,catalog 这一层被整个跳过。随后再单独过滤携带值的字段,因为 schemastery 会把缺省的 dict 物化成 `{}`,于是无论有没有人写过,`chatTemplateKwargs` 都出现在每一个解析过的 profile 上。 + +## Where a refusal lands + +所有检查都在 `resolveProfiles` 中运行,而请求路径不会重新进入它:适配器按原始快照的标识 memoize,且 `apply` 会主动预先解析一次。因此一次拒绝会以 `settings-rejected` 的形式在落盘之前抵达 `settings.mutate`,以插件挂载失败的形式抵达 `cordis.yml` 的 `config:` 块,以 `settings.register` 启动失败的形式抵达已存的 section。 + +对 settings 文件的外部编辑是唯一无法报告的路径:提供方监听器调用 `publish()`,它捕获失败的 section、记录 `settings: keeping last good "%s"`,并让该 namespace 继续服务其先前的值。这是 settings seam 对每一种 schema 与校验器失败的既有行为,并非本次开放引入,弥合它属于那个 seam 而不属于此处。对 compat 而言改变的是失败模型而非报告方式:一个从前永远静默无效的键,如今会拦下下一次启动。 + +## Alternatives considered + +**只补 `supportsDeveloperRole`。** 它修好了报告中的那个网关,却放任 `maxTokensField`——它塑造每一个请求,而不只是推理模型的请求——继续拖垮一整类端点,而且下一个上游新增字段依然可以静默落后。 + +**开放全部上游字段。** pi-ai 自己的 custom-provider 文档收敛到一个小得多的集合,其旗舰示例只点名六个,其余都是其 catalog 已经设定好的厂商绑定开关。在手工声明路由上暴露 `zaiToolStream` 或 `vercelGatewayRouting`,等于提供一个「正确用法是别做手工声明路由」的旋钮。 + +**把 `compat` 按协议分层**(`compat: {openai-completions: {…}}`)。手工声明路由恰好只有一个 `api`,因此这层嵌套只是复述路由已经说过的事,还白白破坏了所有按扁平形状写下的 profile。 + +**接受一个不透明的透传 dict。** 该 schema 同时是配置界面渲染的形状、也是 `verify-config-catalog` 交叉校验的声明,无结构的 dict 会同时击溃两者;它还会让 responses 独有的字段落到 completions 模型上,而逐字段适用性正是为拒绝这种情况而存在。 + +**未知键只告警不拒绝。** 这恰恰是让本缺陷伴随该面存活至今的姿态:一次被接受的写入加一个毫无变化的故障,教给运维的是「这个开关没用」,而不是「这个名字写错了」。 + +**为未知键给出近似拼写建议。** 仓库中没有计算编辑距离的工具,在逐文件覆盖率门禁之下为一条诊断引入依赖或手搓一个都不成比例。点名开放字段能确定地回答同一个问题:词汇检查跑在任何协议解析之前,因此它列出整个开放集,而按协议的拒绝则收窄到该协议实际接受的字段。 + +## Consequences + +- 拒绝 `developer` 角色、`max_completion_tokens`、`store`、`stream_options` 或 `strict` 的 OpenAI 兼容网关,如今属于配置问题而非无法接入的提供方;拒绝 `temperature` 或工具 `cache_control` 的 Anthropic 兼容网关同理。 +- pi-ai 升级新增 compat 字段会使构建失败,直到有人为它做出分类——`chatTemplateKwargs` 与那两个 `chat-template` 格式正是因此不再是一项长期例外。 +- 未知 compat 键并入了其余所有配置错误的失败模型。相对此前静默丢弃的改善程度受 settings seam 限制:外部文件编辑仍会保留其上一个有效值并告警,因此运维拿到的信号是一次重启,而不是那次写入。 +- **搁置而非解决:** 改指 `api` 且完全未配置 compat 的路由,会经模型字面量的 `...base` 展开保留已安装条目的 `compat`,且形状属于**另一个**协议。多个 compat 类型共有的字段(`supportsLongCacheRetention`、`sendSessionAffinityHeaders`)因而会跨协议串味。它早于本面存在——其所依附的提前返回本就在那里——留给独立的一次改动处理。 +- **搁置而非解决:** `publish()` 对被拒绝的已存 section 只通过 `ctx.logger.warn` 报告,没有面向用户的通道。它影响每一个 settings namespace,归属 `dsh-settings`。 +- [[2026-08-08-pi-ai-per-model-reasoning-declarations]] 被部分取代:其 compat 作用域的陈述在此重述,而其 `reasoningEfforts` 形状、该形状所击败的备选方案以及 `modelOverrides` 仍是当前权威。 diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml new file mode 100644 index 0000000000..0c4a921c34 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md +2026-08-18-web-home-path-tilde.md: b148833bab09eadce4c9c1a362dd99d04eba5977 +2026-08-18-web-home-path-tilde.zh.md: 9d15cd6dca1128927389d5731dff6bf831cffe76 diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md new file mode 100644 index 0000000000..b148833bab --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md @@ -0,0 +1,37 @@ +# Agent Note: Web UI abbreviates POSIX home paths as `~` + +Status: implemented + +English | [中文](2026-08-18-web-home-path-tilde.zh.md) + +## Problem + +Workspace hover cards and Tool call summaries showed full POSIX home paths. Those strings are long, repeat the same prefix on every row, and make the sidebar and transcript harder to scan. Windows paths must stay verbatim because `~` is not a Windows filesystem convention. + +## Decision + +`host.describe` reports the host account `home` as a required field. Client and Host ship together, so the field is required rather than optional. ApiProxy fills it from `homedir()` at describe time. + +`abbreviateHomePath` in `dsh-client-runtime` is the display-only helper. It returns `~` or `~/…` when the path is the POSIX home or a descendant, and leaves the path unchanged when `home` is missing, empty, or `/`, when either value is a Windows drive or UNC path, or when the match is only a prefix (`/Users/u` does not claim `/Users/u2`). Tool summaries run workspace-relative shortening first, then this helper, so a path inside the session cwd stays short. `filePath`, Host open, and Workspace hover copy keep the authored filesystem path. + +`ui-tool` and `ui-workspace` inject `connection.hostDescription` at their own slot registrations. ChatView does not grow a Host-description hook. The field is required on `ConnectionHandle`; test fakes supply a source whose snapshot may be undefined before connect. + +The fixture Host home is `/home/fixture`. A second fixture Workspace at `/home/fixture/Documents/project` lets assembled replay hover `~/Documents/project` without moving the existing `/tmp/fixture` account. TerminalBlock's own prompt-label collapse is unchanged. + +## Alternatives considered + +**Guess `/Users` or `/home` without the real home.** Rejected because a shared prefix is not an account home, and `/Users/shared` or `/home/src` would abbreviate incorrectly. + +**Abbreviate Windows `%USERPROFILE%` as `~` as well.** Rejected because the acceptance rule keeps Windows paths verbatim, and `~` is not how Explorer or `cmd` spell those paths. + +**Put the helper in `dsh-home-paths`.** Rejected because that package expands configuration tildes on Node; this helper is a browser display rewrite and must not pull Node `os` into client bundles. + +**Thread `home` from ChatView owner props.** Rejected because it enlarges the conversation inject face and every ChatView test harness for a display fact only Tool and Workspace cards consume. + +## Consequences + +POSIX home-rooted Workspace hover paths and leftover Tool path summaries display as `~`. Copy and open still use the full path. Windows drive and UNC paths never become `~`. A Host that reports `/` as home does not turn the whole filesystem into `~`. Before the first describe, or while reconnecting, the source snapshot is undefined and paths stay unabbreviated. + +## Testing + +Package tests cover `abbreviateHomePath`, `toolRowModel` / `readCardModel` home abbreviation, Workspace hover display versus copy, and `host.describe` schema plus live `homedir()`. Assembled replay `apps/web/tests/home-path-tilde.snapshot.ts` hovers the fixture home-descendant Workspace. Product-GUI PRs still record a real-browser GIF of the hover card. diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md new file mode 100644 index 0000000000..9d15cd6dca --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Web UI abbreviates POSIX home paths as `~` + +Status: implemented + +[English](2026-08-18-web-home-path-tilde.md) | 中文 + +## Problem + +Workspace 悬停卡片和 Tool 调用摘要会显示完整的 POSIX 家目录路径。这些字符串很长,每行重复同一前缀,侧边栏和对话记录更难扫读。Windows 路径必须保持原样,因为 `~` 不是 Windows 文件系统约定。 + +## Decision + +`host.describe` 把宿主账户的 `home` 作为必填字段上报。Client 与 Host 一同发布,因此该字段是必填而不是可选。ApiProxy 在 describe 时用 `homedir()` 填入。 + +`dsh-client-runtime` 中的 `abbreviateHomePath` 是仅用于展示的辅助函数。当路径是 POSIX 家目录或其后代时返回 `~` 或 `~/…`;`home` 缺失、为空或为 `/`,任一侧是 Windows 盘符或 UNC 路径,或只是前缀命中(`/Users/u` 不能收走 `/Users/u2`)时,路径保持不变。Tool 摘要先做工作区相对缩短,再调用该辅助函数,因此会话 cwd 内的路径仍然更短。`filePath`、Host 打开以及 Workspace 悬停复制仍使用作者给出的文件系统路径。 + +`ui-tool` 与 `ui-workspace` 在各自的 slot 注册上注入 `connection.hostDescription`。ChatView 不增加 Host 描述钩子。该字段在 `ConnectionHandle` 上是必填的;测试假对象提供一个来源,其快照在连接完成前可以为 undefined。 + +fixture 的 Host 家目录是 `/home/fixture`。第二个 fixture Workspace 位于 `/home/fixture/Documents/project`,组装回放可以悬停出 `~/Documents/project`,而不必移动现有的 `/tmp/fixture` 账户。TerminalBlock 自有的提示符标签折叠保持不变。 + +## Alternatives considered + +**在没有真实 home 的情况下猜测 `/Users` 或 `/home`。** 否决,因为共享前缀不是账户家目录,`/Users/shared` 或 `/home/src` 会被错误缩写。 + +**同样把 Windows `%USERPROFILE%` 缩写成 `~`。** 否决,因为验收规则要求 Windows 路径保持原样,而且 Explorer 与 `cmd` 并不这样拼写这些路径。 + +**把辅助函数放进 `dsh-home-paths`。** 否决,因为该包在 Node 上展开配置里的波浪号;本辅助函数是浏览器展示改写,不能把 Node `os` 拉进 client 包。 + +**从 ChatView owner props 向下传递 `home`。** 否决,因为它会扩大 conversation 注入面和每一份 ChatView 测试夹具,而只有 Tool 与 Workspace 卡片消费这个展示事实。 + +## Consequences + +POSIX 家目录下的 Workspace 悬停路径,以及缩短 cwd 后仍落在家目录里的 Tool 路径摘要,会显示为 `~`。复制与打开仍使用完整路径。Windows 盘符和 UNC 路径永远不会变成 `~`。若 Host 把 `/` 报成 home,不会把整个文件系统收成 `~`。首次 describe 之前或重连期间,来源快照为 undefined,路径保持未缩写。 + +## Testing + +包测试覆盖 `abbreviateHomePath`、`toolRowModel`/`readCardModel` 的家目录缩写、Workspace 悬停展示与复制,以及 `host.describe` schema 与实时 `homedir()`。组装回放 `apps/web/tests/home-path-tilde.snapshot.ts` 悬停 fixture 中位于家目录下的 Workspace。面向产品 GUI 的 PR 仍需录制悬停卡片的真实浏览器 GIF。 diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml new file mode 100644 index 0000000000..d46a50b6d3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md +2026-08-19-direct-deepseek-vision-input.md: 5c2f2ea3ff87b58906ce76caf9f611e07f3e2d04 +2026-08-19-direct-deepseek-vision-input.zh.md: 3aea6b6a4f27f7116c1cb935b1719dfbcf7ed8fc diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md new file mode 100644 index 0000000000..5c2f2ea3ff --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md @@ -0,0 +1,34 @@ +# Agent Note: Direct DeepSeek vision input + +Status: implemented + +English | [中文](2026-08-19-direct-deepseek-vision-input.zh.md) + +## Problem + +DeepSeek vision deployments use the chat-completions image protocol, but the direct `deepseek-official` adapter declares every catalog and pass-through model text-only and rejects every `ImageBlock`. The durable attachment path therefore works only through configurable pi-ai routes, and a deployment cannot pass user uploads or image-bearing tool results through the direct provider. + +## Decision + +The direct adapter lets a configured model opt in with `inputModalities: [text, image]`; validation rejects empty, unknown, or duplicate modalities. Flash, Pro, unlisted ids, and configured models that omit `inputModalities` remain explicitly text-only. The shipped catalog does not advertise `deepseek-v4-flash-vision-exp` until its model endpoint is ready, so the model selector cannot offer an unavailable route; deployment and snapshot catalogs can enable their exact vision model independently. + +The adapter resolves `ctx.attachments` per image request, reads each retained durable reference with the request signal, and serializes verified bytes as ordered OpenAI-compatible `image_url` data URLs. Text-only user messages retain string content. Tool results retain string-only `tool` messages; image-only results use `(see attached image)`, and consecutive retained tool-result images follow in one `user` message beginning `Attached image(s) from tool result:`. System and assistant history images fail with `UNSUPPORTED_CONTENT` before attachment or network I/O. + +The direct adapter and pi-ai conversion share the deterministic [request-level image payload bound](../bug-fix/2026-08-18-request-image-payload-bound.md). Both default to 20 MiB of accumulated base64 payload, replace oldest image occurrences with the same fixed placeholder, and never read omitted attachments. Direct HTTP 413 responses are `INVALID_REQUEST`; attachment failures retain their stable attachment code rather than becoming `TRANSPORT`. + +Canonical messages continue to store only `ImageAttachmentRef`. Data URLs exist only while preparing one provider request, so no session event, persistence format, API schema, or SDK projection changes. The route accepts PNG, JPEG, WebP, and GIF already admitted by the attachment service. External image URLs, the Files API, and image output remain unsupported. + +## Alternatives considered + +- **Use only the pi-ai DeepSeek provider.** Its generic multimodal path proves the content conversion, but it does not make the direct official route truthful or usable with the official model id. +- **Declare the whole provider image-capable.** This would let Flash, Pro, and unknown pass-through ids accept durable images that their exact wire model cannot promise to consume. Capability remains exact-model metadata. +- **Send images inside `tool` message content.** The documented compatible form keeps tool content a string. A following user message avoids relying on an undocumented multimodal tool-role form while preserving call-result order. +- **Add external URLs or Files uploads.** Both require new canonical input, authorization, lifetime, cleanup, and replay decisions. Transient base64 uses the existing durable attachment contract without expanding those concerns. + +## Verification + +Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder. + +## Consequences + +Configured DeepSeek vision routes can consume durable user and tool-result images without changing session durability or response streaming. Repeated history still expands request bodies, but deterministic oldest-first offload bounds the dominant payload and leaves headroom below the official 30 MiB request-body limit. Image token pricing remains provider-owned because the official image token formula is not available. diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md new file mode 100644 index 0000000000..3aea6b6a4f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 直接 DeepSeek 视觉输入 + +Status: implemented + +[English](2026-08-19-direct-deepseek-vision-input.md) | 中文 + +## Problem + +DeepSeek 视觉部署使用 chat-completions 图片协议,但直接 `deepseek-official` 适配器把所有 catalog 与原样传递模型都声明为仅文本,并拒绝每一个 `ImageBlock`。因此,持久附件路径只能经可配置 pi-ai 路由工作,部署方无法通过直接提供方传递用户上传或包含图片的工具结果。 + +## Decision + +直接适配器允许已配置模型通过 `inputModalities: [text, image]` 选择加入;校验会拒绝空列表、未知模态或重复模态。Flash、Pro、未列出 id,以及省略 `inputModalities` 的已配置模型仍明确仅支持文本。在模型端点就绪前,随附目录不会公布 `deepseek-v4-flash-vision-exp`,因此模型选择器不会提供不可用路由;部署与 snapshot 目录可以独立启用其确切视觉模型。 + +适配器会对每个图片请求解析 `ctx.attachments`,用请求 signal 读取每个保留的持久引用,并将校验后的字节按顺序序列化为 OpenAI 兼容的 `image_url` data URL。纯文本 user 消息保留字符串内容。工具结果保留仅字符串的 `tool` 消息;仅含图片的结果使用 `(see attached image)`,连续工具结果中保留的图片随后合并进一条以 `Attached image(s) from tool result:` 开头的 `user` 消息。System 与 assistant 历史图片会在附件或网络 I/O 前以 `UNSUPPORTED_CONTENT` 失败。 + +直接适配器与 pi-ai 转换共享确定性的[请求级图片载荷上限](../bug-fix/2026-08-18-request-image-payload-bound.md)。两者都以 20 MiB 累计 base64 payload 为默认值,用相同固定占位文本替换最旧的图片出现位置,并且绝不读取被省略的附件。直接 HTTP 413 响应归类为 `INVALID_REQUEST`;附件失败会保留其稳定附件 code,不会变成 `TRANSPORT`。 + +规范消息继续只存储 `ImageAttachmentRef`。Data URL 只在准备单次提供方请求时存在,因此无需修改会话事件、持久化格式、API schema 或 SDK 投影。路由接受已经由附件服务准入的 PNG、JPEG、WebP 和 GIF。不支持外部图片 URL、Files API 和图片输出。 + +## Alternatives considered + +- **只使用 pi-ai DeepSeek 提供方。** 其通用多模态路径验证了内容转换,但无法让直接官方路由如实公布能力,也无法让它配合官方模型 id 使用。 +- **把整个提供方声明为支持图片。** 这样会让 Flash、Pro 和未知的原样传递 id 接受持久图片,但其确切协议模型无法承诺消费这些图片。能力仍属于确切模型元数据。 +- **在 `tool` 消息内容中发送图片。** 已记录的兼容形式要求工具内容保持字符串。随后发送 user 消息可避免依赖未记录的多模态 tool role 形式,同时保留调用结果顺序。 +- **增加外部 URL 或 Files 上传。** 两者都需要新的规范输入、授权、生命周期、清理和重放决策。瞬态 base64 可以复用现有持久附件约定,不扩展这些问题。 + +## Verification + +包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。 + +## Consequences + +已配置的 DeepSeek 视觉路由可以消费持久 user 与工具结果图片,而无需改变会话持久性或响应流。重复历史仍会扩张请求正文,但确定性的最旧优先 offload 会限制主导 payload,并在官方 30 MiB 请求正文上限下保留余量。由于官方图片 token 公式尚不可用,图片 token 定价仍由提供方掌握。 diff --git a/apps/web/tests/home-path-tilde.snapshot.ts b/apps/web/tests/home-path-tilde.snapshot.ts new file mode 100644 index 0000000000..059a0ac003 --- /dev/null +++ b/apps/web/tests/home-path-tilde.snapshot.ts @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +// Assembled POSIX home-path display: the fixture Host home is `/home/fixture` +// and a second Workspace lives under it. The sidebar hover card must show +// `~/Documents/project` while copy still writes the full path. +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp, REFRESHING_GOLDEN } from './assembled-boot.ts' + +const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt') + +installAssembledBootEnv() + +describe('assembled POSIX home-path display', () => { + it('shows the home-descendant Workspace path as ~ and copies the full path', async () => { + mountAssembledApp() + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const group = (await within(tree).findAllByText('project')) + .map(el => el.closest('[role="treeitem"]')) + .find(el => el?.getAttribute('aria-expanded') !== null) + if (group == null) throw new Error('home-descendant Workspace group missing') + + fireEvent.pointerEnter(group.parentElement as HTMLElement) + const hoverPath = await waitFor(() => { + const found = screen.getByText('~/Documents/project') + expect(found).toBeTruthy() + return found + }, { timeout: 2_000 }) + expect(screen.queryByText('/home/fixture/Documents/project')).toBeNull() + const copy = screen.getByRole('button', { name: 'Copy: /home/fixture/Documents/project' }) + + const shape = [ + `hover=${hoverPath.textContent}`, + `copy=${copy.getAttribute('aria-label')}`, + ].join('\n') + '\n' + if (REFRESHING_GOLDEN) { + mkdirSync(dirname(EXPECTED), { recursive: true }) + writeFileSync(EXPECTED, shape) + } + await expect(shape).toMatchFileSnapshot(EXPECTED) + act(() => { fireEvent.pointerLeave(group.parentElement as HTMLElement) }) + }) +}) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index a0b34e53b9..1e2a0953b2 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -12,7 +12,7 @@ import { join } from 'node:path' import type { Browser, Page, Response } from 'playwright' import { chromium } from 'playwright' import { strFromU8, unzipSync } from 'fflate' -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { @@ -415,8 +415,17 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // Read summaries are host-open file links; they also must not open details. const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) - await fileLink.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') + .mockImplementation(async (request, _signal) => ({ + rpcId: request.rpcId, + result: { ok: true, value: { opened: true as const } }, + })) + try { + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + } finally { + openPath.mockRestore() + } }, 60_000) it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => { diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index ad9bc0903f..407a53dd22 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -224,7 +224,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup expect(document).toContain('name: Private Preview') expect(document).toContain('contextWindow: 131072') expect(document).toContain('maxTokens: 64000') - expect(document).not.toContain('id: deepseek-v4-flash') + expect(document).not.toMatch(/^\s*- id: deepseek-v4-flash$/m) await page.keyboard.press('Escape') // A connected Workspace is what puts a live composer — and its model diff --git a/apps/web/tests/rail-search-expand.e2e.ts b/apps/web/tests/rail-search-expand.e2e.ts new file mode 100644 index 0000000000..74f7cb12c9 --- /dev/null +++ b/apps/web/tests/rail-search-expand.e2e.ts @@ -0,0 +1,68 @@ +// Web e2e scenario: the collapsed rail's search control in the real event +// order. The rail click flips the sidebar wide and mounts WorkspaceBrowser's +// outside-click dismissal listener during its own React dispatch; the same +// click then keeps bubbling to document with the unmounted rail button as its +// target — outside searchRoot. The package-level jsdom test cannot replay +// that continuation (fireEvent does not re-bubble through listeners mounted +// mid-dispatch), so the guard that keeps the gesture alive +// (.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md) +// is pinned here, in the assembled application under a real browser click. +// +// Zero model calls: collapsing the sidebar and expanding the search are pure +// client layout gestures; the scenario needs no session content at all. +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +/** WorkspaceBrowser's rail-search focus delay (EXPAND_SLIDE_MS) plus flush headroom. */ +const FOCUS_SETTLE_MS = 600 + +describe('web e2e: rail search click survives its own document-level bubble', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('expands the search and lands focus in the input from one rail click', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-rail-search-expand')) + await page.getByRole('button', { name: 'Collapse sidebar' }).click() + const railSearch = page.getByRole('button', { name: 'Search sessions' }) + // The wide chrome stays mounted through the 150ms collapse crossfade; the + // rail control (no aria-expanded) replaces it at settle. + await expect.poll(async () => railSearch.getAttribute('aria-expanded'), { timeout: 10_000 }).toBeNull() + + // The one real click under test: it must expand the sidebar AND leave the + // search expanded after its own bubble reaches document. + await railSearch.click() + + const wideSearch = page.getByRole('button', { name: 'Search sessions' }) + await expect.poll(async () => wideSearch.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true') + const input = page.getByPlaceholder('Search sessions...') + await expect.poll( + async () => input.evaluate(el => document.activeElement === el), + { timeout: FOCUS_SETTLE_MS + 10_000 }, + ).toBe(true) + + // The guard ends with the gesture: a genuine outside click on an empty + // query dismisses the expanded search as before. + await page.getByRole('button', { name: 'New session' }).first().click() + await expect.poll(async () => wideSearch.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('false') + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) +}) diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts new file mode 100644 index 0000000000..c67a975cbd --- /dev/null +++ b/apps/web/tests/reference-composer.e2e.ts @@ -0,0 +1,113 @@ +// Web e2e scenario: the shipped composition discovers local files and cold +// sessions through the real Host, groups both domains in the shared @ menu, +// and projects each pick back into the composer without issuing a model call. +import { writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { + SESSION_FORMAT_VERSION, + Session, + SessionId, +} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/reference-composer', import.meta.url)) +const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') +const MODE = webSnapshotMode() +const SOURCE_SESSION_ID = 'reference-source-session' + +/** Build one closed source session with a stable title for reference discovery. */ +function sourceSessionFixture(): string { + const session = Session.create(SessionId(SOURCE_SESSION_ID)) + session.append('turn/start', { + turn: 1, + }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Research context for the reference menu.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Research notes', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + }), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +describe.skipIf(MODE === 'record')('web e2e: file and session references through the real host', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, sourceSessionFixture(), SOURCE_SESSION_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + await writeFile(join(scaffold.workspaceCwd, 'workspace', 'reference.txt'), 'reference fixture\n') + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('groups both sources and projects file text plus an atomic session chip', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-reference-composer')) + const input = page.locator('textarea').first() + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + + await input.fill('@') + await expect.poll(() => menu.getByRole('option').count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(2) + const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) + expect(snapshot).toContain('Files & folders') + expect(snapshot).toContain('Session conversations') + expect(snapshot).toContain('File \u00b7 reference.txt') + expect(snapshot).toContain('Session \u00b7 Research notes') + expect(snapshot).not.toContain('text: Subagents') + + await input.fill('@reference') + await menu.getByRole('option', { name: /File \u00b7 reference\.txt/ }).click() + await expect.poll(() => input.inputValue()).toBe('@reference.txt ') + + await input.fill('@Research') + await menu.getByRole('option', { name: /Session \u00b7 Research notes/ }).click() + await expect.poll(() => page.locator('[data-decoration="chip"]').textContent()).toBe('@Research notes') + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index ac2ca402e6..c6cd58d0a1 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -856,8 +856,9 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string { // Seeded compaction prices realized file paths, whose length differs // between local worktrees and CI scratch directories. .replace(/(Compacted \d+ history items \(~)\d+( tokens\))/g, '$1{{tokens}}$2') - // Message IconActions clocks widen by calendar day/year; collapse every - // format so goldens stay stable across midnight and year changes. + // Session summaries and Message IconActions clocks cross calendar + // boundaries; collapse every shape so goldens stay stable across them. + .replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, '{{timestamp}}') .replace(/\d{4}年\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') .replace(/\d{1,2}月\d{1,2}日 \d{2}:\d{2}/g, '{{clock}}') .replace(/(? { await fileLink.waitFor({ timeout: 10_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') - await fileLink.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') + .mockImplementation(async (request, _signal) => ({ + rpcId: request.rpcId, + result: { ok: true, value: { opened: true as const } }, + })) + try { + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + } finally { + openPath.mockRestore() + } // Path label survives from the recorded args (a.txt). await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) }) + it.skipIf(MODE === 'record')('a Host open refusal keeps the reason and retries the same path', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-file-open-failure')) + const fileLink = page.locator('[data-variant="read"] button').first() + await fileLink.waitFor({ timeout: 10_000 }) + const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') + .mockImplementation(async (request, _signal) => ({ + rpcId: request.rpcId, + result: { + ok: false as const, + error: { code: 'internal', message: 'xdg-open is not available', details: {} }, + }, + })) + try { + await fileLink.click() + const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' }) + await dialog.waitFor({ timeout: 5_000 }) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(FILE_OPEN_FAILURE_EXPECTED, snapshot, MODE) + await expect.poll(() => dialog.innerText(), { timeout: 5_000 }) + .toContain('path open failed: xdg-open is not available') + await page.getByRole('button', { name: 'Retry' }).click() + await expect.poll(() => openPath.mock.calls.length, { timeout: 5_000 }).toBe(2) + expect(openPath.mock.calls[0]![0].payload).toEqual(openPath.mock.calls[1]![0].payload) + await page.getByRole('button', { name: 'Cancel' }).click() + await expect.poll(() => page.getByRole('dialog', { name: 'Couldn’t open file' }).count(), { + timeout: 5_000, + }).toBe(0) + } finally { + // Shared page: a leftover mask blocks later cases even when this one fails. + if (await page.getByRole('dialog', { name: 'Couldn’t open file' }).count() > 0) { + await page.keyboard.press('Escape') + } + openPath.mockRestore() + } + }) + it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction')) const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ }) @@ -505,6 +551,6 @@ describe('web e2e: seeded history renders through cold resume', () => { // stream would have failed the turn loudly. Cleanliness pins the wire. expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'seed.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md', 'seed.jsonl', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt b/apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt new file mode 100644 index 0000000000..23ed9fb622 --- /dev/null +++ b/apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt @@ -0,0 +1,2 @@ +hover=~/Documents/project +copy=Copy: /home/fixture/Documents/project diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index bafa739a58..85a3661eb8 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -20,9 +20,13 @@ - text: Stopped - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} - textbox "Message the agent" - button "Commands": - img @@ -31,4 +35,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 43c9665ac1..09f677bcc8 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -20,9 +20,13 @@ - text: Stopped - button "Copy": - img +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} - button "2 queued messages" [expanded] - list: - listitem: @@ -50,4 +54,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} diff --git a/apps/web/tests/snapshots/reference-composer/menu.expected.md b/apps/web/tests/snapshots/reference-composer/menu.expected.md new file mode 100644 index 0000000000..f42be2dc47 --- /dev/null +++ b/apps/web/tests/snapshots/reference-composer/menu.expected.md @@ -0,0 +1,5 @@ +- listbox "Trigger suggestions": + - text: reference Files & folders + - option "File · reference.txt reference.txt" [selected] + - text: Session conversations + - option "Session · Research notes reference-source-session · {{cwd}} · {{timestamp}}" diff --git a/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md b/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md new file mode 100644 index 0000000000..fece247a7a --- /dev/null +++ b/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md @@ -0,0 +1,7 @@ +- dialog "Couldn’t open file": + - heading "Couldn’t open file" [level=2] + - button "Close": + - img + - paragraph: "path open failed: xdg-open is not available" + - button "Cancel" + - button "Retry" diff --git a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md index 01e67a351f..cbac0d4880 100644 --- a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md +++ b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md @@ -30,6 +30,10 @@ - button "Copy": - img - tooltip "Copy" +- button "Good response": + - img +- button "Bad response": + - img - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 82baea4aa8..43fccd65b5 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -106,9 +106,8 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // this exact occurrence into the current turn's steering outbox. await input.fill(STEER) await input.press('Enter') - const queued = page.getByText(STEER, { exact: true }) - await queued.waitFor({ timeout: 10_000 }) const queuedRow = page.getByRole('listitem').filter({ hasText: STEER }) + await queuedRow.waitFor({ timeout: 10_000 }) const steerButton = queuedRow.getByRole('button', { name: 'Steer queued message' }) await expect.poll(() => steerButton.isEnabled(), { timeout: 10_000 }).toBe(true) await steerButton.click({ timeout: 10_000 }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 50ef23bb8d..111aefe63c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -53,6 +53,7 @@ "tests/cold-blank-session.e2e.ts", "tests/stats-paged-history.e2e.ts", "tests/sidebar-scrollbar.e2e.ts", + "tests/rail-search-expand.e2e.ts", "tests/conversation-column-overflow.e2e.ts", "tests/code-mode-round.e2e.ts", "tests/composer-draft-scroll.e2e.ts", @@ -62,6 +63,7 @@ "tests/message-feedback.e2e.ts", "tests/message-feedback-layout.e2e.ts", "tests/markdown-images.e2e.ts", + "tests/reference-composer.e2e.ts", "tests/math-rendering.e2e.ts", "tests/markdown-cjk-strong.e2e.ts", "tests/markdown-inline-code-links.e2e.ts", diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 1042393b52..e513e79a93 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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 docs/capability-seams.md -capability-seams.md: 41ec9859d2dbf4c2c237059458803a5651bb9504 -capability-seams.zh.md: 100486445e55bb411f062637ec6cff636806cb97 +capability-seams.md: 02edc5431926c3ee5e1caf3334bd8f482f7e81ba +capability-seams.zh.md: dc58b0cddd1a353a70bb623ce3090d4493a9eac7 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 41ec9859d2..02edc54319 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -67,6 +67,9 @@ flowchart LR svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] pkg_tool_session_query["tool-session-query"] + pkg_file_reference["file-reference"] + svc_fileReferences["ctx.fileReferences
File reference discovery"] + pkg_file_reference_local["file-reference-local"] svc_sessionReferenceResolver["ctx.sessionReferenceResolver
Cross-session snapshot preparation"] pkg_session_title["session-title"] svc_sessionTitle["ctx.sessionTitle
Log-backed session titles"] @@ -224,6 +227,8 @@ flowchart LR pkg_directory_picker_browse --> svc_directoryPicker pkg_directory_picker_native --> svc_directoryPicker pkg_e2b --> svc_e2b + pkg_file_reference --> svc_fileReferences + pkg_file_reference_local --> svc_fileReferences pkg_fs --> svc_fs pkg_fs_e2b --> svc_fs pkg_fs_local --> svc_fs @@ -437,6 +442,7 @@ flowchart LR | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry. | | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | +| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | - | - | The interface returns path-only completion candidates within the addressed Agent cwd through its unary Remote contract; providers own namespace access and ranking without reading file contents. | | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 100486445e..dc58b0cddd 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -69,6 +69,9 @@ flowchart LR svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] pkg_tool_session_query["tool-session-query"] + pkg_file_reference["file-reference"] + svc_fileReferences["ctx.fileReferences
File reference discovery"] + pkg_file_reference_local["file-reference-local"] svc_sessionReferenceResolver["ctx.sessionReferenceResolver
Cross-session snapshot preparation"] pkg_session_title["session-title"] svc_sessionTitle["ctx.sessionTitle
Log-backed session titles"] @@ -226,6 +229,8 @@ flowchart LR pkg_directory_picker_browse --> svc_directoryPicker pkg_directory_picker_native --> svc_directoryPicker pkg_e2b --> svc_e2b + pkg_file_reference --> svc_fileReferences + pkg_file_reference_local --> svc_fileReferences pkg_fs --> svc_fs pkg_fs_e2b --> svc_fs pkg_fs_local --> svc_fs @@ -439,6 +444,7 @@ flowchart LR | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | 拥有本地逐 assistant 消息反馈、生命周期与目标校验、逐条目 compare-and-set 及 Host 一元 Remote 契约,且不进入 Session 历史或遥测。 | | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | 通过领域设施拥有带 WorkspaceId 品牌类型的记录;稳定的 sessionIds 账户驱动 Host RPC 与 GUI 投影。 | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | 该接口提供精确读取、过滤和追踪;具体后端还提供全文协调、排序、摘要片段和游标世代,而模型消费方负责工作区权限与不含游标的渲染。 | +| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | - | - | 该接口通过其一元 Remote 契约返回指定 Agent cwd 内仅含路径的补全候选;提供方负责命名空间访问和排序,但不会读取文件内容。 | | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | - | - | 负责确定性回退、最新标题折叠区,以及唯一的可选异步提供方注册。 | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7f32d0a130..c736710255 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 9c4d4fd1c4e198973940bf93eb5e66d9e7c47d04 -config-catalog.zh.md: 9a62d1d62703bba3ceb5a9a21752d86a3a72f2d6 +config-catalog.md: adc32e9b41f7fd29df08da7e25cae0be11cdbfbe +config-catalog.zh.md: 6f8305af5c2aac7792de9aed0041f8abb2e36764 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9c4d4fd1c4..adc32e9b41 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -587,6 +587,26 @@ export interface Config { Source: [`packages/e2b/e2b/src/index.ts:43`](../packages/e2b/e2b/src/index.ts) + + +## `@deepseek-ai/dsh-file-reference-local` + +Requires: `agents` + +```ts config-catalog +/** Local file-reference discovery configuration. */ +export interface Config { + /** Maximum ranked candidates returned for one query. */ + maxResults?: number + /** Maximum indexed files and directories per agent workspace. */ + maxEntries?: number + /** Directory basenames never traversed or offered. */ + excludedDirectories?: string[] +} +``` + +Source: [`packages/context/file-reference-local/src/index.ts:35`](../packages/context/file-reference-local/src/index.ts) + ## `@deepseek-ai/dsh-fs-local` @@ -870,6 +890,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Maximum accumulated base64 image payload per request (default 20 MiB). */ + maxRequestImageBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -886,12 +908,14 @@ export interface DeepSeekCatalogModel { contextWindow?: number /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ maxTokens?: number + /** Accepted request modalities; omission is text-only. */ + inputModalities?: ModelModality[] } ``` -Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) +Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:66`](../packages/llm/llm-deepseek/src/index.ts) @@ -940,10 +964,11 @@ export interface PiAiProviderProfile { */ modelOverrides?: Record /** - * Reasoning-dispatch switches for every `openai-completions` model on this - * route; each model's own `compat` overrides per field. What neither sets - * keeps the installed catalog entry's value, then pi-ai's baseURL-derived - * detection. + * pi-ai wire-compatibility switches defaulting every model on this route + * whose protocol declares them; each model's own `compat` overrides per + * field. What neither sets keeps the installed catalog entry's value, then + * pi-ai's own detection. A switch no model on the route could read is + * refused rather than left looking applied. */ compat?: PiAiCompatProfile /** @@ -1031,7 +1056,7 @@ export interface PiAiModelProfile { * declares the offered levels and their wire spellings. */ reasoningEfforts?: false | PiAiReasoningEfforts - /** Reasoning-dispatch switches for this model, winning over the route's. */ + /** pi-ai wire-compatibility switches for this model, winning over the route's per field; one its protocol does not declare is refused. */ compat?: PiAiCompatProfile } @@ -1045,19 +1070,80 @@ export interface PiAiModelProfile { export type PiAiModelOverride = Omit /** - * Reasoning-dispatch compatibility switches, set on the route (its models' - * default) or per model (winning over the route). Only the switches pi-ai's - * reasoning dispatch reads are offered; the rest of pi-ai's compat surface - * keeps its baseURL-derived auto-detection. pi-ai types both fields only on - * `OpenAICompletionsCompat` — the other wire protocols define their reasoning - * fields in the protocol itself — so resolution rejects a model-level switch - * anywhere else, while a route-level default skips past models it cannot fit. + * pi-ai wire-compatibility switches, set on the route (its models' default) or + * per model (winning over the route, field by field). + * + * pi-ai decides each of these from the provider id and baseURL when no layer + * sets it, and a private gateway's URL says nothing: for an endpoint it does + * not recognize the detection answers as though it were OpenAI itself, which + * is wrong for most OpenAI-compatible gateways. So every field here is one a + * deployment must be able to state because nothing can infer it, while the + * fields pi-ai's catalog sets for a named vendor stay withheld. + * + * A field belongs to the protocols whose upstream compat type declares it: a + * model-level switch its protocol does not take fails resolution, and a + * route-level one skips past models it cannot fit. "The three Responses + * protocols" below means `openai-responses`, `azure-openai-responses`, and + * `openai-codex-responses`, which pi-ai gives one shared compat type, so a + * switch settable on one is settable on all three. */ export interface PiAiCompatProfile { - /** Reasoning parameter format the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ - thinkingFormat?: PiAiThinkingFormat - /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + /** Whether the endpoint accepts `store`; `openai-completions`. */ + supportsStore?: boolean + /** + * Whether the endpoint accepts the `developer` role for the system prompt, + * which pi-ai sends only to a reasoning model; `false` keeps `system`. + * `openai-completions` and the three Responses protocols. + */ + supportsDeveloperRole?: boolean + /** Whether the endpoint accepts `reasoning_effort`; `openai-completions`. */ supportsReasoningEffort?: boolean + /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ + supportsUsageInStreaming?: boolean + /** Which output-cap field the endpoint reads; `openai-completions`. */ + maxTokensField?: NonNullable + /** Whether tool results must carry `name`; `openai-completions`. */ + requiresToolResultName?: boolean + /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */ + requiresAssistantAfterToolResult?: boolean + /** Whether thinking blocks must travel as text in `` delimiters; `openai-completions`. */ + requiresThinkingAsText?: boolean + /** Whether replayed assistant messages need an empty `reasoning_content` while reasoning is on; `openai-completions`. */ + requiresReasoningContentOnAssistantMessages?: boolean + /** Reasoning parameter format the endpoint expects; `openai-completions`. */ + thinkingFormat?: PiAiThinkingFormat + /** + * Kwargs sent as `chat_template_kwargs`, which pi-ai reads only under the + * two `chat-template` thinking formats; `openai-completions`. Nothing checks + * that pairing: the format in force may come from the installed catalog + * entry or from pi-ai's own baseURL detection, neither of which resolution + * can read, so kwargs set beside another format are sent nowhere. + */ + chatTemplateKwargs?: NonNullable + /** + * Whether the endpoint accepts `strict` in tool definitions; + * `openai-completions`, the three Responses protocols, `bedrock-converse-stream`. + */ + supportsStrictMode?: boolean + /** Prompt-cache marker convention; `openai-completions`. */ + cacheControlFormat?: NonNullable + /** + * Whether the endpoint accepts long prompt-cache retention; + * `openai-completions`, the three Responses protocols, `anthropic-messages`. + */ + supportsLongCacheRetention?: boolean + /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */ + supportsEagerToolInputStreaming?: boolean + /** Whether the endpoint accepts `cache_control` on tool definitions; `anthropic-messages`. */ + supportsCacheControlOnTools?: boolean + /** Whether the endpoint accepts the `temperature` request field; `anthropic-messages`. */ + supportsTemperature?: boolean + /** Whether to force adaptive thinking regardless of model id; `anthropic-messages`. */ + forceAdaptiveThinking?: boolean + /** Whether to replay an empty thinking signature instead of converting thinking to text; `anthropic-messages`. */ + allowEmptySignature?: boolean + /** Whether the endpoint accepts Anthropic strict tool schemas; `anthropic-messages`. */ + supportsStrictTools?: boolean } /** One request modality a pi-ai model may accept. */ @@ -1074,21 +1160,12 @@ export type PiAiModality = Model['input'][number] export type PiAiReasoningEfforts = Partial> /** One reasoning-dispatch wire format a profile may name. */ -export type PiAiThinkingFormat = Exclude - -/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ -type PiThinkingFormat = NonNullable - -/** - * pi-ai thinking formats a profile cannot name: both drive the request through - * `chatTemplateKwargs`, which this configuration does not expose. - */ -type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' +export type PiAiThinkingFormat = NonNullable ``` Depends on: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:192`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:201`](../packages/llm/llm-pi-ai/src/config.ts) @@ -3122,6 +3199,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-model-selection` ([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission-presets` ([`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-reference` ([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts)) - `@deepseek-ai/dsh-client-ui-renderer` ([`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) @@ -3176,6 +3254,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — abstract `CredentialProvider` ([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) +- `@deepseek-ai/dsh-file-reference` — abstract `FileReferenceService` ([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) - `@deepseek-ai/dsh-jobs` — abstract `JobRegistry` ([`packages/jobs/jobs/src/index.ts`](../packages/jobs/jobs/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9a62d1d627..6f8305af5c 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -589,6 +589,26 @@ export interface Config { 来源:[`packages/e2b/e2b/src/index.ts:43`](../packages/e2b/e2b/src/index.ts) + + +## `@deepseek-ai/dsh-file-reference-local` + +需要:`agents` + +```ts config-catalog +/** Local file-reference discovery configuration. */ +export interface Config { + /** Maximum ranked candidates returned for one query. */ + maxResults?: number + /** Maximum indexed files and directories per agent workspace. */ + maxEntries?: number + /** Directory basenames never traversed or offered. */ + excludedDirectories?: string[] +} +``` + +来源:[`packages/context/file-reference-local/src/index.ts:35`](../packages/context/file-reference-local/src/index.ts) + ## `@deepseek-ai/dsh-fs-local` @@ -872,6 +892,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Maximum accumulated base64 image payload per request (default 20 MiB). */ + maxRequestImageBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -888,12 +910,14 @@ export interface DeepSeekCatalogModel { contextWindow?: number /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ maxTokens?: number + /** Accepted request modalities; omission is text-only. */ + inputModalities?: ModelModality[] } ``` -依赖:[`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) +依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepseek/src/index.ts) +来源:[`packages/llm/llm-deepseek/src/index.ts:66`](../packages/llm/llm-deepseek/src/index.ts) @@ -942,10 +966,11 @@ export interface PiAiProviderProfile { */ modelOverrides?: Record /** - * Reasoning-dispatch switches for every `openai-completions` model on this - * route; each model's own `compat` overrides per field. What neither sets - * keeps the installed catalog entry's value, then pi-ai's baseURL-derived - * detection. + * pi-ai wire-compatibility switches defaulting every model on this route + * whose protocol declares them; each model's own `compat` overrides per + * field. What neither sets keeps the installed catalog entry's value, then + * pi-ai's own detection. A switch no model on the route could read is + * refused rather than left looking applied. */ compat?: PiAiCompatProfile /** @@ -1033,7 +1058,7 @@ export interface PiAiModelProfile { * declares the offered levels and their wire spellings. */ reasoningEfforts?: false | PiAiReasoningEfforts - /** Reasoning-dispatch switches for this model, winning over the route's. */ + /** pi-ai wire-compatibility switches for this model, winning over the route's per field; one its protocol does not declare is refused. */ compat?: PiAiCompatProfile } @@ -1047,19 +1072,80 @@ export interface PiAiModelProfile { export type PiAiModelOverride = Omit /** - * Reasoning-dispatch compatibility switches, set on the route (its models' - * default) or per model (winning over the route). Only the switches pi-ai's - * reasoning dispatch reads are offered; the rest of pi-ai's compat surface - * keeps its baseURL-derived auto-detection. pi-ai types both fields only on - * `OpenAICompletionsCompat` — the other wire protocols define their reasoning - * fields in the protocol itself — so resolution rejects a model-level switch - * anywhere else, while a route-level default skips past models it cannot fit. + * pi-ai wire-compatibility switches, set on the route (its models' default) or + * per model (winning over the route, field by field). + * + * pi-ai decides each of these from the provider id and baseURL when no layer + * sets it, and a private gateway's URL says nothing: for an endpoint it does + * not recognize the detection answers as though it were OpenAI itself, which + * is wrong for most OpenAI-compatible gateways. So every field here is one a + * deployment must be able to state because nothing can infer it, while the + * fields pi-ai's catalog sets for a named vendor stay withheld. + * + * A field belongs to the protocols whose upstream compat type declares it: a + * model-level switch its protocol does not take fails resolution, and a + * route-level one skips past models it cannot fit. "The three Responses + * protocols" below means `openai-responses`, `azure-openai-responses`, and + * `openai-codex-responses`, which pi-ai gives one shared compat type, so a + * switch settable on one is settable on all three. */ export interface PiAiCompatProfile { - /** Reasoning parameter format the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ - thinkingFormat?: PiAiThinkingFormat - /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + /** Whether the endpoint accepts `store`; `openai-completions`. */ + supportsStore?: boolean + /** + * Whether the endpoint accepts the `developer` role for the system prompt, + * which pi-ai sends only to a reasoning model; `false` keeps `system`. + * `openai-completions` and the three Responses protocols. + */ + supportsDeveloperRole?: boolean + /** Whether the endpoint accepts `reasoning_effort`; `openai-completions`. */ supportsReasoningEffort?: boolean + /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ + supportsUsageInStreaming?: boolean + /** Which output-cap field the endpoint reads; `openai-completions`. */ + maxTokensField?: NonNullable + /** Whether tool results must carry `name`; `openai-completions`. */ + requiresToolResultName?: boolean + /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */ + requiresAssistantAfterToolResult?: boolean + /** Whether thinking blocks must travel as text in `` delimiters; `openai-completions`. */ + requiresThinkingAsText?: boolean + /** Whether replayed assistant messages need an empty `reasoning_content` while reasoning is on; `openai-completions`. */ + requiresReasoningContentOnAssistantMessages?: boolean + /** Reasoning parameter format the endpoint expects; `openai-completions`. */ + thinkingFormat?: PiAiThinkingFormat + /** + * Kwargs sent as `chat_template_kwargs`, which pi-ai reads only under the + * two `chat-template` thinking formats; `openai-completions`. Nothing checks + * that pairing: the format in force may come from the installed catalog + * entry or from pi-ai's own baseURL detection, neither of which resolution + * can read, so kwargs set beside another format are sent nowhere. + */ + chatTemplateKwargs?: NonNullable + /** + * Whether the endpoint accepts `strict` in tool definitions; + * `openai-completions`, the three Responses protocols, `bedrock-converse-stream`. + */ + supportsStrictMode?: boolean + /** Prompt-cache marker convention; `openai-completions`. */ + cacheControlFormat?: NonNullable + /** + * Whether the endpoint accepts long prompt-cache retention; + * `openai-completions`, the three Responses protocols, `anthropic-messages`. + */ + supportsLongCacheRetention?: boolean + /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */ + supportsEagerToolInputStreaming?: boolean + /** Whether the endpoint accepts `cache_control` on tool definitions; `anthropic-messages`. */ + supportsCacheControlOnTools?: boolean + /** Whether the endpoint accepts the `temperature` request field; `anthropic-messages`. */ + supportsTemperature?: boolean + /** Whether to force adaptive thinking regardless of model id; `anthropic-messages`. */ + forceAdaptiveThinking?: boolean + /** Whether to replay an empty thinking signature instead of converting thinking to text; `anthropic-messages`. */ + allowEmptySignature?: boolean + /** Whether the endpoint accepts Anthropic strict tool schemas; `anthropic-messages`. */ + supportsStrictTools?: boolean } /** One request modality a pi-ai model may accept. */ @@ -1076,21 +1162,12 @@ export type PiAiModality = Model['input'][number] export type PiAiReasoningEfforts = Partial> /** One reasoning-dispatch wire format a profile may name. */ -export type PiAiThinkingFormat = Exclude - -/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ -type PiThinkingFormat = NonNullable - -/** - * pi-ai thinking formats a profile cannot name: both drive the request through - * `chatTemplateKwargs`, which this configuration does not expose. - */ -type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' +export type PiAiThinkingFormat = NonNullable ``` -依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`) +依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`) -来源:[`packages/llm/llm-pi-ai/src/config.ts:192`](../packages/llm/llm-pi-ai/src/config.ts) +来源:[`packages/llm/llm-pi-ai/src/config.ts:201`](../packages/llm/llm-pi-ai/src/config.ts) @@ -3126,6 +3203,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-model-selection`([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission-presets`([`packages/client/ui-permission-presets/src/index.ts`](../packages/client/ui-permission-presets/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-reference`([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts)) - `@deepseek-ai/dsh-client-ui-renderer`([`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) @@ -3180,6 +3258,7 @@ export interface Config { - `@deepseek-ai/dsh-code-runtime` — 抽象 `CodeRuntime`([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts)) - `@deepseek-ai/dsh-credentials` — 抽象 `Credentials`([`packages/credentials/credentials/src/index.ts`](../packages/credentials/credentials/src/index.ts)) +- `@deepseek-ai/dsh-file-reference` — 抽象 `FileReferenceService`([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — 抽象 `FileSystem`([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker` — 抽象 `DirectoryPicker`([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) - `@deepseek-ai/dsh-jobs` — 抽象 `JobRegistry`([`packages/jobs/jobs/src/index.ts`](../packages/jobs/jobs/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index c72e3f9fdd..b34c7e01b3 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: e5d09f9ac963a401d267ac924e1a8aa681cc186b -event-producer-consumer.zh.md: daf8690cff14771182cc903759c5137577607220 +event-producer-consumer.md: 5f8b5f09cd17d4caab18e9c1314a2c76a738f7ac +event-producer-consumer.zh.md: 080e86207449ab6ac642beb93c58eb8775ace4b7 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e5d09f9ac9..5f8b5f09cd 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,13 +9,13 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`team`](../packages/experimental/team) | @@ -40,7 +40,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`team`](../packages/experimental/team) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `apiproxy`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`team`](../packages/experimental/team), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `apiproxy`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`team`](../packages/experimental/team), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index daf8690cff..080e862074 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -4,20 +4,18 @@ # 事件生产方与消费方矩阵 [English](event-producer-consumer.md) | 中文 - 本矩阵展示哪些包会派发各个 harness 自有事件,以及哪些包会监听这些事件。事件之间存在多对多关系,因此密集的关系数据以表格而非一张大型关系图呈现。接收方和事件名称类型还涵盖有意绕过 `ctx.emit` 的内含派发位置,例如 subagent 生命周期封装。 - | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), [`tool-team`](../packages/experimental/tool-team) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-team`](../packages/experimental/tool-team) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`team`](../packages/experimental/team) | @@ -42,7 +40,7 @@ | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`team`](../packages/experimental/team) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `apiproxy`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`team`](../packages/experimental/team), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `apiproxy`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`team`](../packages/experimental/team), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:48`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:35`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index ac07a9f002..0855c1cc85 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: af584780da1a00ea515fb7ade824270cb660e231 -module-graph.zh.md: 0ccbf94e41d301793fb6e99b14883ce472f31674 +module-graph.md: c45315b999f9b430c572df7a691b62731567922f +module-graph.zh.md: 8df2ea969ed93f2f5de31aba0532c65d81a5c8bf diff --git a/docs/module-graph.md b/docs/module-graph.md index af584780da..c45315b999 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -139,6 +139,7 @@ flowchart TD pkg_client_ui_permission_presets["client-ui-permission-presets"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_primitives["client-ui-primitives"] + pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_general["client-ui-settings-general"] @@ -170,6 +171,8 @@ flowchart TD end subgraph group_context["packages/context"] pkg_agent_instructions["agent-instructions"] + pkg_file_reference["file-reference"] + pkg_file_reference_local["file-reference-local"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_tmux_context["tmux-context"] @@ -407,6 +410,7 @@ flowchart TD pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_attachment pkg_llm_deepseek --> pkg_credentials pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_launch_environment @@ -568,6 +572,9 @@ flowchart TD pkg_session_query --> pkg_session pkg_session_query --> pkg_session_persistence pkg_session_query --> pkg_session_title + pkg_file_reference --> pkg_agent + pkg_file_reference --> pkg_invariants + pkg_file_reference --> pkg_typert_protocol pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -854,6 +861,11 @@ flowchart TD pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session pkg_agent_instructions --> pkg_tools + pkg_file_reference_local --> pkg_agent + pkg_file_reference_local --> pkg_file_reference + pkg_file_reference_local --> pkg_invariants + pkg_file_reference_local --> pkg_system_prompt + pkg_file_reference_local --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction pkg_session_reference --> pkg_invariants @@ -861,6 +873,7 @@ flowchart TD pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_typert_protocol pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand pkg_cordis_host_runner --> pkg_invariants @@ -1163,6 +1176,7 @@ flowchart TD pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner pkg_api_remotes --> pkg_credentials + pkg_api_remotes --> pkg_file_reference pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_host_plugin_inventory pkg_api_remotes --> pkg_invariants @@ -1170,6 +1184,7 @@ flowchart TD pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence + pkg_api_remotes --> pkg_session_reference pkg_api_remotes --> pkg_settings pkg_api_remotes --> pkg_typert_registry pkg_client_runtime --> pkg_agent @@ -1207,6 +1222,7 @@ flowchart TD pkg_client_test_runtime --> pkg_invariants pkg_client_ui_input_trigger --> pkg_client_locale pkg_client_ui_input_trigger --> pkg_client_runtime + pkg_client_ui_input_trigger --> pkg_file_reference pkg_client_ui_input_trigger --> pkg_invariants pkg_client_ui_settings_models --> pkg_api_remotes pkg_client_ui_settings_models --> pkg_client_connection @@ -1236,6 +1252,14 @@ flowchart TD pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_reference --> pkg_api_remotes + pkg_client_ui_reference --> pkg_client_locale + pkg_client_ui_reference --> pkg_client_runtime + pkg_client_ui_reference --> pkg_client_ui_input_trigger + pkg_client_ui_reference --> pkg_file_reference + pkg_client_ui_reference --> pkg_invariants + pkg_client_ui_reference --> pkg_session_reference + pkg_client_ui_reference --> pkg_typert_protocol pkg_cordis_client_runner --> pkg_api_remotes pkg_cordis_client_runner --> pkg_client_connection pkg_cordis_client_runner --> pkg_client_modules @@ -1335,6 +1359,7 @@ flowchart TD pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter pkg_client_ui_tool --> pkg_api_remotes + pkg_client_ui_tool --> pkg_client_connection pkg_client_ui_tool --> pkg_client_locale pkg_client_ui_tool --> pkg_client_runtime pkg_client_ui_tool --> pkg_client_ui_conversation @@ -1358,6 +1383,7 @@ flowchart TD pkg_client_ui_workflow_run --> pkg_session pkg_client_ui_workflow_run --> pkg_tool_workflow pkg_client_ui_workflow_run --> pkg_workflow + pkg_client_ui_workspace --> pkg_client_connection pkg_client_ui_workspace --> pkg_client_locale pkg_client_ui_workspace --> pkg_client_runtime pkg_client_ui_workspace --> pkg_client_ui_conversation @@ -1466,7 +1492,7 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1506,6 +1532,7 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1556,7 +1583,8 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | +| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1602,18 +1630,19 @@ flowchart TD | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`client-runtime`](../packages/client/runtime) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-title`](../packages/session/session-title), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry) | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`client-runtime`](../packages/client/runtime), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-slots`](../packages/client/ui-slots), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-settings`](../packages/client/ui-settings), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-layout`](../packages/client/ui-layout), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1627,11 +1656,11 @@ flowchart TD | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 0ccbf94e41..8df2ea969e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -6,7 +6,6 @@ [English](module-graph.md) | 中文 `@deepseek-ai/dsh-*` harness 包之间的依赖关系。该关系图根据各包的 `peerDependencies`(规范的运行时依赖信号)生成,并按 `packages//` 层级分组。边 `a --> b` 表示包 `a` 依赖包 `b`。名称中的 `@deepseek-ai/dsh-` 前缀已移除。 - ```mermaid flowchart TD subgraph group_util["packages/util"] @@ -141,6 +140,7 @@ flowchart TD pkg_client_ui_permission_presets["client-ui-permission-presets"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_primitives["client-ui-primitives"] + pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_renderer["client-ui-renderer"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_general["client-ui-settings-general"] @@ -172,6 +172,8 @@ flowchart TD end subgraph group_context["packages/context"] pkg_agent_instructions["agent-instructions"] + pkg_file_reference["file-reference"] + pkg_file_reference_local["file-reference-local"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_tmux_context["tmux-context"] @@ -409,6 +411,7 @@ flowchart TD pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_attachment pkg_llm_deepseek --> pkg_credentials pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_launch_environment @@ -570,6 +573,9 @@ flowchart TD pkg_session_query --> pkg_session pkg_session_query --> pkg_session_persistence pkg_session_query --> pkg_session_title + pkg_file_reference --> pkg_agent + pkg_file_reference --> pkg_invariants + pkg_file_reference --> pkg_typert_protocol pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -856,6 +862,11 @@ flowchart TD pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session pkg_agent_instructions --> pkg_tools + pkg_file_reference_local --> pkg_agent + pkg_file_reference_local --> pkg_file_reference + pkg_file_reference_local --> pkg_invariants + pkg_file_reference_local --> pkg_system_prompt + pkg_file_reference_local --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction pkg_session_reference --> pkg_invariants @@ -863,6 +874,7 @@ flowchart TD pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_typert_protocol pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand pkg_cordis_host_runner --> pkg_invariants @@ -1165,6 +1177,7 @@ flowchart TD pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner pkg_api_remotes --> pkg_credentials + pkg_api_remotes --> pkg_file_reference pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_host_plugin_inventory pkg_api_remotes --> pkg_invariants @@ -1172,6 +1185,7 @@ flowchart TD pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence + pkg_api_remotes --> pkg_session_reference pkg_api_remotes --> pkg_settings pkg_api_remotes --> pkg_typert_registry pkg_client_runtime --> pkg_agent @@ -1209,6 +1223,7 @@ flowchart TD pkg_client_test_runtime --> pkg_invariants pkg_client_ui_input_trigger --> pkg_client_locale pkg_client_ui_input_trigger --> pkg_client_runtime + pkg_client_ui_input_trigger --> pkg_file_reference pkg_client_ui_input_trigger --> pkg_invariants pkg_client_ui_settings_models --> pkg_api_remotes pkg_client_ui_settings_models --> pkg_client_connection @@ -1238,6 +1253,14 @@ flowchart TD pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_reference --> pkg_api_remotes + pkg_client_ui_reference --> pkg_client_locale + pkg_client_ui_reference --> pkg_client_runtime + pkg_client_ui_reference --> pkg_client_ui_input_trigger + pkg_client_ui_reference --> pkg_file_reference + pkg_client_ui_reference --> pkg_invariants + pkg_client_ui_reference --> pkg_session_reference + pkg_client_ui_reference --> pkg_typert_protocol pkg_cordis_client_runner --> pkg_api_remotes pkg_cordis_client_runner --> pkg_client_connection pkg_cordis_client_runner --> pkg_client_modules @@ -1337,6 +1360,7 @@ flowchart TD pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter pkg_client_ui_tool --> pkg_api_remotes + pkg_client_ui_tool --> pkg_client_connection pkg_client_ui_tool --> pkg_client_locale pkg_client_ui_tool --> pkg_client_runtime pkg_client_ui_tool --> pkg_client_ui_conversation @@ -1360,6 +1384,7 @@ flowchart TD pkg_client_ui_workflow_run --> pkg_session pkg_client_ui_workflow_run --> pkg_tool_workflow pkg_client_ui_workflow_run --> pkg_workflow + pkg_client_ui_workspace --> pkg_client_connection pkg_client_ui_workspace --> pkg_client_locale pkg_client_ui_workspace --> pkg_client_runtime pkg_client_ui_workspace --> pkg_client_ui_conversation @@ -1468,7 +1493,7 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1508,6 +1533,7 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-title`](../packages/session/session-title) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1558,7 +1584,8 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | +| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1604,18 +1631,19 @@ flowchart TD | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/boot/app-boot), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`client-runtime`](../packages/client/runtime) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`commands`](../packages/interaction/commands), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-title`](../packages/session/session-title), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry) | | [`client-ui-renderer`](../packages/client/ui-renderer) | `client` | [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`client-runtime`](../packages/client/runtime), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-slots`](../packages/client/ui-slots), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-settings`](../packages/client/ui-settings), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-layout`](../packages/client/ui-layout), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1629,11 +1657,11 @@ flowchart TD | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 4e74c063fb..79d7f7f420 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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 docs/persistence-catalog.md -persistence-catalog.md: eb035b2b2529c0b36e7d2ad55dbe7990995d9093 -persistence-catalog.zh.md: ad4c70b0e1d36beac173e19a309dadefb0a6c349 +persistence-catalog.md: 6d9d21dac2c6a8eab6eee79dee9918655fc4940a +persistence-catalog.zh.md: e8ecf723787c87f21100a4cb826e0aab678027a7 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index eb035b2b25..6d9d21dac2 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -90,7 +90,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:372`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:340`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:408`](../packages/core/session/src/types.ts) ## Events @@ -226,14 +226,18 @@ Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/ * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event with `interrupted: true`; undispatched tool calls are absent. The + * marker distinguishes that prefix without re-deriving interruption from turn + * boundaries. An aborted turn with no such event streamed no visible content. */ -'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } +'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } ``` Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) ### `command/*` @@ -543,7 +547,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:54`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) @@ -557,7 +561,7 @@ Source: [`packages/core/session/src/types.ts:309`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -632,7 +636,7 @@ Source: [`packages/schedule/schedule/src/types.ts:219`](../packages/schedule/sch 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) @@ -778,7 +782,7 @@ Source: [`packages/experimental/team/src/types.ts:208`](../packages/experimental Types: [TodoItem](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) ### `tool/*` @@ -797,7 +801,7 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/ Types: [CallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) @@ -872,7 +876,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:295`](../packages/core/session/src/types.ts) ### `tool-workflow/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index ad4c70b0e1..e8ecf72378 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -92,7 +92,7 @@ export type SessionEvent = { }[T] ``` -来源:[`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:343`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:372`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:340`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:408`](../packages/core/session/src/types.ts) ## 事件 @@ -228,14 +228,18 @@ export type SessionEvent = { * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event with `interrupted: true`; undispatched tool calls are absent. The + * marker distinguishes that prefix without re-deriving interruption from turn + * boundaries. An aborted turn with no such event streamed no visible content. */ -'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } +'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } ``` 类型:[TokenUsage](subsystems/llm-streaming.md) -来源:[`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) ### `command/*` @@ -545,7 +549,7 @@ export type SessionEvent = { 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:313`](../packages/core/session/src/types.ts) @@ -559,7 +563,7 @@ export type SessionEvent = { 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -来源:[`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -634,7 +638,7 @@ export type SessionEvent = { 'session/end-seed': Record ``` -来源:[`packages/core/session/src/types.ts:332`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:336`](../packages/core/session/src/types.ts) @@ -780,7 +784,7 @@ export type SessionEvent = { 类型:[TodoItem](subsystems/session.md) -来源:[`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:303`](../packages/core/session/src/types.ts) ### `tool/*` @@ -799,7 +803,7 @@ export type SessionEvent = { 类型:[CallId](subsystems/core.md) -来源:[`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) @@ -874,7 +878,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:295`](../packages/core/session/src/types.ts) ### `tool-workflow/*` diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 21290b63f6..6666570ea6 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.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 docs/subsystems/llm-streaming.md -llm-streaming.md: c1b2ab5f1e0926864f25409c021691d078df9e0f -llm-streaming.zh.md: 7bf04bf7a4a7de63d20eb67461842ff49f3b189d +llm-streaming.md: 8731a535267bddd2bf99a06a2beee92d3083232d +llm-streaming.zh.md: b9e71bba7428dafbbc7a366a97c2d7ddc9a87d46 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index c1b2ab5f1e..8731a53526 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -298,7 +298,8 @@ One keep/drop decision covers content and metadata together: a `max-tokens` fini * {@link ContentBlock}s and a final assistant {@link Message}. * * The agent loop feeds it while logging raw chunks for replay fidelity, then - * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends, + * or `interruptedBlocks()` when cancellation cut the stream short. * * Tolerant of delta-only protocols (no block-start/end); deltas arriving for * an index already closed by `block-end` are ignored (malformed stream) so a @@ -317,6 +318,14 @@ declare class BlockAssembler { * its accumulated deltas (an unknown block type never closed by `block-end` throws). */ blocks(): ContentBlock[]; + /** + * Assemble the prefix an interrupted stream can safely finalize: closed and + * open text/reasoning blocks with non-whitespace content, in stream order. + * Tool calls are omitted because interruption precedes dispatch; retaining + * one would require a fabricated result. Open unknown blocks are also omitted. + * @returns the kept blocks; empty when nothing streamed before the interruption. + */ + interruptedBlocks(): ContentBlock[]; /** Usage from the `usage` chunk; undefined until one arrives. */ get usage(): TokenUsage | undefined; /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */ diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 7bf04bf7a4..b9e71bba74 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -304,7 +304,8 @@ interface TokenUsage { * {@link ContentBlock}s and a final assistant {@link Message}. * * The agent loop feeds it while logging raw chunks for replay fidelity, then - * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends, + * or `interruptedBlocks()` when cancellation cut the stream short. * * Tolerant of delta-only protocols (no block-start/end); deltas arriving for * an index already closed by `block-end` are ignored (malformed stream) so a @@ -323,6 +324,14 @@ declare class BlockAssembler { * its accumulated deltas (an unknown block type never closed by `block-end` throws). */ blocks(): ContentBlock[]; + /** + * Assemble the prefix an interrupted stream can safely finalize: closed and + * open text/reasoning blocks with non-whitespace content, in stream order. + * Tool calls are omitted because interruption precedes dispatch; retaining + * one would require a fabricated result. Open unknown blocks are also omitted. + * @returns the kept blocks; empty when nothing streamed before the interruption. + */ + interruptedBlocks(): ContentBlock[]; /** Usage from the `usage` chunk; undefined until one arrives. */ get usage(): TokenUsage | undefined; /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */ diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index b6358e19d6..d354125b2a 100644 --- a/docs/subsystems/session-reference.i18n.yaml +++ b/docs/subsystems/session-reference.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 docs/subsystems/session-reference.md -session-reference.md: 4140eb2a27d79ee0975d97ae9e96a1b502d74187 -session-reference.zh.md: 9dcfdbd07d4df1c54c51dc06d36af2f052969783 +session-reference.md: e3ef2c00b7ddbeed9fa3f9d074df436d8c0cc7b5 +session-reference.zh.md: 74063c1525a1f7f67663e9b11148e18902b22748 diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index 4140eb2a27..e3ef2c00b7 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -2,9 +2,23 @@ English | [中文](session-reference.zh.md) -Structured cross-session reference requests and prepared message contexts. The [package contract](../../packages/context/session-reference) defines canonical URIs, current-surface projection, tag-safe JSON and byte retention, stable errors, and the untrusted model prompt. Host adapters use these types instead of passing their UI mention syntax into the agent core. +Host-backed file discovery plus structured cross-session reference requests and prepared message contexts. The [file-reference contract](../../packages/context/file-reference) owns path-only completion records and grammar; the [session-reference contract](../../packages/context/session-reference) defines canonical URIs, current-surface projection, tag-safe JSON and byte retention, stable errors, and the untrusted model prompt. Host adapters use these types instead of passing their UI mention syntax into the agent core. -Source: [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) +Sources: [`packages/context/file-reference/src/types.ts`](../../packages/context/file-reference/src/types.ts) · [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) + +## File candidates + +`FileReferenceCandidate` is the path-only discovery result. The addressed agent supplies the working-directory scope; providers decide ranking and namespace access without reading file contents. + +```ts type-equiv +/** One path-only completion candidate inside the target session cwd. */ +interface FileReferenceCandidate { + /** User-facing path accepted by normal prompts and filesystem tools. */ + path: string + /** Directories keep completion open; files finish the mention. */ + kind: 'file' | 'directory' +} +``` ## Inputs and candidates @@ -36,6 +50,16 @@ interface SessionReferenceCandidate { } ``` +The `sessionReferenceResolver/candidates` Remote method serves the same discovery to browser consumers and attaches each candidate's canonical prompt mention. + +```ts type-equiv +/** One discovery candidate carrying its canonical prompt mention. */ +interface SessionReferenceMentionCandidate extends SessionReferenceCandidate { + /** Canonical `@[label](dsh-session:…)` mention serialized into the prompt draft. */ + mention: string +} +``` + ## Prepared messages Preparation preserves readable current-message content and returns at most one aggregated context. @@ -74,6 +98,37 @@ type SessionReferenceErrorCode = Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.fileReferences` — `FileReferenceService` (abstract seam) + +Host capability for cancellable file-reference discovery. + +```ts cordis-catalog +/** + * List file and directory candidates for one agent's working directory. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ +abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise + +/** + * Remote face of {@link list}; the decorator cannot mark the abstract + * member, so this concrete adapter carries the identical contract. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ +@Remote('list') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise +``` + +Types: [Agent](core.md) + +Source: [`packages/context/file-reference/src/index.ts:27`](../../packages/context/file-reference/src/index.ts) + ### `ctx.sessionReferenceResolver` — `SessionReferenceResolver` @@ -92,11 +147,22 @@ Exact-read consumer that prepares immutable cross-session message context. async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise /** - * Snapshot all references before enqueue and return one aggregated durable context. + * Remote face of {@link listCandidates}: the configured candidate limit + * applies, and every candidate carries the canonical mention a host inserts + * into the prompt draft. + * @param agent - target agent; self is excluded and its cwd drives ranking. + * @param query - optional case-insensitive session-id/cwd/title substring. + * @param signal - caller cancellation. + * @returns mention-carrying candidates in rank order. + */ +@Remote('candidates') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise + +/** + * Snapshot all references for one accepted direct message and return one aggregated durable context. * @param agent - target agent; references to it are rejected. * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. - * @param signal - optional cancellation boundary for host request teardown. + * @param signal - optional cancellation boundary for the active turn. * @returns detached content and optional referenced-session context. */ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise @@ -104,5 +170,5 @@ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferen Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) -Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts) +Source: [`packages/context/session-reference/src/index.ts:75`](../../packages/context/session-reference/src/index.ts) diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 9dcfdbd07d..74063c1525 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -2,9 +2,23 @@ [English](session-reference.md) | 中文 -结构化的跨会话引用请求与准备后的消息上下文。[包约定](../../packages/context/session-reference) 定义规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 +由 Host 支撑的文件发现,以及结构化的跨会话引用请求与准备后的消息上下文。[文件引用约定](../../packages/context/file-reference)负责仅含路径的补全记录与语法;[会话引用约定](../../packages/context/session-reference)定义规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 -来源:[`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) +来源:[`packages/context/file-reference/src/types.ts`](../../packages/context/file-reference/src/types.ts) · [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) + +## 文件候选项 + +`FileReferenceCandidate` 是仅含路径的发现结果。被寻址的 agent 提供工作目录范围;提供方负责排序和命名空间访问,但不会读取文件内容。 + +```ts type-equiv +/** One path-only completion candidate inside the target session cwd. */ +interface FileReferenceCandidate { + /** User-facing path accepted by normal prompts and filesystem tools. */ + path: string + /** Directories keep completion open; files finish the mention. */ + kind: 'file' | 'directory' +} +``` ## 输入与候选项 @@ -36,6 +50,16 @@ interface SessionReferenceCandidate { } ``` +`sessionReferenceResolver/candidates` Remote 方法向浏览器消费方提供同一发现能力,并为每个候选附上规范提示词 mention。 + +```ts type-equiv +/** One discovery candidate carrying its canonical prompt mention. */ +interface SessionReferenceMentionCandidate extends SessionReferenceCandidate { + /** Canonical `@[label](dsh-session:…)` mention serialized into the prompt draft. */ + mention: string +} +``` + ## 准备后的消息 准备过程保留可读的当前消息内容,并最多返回一个聚合上下文。 @@ -74,6 +98,37 @@ type SessionReferenceErrorCode = Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.fileReferences` — `FileReferenceService` (abstract seam) + +Host capability for cancellable file-reference discovery. + +```ts cordis-catalog +/** + * List file and directory candidates for one agent's working directory. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ +abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise + +/** + * Remote face of {@link list}; the decorator cannot mark the abstract + * member, so this concrete adapter carries the identical contract. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ +@Remote('list') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise +``` + +Types: [Agent](core.md) + +Source: [`packages/context/file-reference/src/index.ts:27`](../../packages/context/file-reference/src/index.ts) + ### `ctx.sessionReferenceResolver` — `SessionReferenceResolver` @@ -92,11 +147,22 @@ Exact-read consumer that prepares immutable cross-session message context. async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise /** - * Snapshot all references before enqueue and return one aggregated durable context. + * Remote face of {@link listCandidates}: the configured candidate limit + * applies, and every candidate carries the canonical mention a host inserts + * into the prompt draft. + * @param agent - target agent; self is excluded and its cwd drives ranking. + * @param query - optional case-insensitive session-id/cwd/title substring. + * @param signal - caller cancellation. + * @returns mention-carrying candidates in rank order. + */ +@Remote('candidates') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise + +/** + * Snapshot all references for one accepted direct message and return one aggregated durable context. * @param agent - target agent; references to it are rejected. * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. - * @param signal - optional cancellation boundary for host request teardown. + * @param signal - optional cancellation boundary for the active turn. * @returns detached content and optional referenced-session context. */ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise @@ -104,5 +170,5 @@ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferen Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) -Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts) +Source: [`packages/context/session-reference/src/index.ts:75`](../../packages/context/session-reference/src/index.ts) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 1c33960769..c2b5d5629e 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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 docs/subsystems/session.md -session.md: aea9d00b38e384e7a973ce168c3a75a62e70a8bb -session.zh.md: 8c56029af5144569f1ab6df73a8fe2278f9ef5b4 +session.md: 3dc8762468efd2f1515ae10e7400a7d85941b461 +session.zh.md: 3edbe86c3dea7dcfaa3c1fef6a5ca9e4a4b2c36a diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index aea9d00b38..3dc8762468 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -59,9 +59,13 @@ interface SessionEventMap { * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event with `interrupted: true`; undispatched tool calls are absent. The + * marker distinguishes that prefix without re-deriving interruption from turn + * boundaries. An aborted turn with no such event streamed no visible content. */ - 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } + 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } /** * The model requested one tool invocation: `name` with the raw `arguments` * JSON string exactly as the model produced it (unparsed). `callId` pairs the diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 8c56029af5..3edbe86c3d 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -59,9 +59,13 @@ interface SessionEventMap { * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event with `interrupted: true`; undispatched tool calls are absent. The + * marker distinguishes that prefix without re-deriving interruption from turn + * boundaries. An aborted turn with no such event streamed no visible content. */ - 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } + 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } /** * The model requested one tool invocation: `name` with the raw `arguments` * JSON string exactly as the model produced it (unparsed). `callId` pairs the diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index faff9bb2ee..1942599b5d 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.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 docs/user/guide/providers.md -providers.md: 099f434ec4602aa402239e83c708d81fcadd7732 -providers.zh.md: 367c90b525ad628b3cd86b2d22045c25064e88a1 +providers.md: 59682d38c71893c16118639cb2b24f63497a3c13 +providers.zh.md: a9deac5c8ba46950af75b02e631aa83c56a4b72f diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 099f434ec4..59682d38c7 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -79,6 +79,42 @@ Every list must name at least one modality except a model's own, where an empty Both fields state a claim about your endpoint rather than checking it. A model that declares images its endpoint does not serve is not caught here; the provider rejects the request instead. +### Request compatibility + +A gateway can hold a working key at a reachable address and still refuse every request. pi-ai decides the shape of a request — which role carries the system prompt, which field caps the output, how a thinking level travels — from the endpoint's URL, and an address it does not recognize is addressed as though it were OpenAI itself. Most OpenAI-compatible gateways refuse at least one thing OpenAI accepts. + +Two account for most of it. A model that declares reasoning has its system prompt sent as `role: "developer"`, which many gateways reject outright, and the output cap is sent as `max_completion_tokens`, which a server that only knows `max_tokens` refuses. The form has no field for either; correct them on the route in `$DSH_HOME/settings.yaml`: + +```yaml +llm-pi-ai: + providers: + my-gateway: + apiKeyEnv: GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.example/v1 + compat: + supportsDeveloperRole: false + maxTokensField: max_tokens + models: + - id: my-model +``` + +A route's `compat` is the default for its models, and a model's own wins field by field, so one model can be corrected without restating the route: + +```yaml + models: + - id: my-model + - id: my-reasoner + compat: + thinkingFormat: deepseek +``` + +What neither sets keeps the installed catalog's value for that model, and what the catalog does not describe falls to pi-ai's detection. Give every switch you name a value: a key left empty (`supportsDeveloperRole:`) is refused rather than ignored, because an empty value would erase what the catalog knows while saying nothing in its place. A name no protocol accepts is refused too, and the message lists the ones that are available. + +Each switch belongs to the protocols that declare it, so a switch valid on one `api` may be refused on another — the message names what that protocol does offer. Like `input` above, a switch states a claim about your endpoint rather than checking it: setting one your gateway does not actually need simply sends a different request. + +Every switch, its accepted values, and the protocols that take it are listed under `PiAiCompatProfile` in the [generated `dsh-llm-pi-ai` configuration reference](../../config-catalog.md#deepseek-aidsh-llm-pi-ai) — which is derived from the source, so it cannot fall behind what the adapter accepts. + ## Select a model Configured providers appear in the model picker. Selecting a model also makes it the default for new sessions. A session that has already sent a request retains the model recorded in its own log. @@ -90,9 +126,12 @@ If a saved default names a provider that was deleted, the composer displays **Se - **`MISSING_CREDENTIAL`** — Store the provider key through the Models page or supply the referenced environment variable. - **`UNKNOWN_MODEL`** — Select a configured model or add the missing model to the custom provider. - **Fetching available models returns 401** — Check the key. Model discovery calls the OpenAI-compatible `GET /models` endpoint; enter models manually for endpoints that do not provide it. +- **The gateway refuses every request although the key and URL are right** — Its request shape differs from OpenAI's. Start with `compat.supportsDeveloperRole: false` and `compat.maxTokensField: max_tokens` on the route. +- **Only reasoning models fail** — pi-ai sends their system prompt as the `developer` role, which the gateway rejects. Set `compat.supportsDeveloperRole: false`. +- **A compat switch is refused as having no value** — A key written with nothing after the colon. Give it a value, or remove the key to keep the installed catalog's. - **An image is refused before sending** — The model declares no image modality. Give a custom provider's model `input: [text, image]`; DeepSeek's own chat-completions route is text-only and cannot be configured otherwise. - **The provider rejects a request carrying an image** — The model declares images its endpoint does not actually serve. Remove `image` from whichever list granted it — the model's `input`, or the route's `defaultInput` — then start a new session: the attached image stays in the session log, so the same request repeats until the session moves off it. ## Advanced configuration -The generated [plugin configuration catalog](../../config-catalog.md) lists every supported field and default. The [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) references own direct `settings.yaml` configuration, catalog resolution, reasoning controls, credentials, and adapter errors. +The generated [plugin configuration catalog](../../config-catalog.md) lists every supported field and default for every plugin; [`dsh-llm-pi-ai`](../../config-catalog.md#deepseek-aidsh-llm-pi-ai) is the provider section this page configures. The [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) references own direct `settings.yaml` configuration, catalog resolution, reasoning controls, credentials, and adapter errors. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 367c90b525..a9deac5c8b 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -79,6 +79,42 @@ llm-pi-ai: 这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供方拒绝该请求。 +### 请求兼容性 + +网关可能持有可用的密钥、地址也通得到,却仍然拒绝每一个请求。pi-ai 依据端点的 URL 决定请求的形状——系统提示词由哪个角色承载、输出上限写在哪个字段、思考级别如何传输——而对于它无法识别的地址,会当作 OpenAI 本身来对待。多数 OpenAI 兼容网关至少会拒绝 OpenAI 所接受的某一样东西。 + +其中两样占了绝大多数。声明了推理能力的模型,其系统提示词会以 `role: "developer"` 发出,很多网关直接拒绝;输出上限则写作 `max_completion_tokens`,只认 `max_tokens` 的服务端会拒绝。表单里没有这两个字段;请在 `$DSH_HOME/settings.yaml` 的路由上更正: + +```yaml +llm-pi-ai: + providers: + my-gateway: + apiKeyEnv: GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.example/v1 + compat: + supportsDeveloperRole: false + maxTokensField: max_tokens + models: + - id: my-model +``` + +路由的 `compat` 是其模型的默认值,模型自身的则逐字段胜出,因此更正某一个模型无需重述整条路由: + +```yaml + models: + - id: my-model + - id: my-reasoner + compat: + thinkingFormat: deepseek +``` + +两者都未设置的字段,沿用已安装 catalog 为该模型记录的值;catalog 也未描述的,落到 pi-ai 的检测。凡是写下的开关都要给值:冒号后留空的键(`supportsDeveloperRole:`)会被拒绝而不是被忽略,因为空值会抹掉 catalog 已知的信息,却又没有给出任何替代。任何协议都不接受的名字同样会被拒绝,报错会列出可用的那些。 + +每个开关归属于声明了它的那些协议,因此在某个 `api` 上合法的开关,在另一个上可能被拒绝——报错会点名该协议实际提供哪些。与上面的 `input` 一样,开关陈述的是关于你的端点的一个断言,而不是对它的检查:设置一个网关其实并不需要的开关,只是发出一个不同的请求而已。 + +全部开关、各自接受的取值,以及接受它们的协议,都列在[生成的 `dsh-llm-pi-ai` 配置参考](../../config-catalog.md#deepseek-aidsh-llm-pi-ai)的 `PiAiCompatProfile` 之下——该参考派生自源码,因此不会落后于适配器实际接受的内容。 + ## 选择模型 已配置的提供方会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。 @@ -90,9 +126,12 @@ llm-pi-ai: - **`MISSING_CREDENTIAL`**:通过模型页存储提供方密钥,或提供被引用的环境变量。 - **`UNKNOWN_MODEL`**:选择已配置的模型,或向自定义提供方添加缺失的模型。 - **获取可用模型返回 401**:检查密钥。模型发现会调用 OpenAI 兼容的 `GET /models` 端点;对于不提供该端点的服务,请手动输入模型。 +- **密钥与地址都正确,网关却拒绝每一个请求**:它的请求形状与 OpenAI 不同。先在路由上设 `compat.supportsDeveloperRole: false` 与 `compat.maxTokensField: max_tokens`。 +- **只有推理模型失败**:pi-ai 把它们的系统提示词以 `developer` 角色发出,而网关拒绝该角色。设 `compat.supportsDeveloperRole: false`。 +- **某个 compat 开关因没有值而被拒绝**:冒号后什么都没写。给它一个值,或删掉该键以沿用已安装 catalog 的值。 - **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供方的模型加上 `input: [text, image]`;DeepSeek 自身的 chat-completions 路由是纯文本的,且无法通过配置改变。 - **提供方拒绝了带图片的请求**:该模型声明了其端点实际并不提供的图片能力。请从授予它图片能力的那个列表中移除 `image`——可能是模型的 `input`,也可能是路由的 `defaultInput`——然后开启新会话:附加的图片会留在会话日志里,因此在会话离开它之前,同一个请求会不断重复。 ## 进阶配置 -自动生成的[插件配置目录](../../config-catalog.md)列出所有受支持的字段与默认值。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。 +自动生成的[插件配置目录](../../config-catalog.md)列出每个插件的所有受支持字段与默认值;[`dsh-llm-pi-ai`](../../config-catalog.md#deepseek-aidsh-llm-pi-ai) 就是本页所配置的那个提供方段落。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。 diff --git a/examples/acp-agent/code-mode-image.cordis.snapshot.yml b/examples/acp-agent/code-mode-image.cordis.snapshot.yml index f722a66efd..4f227ec19a 100644 --- a/examples/acp-agent/code-mode-image.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode-image.cordis.snapshot.yml @@ -13,7 +13,7 @@ name: '@deepseek-ai/dsh-acp-demo' config: provider: deepseek-official - model: deepseek-v4-flash + model: deepseek-v4-flash-vision-exp persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none workspaceContext: @@ -37,6 +37,8 @@ name: DeepSeek models: - id: deepseek-v4-flash - inputModalities: [text, image] + inputModalities: [text] - id: deepseek-v4-pro inputModalities: [text] + - id: deepseek-v4-flash-vision-exp + inputModalities: [text, image] diff --git a/examples/acp-agent/image.cordis.snapshot.yml b/examples/acp-agent/image.cordis.snapshot.yml index f056f775d3..7a355618d3 100644 --- a/examples/acp-agent/image.cordis.snapshot.yml +++ b/examples/acp-agent/image.cordis.snapshot.yml @@ -1,9 +1,9 @@ # Keyless replay for the read-image success scenario. Include patches cannot # target entries behind a nested include, so this restates the replay overlay # directly over the base cordis.yml (the fs.cordis.snapshot.yml pattern) and -# re-pins the recorded flash model. The replay catalog declares image input on -# flash, so the strict read_image gate accepts the route and the tool result -# carries the durable image block; the live DeepSeek route cannot record this. +# re-pins the recorded vision model. The replay catalog declares image input, +# so the strict read_image gate accepts the route and the tool result carries +# the durable image block. - id: base name: '@deepseek-ai/cordis-plugin-include' config: @@ -16,7 +16,7 @@ name: '@deepseek-ai/dsh-acp-demo' config: provider: deepseek-official - model: deepseek-v4-flash + model: deepseek-v4-flash-vision-exp persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none workspaceContext: @@ -36,6 +36,8 @@ name: DeepSeek models: - id: deepseek-v4-flash - inputModalities: [text, image] + inputModalities: [text] - id: deepseek-v4-pro inputModalities: [text] + - id: deepseek-v4-flash-vision-exp + inputModalities: [text, image] diff --git a/examples/acp-agent/image.cordis.yml b/examples/acp-agent/image.cordis.yml index a12cef1e85..3d60d107b4 100644 --- a/examples/acp-agent/image.cordis.yml +++ b/examples/acp-agent/image.cordis.yml @@ -3,7 +3,8 @@ # snapshot harness scopes per run, so the overlay itself carries no paths. The # app config is restated to re-pin `deepseek-v4-flash` (base ships pro; the # authored fixture and the pinned header class are flash), because a config -# patch replaces the whole app config. +# patch replaces the whole app config. Keyless replay supplies its own exact +# image-capable catalog while the production endpoint remains unavailable. - id: base name: '@deepseek-ai/cordis-plugin-include' config: diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 4742906b8a..d32ef2abeb 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -16,6 +16,7 @@ import { } from '@deepseek-ai/dsh-acp-snapshot' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { decodeStorageRecord } from '@deepseek-ai/dsh-session' +import { OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm' /** * The acp-agent example's snapshot suite: the scenario table for @@ -222,27 +223,26 @@ const SCENARIOS: Scenario[] = [ posixOnly: true, }, // Authored keyless replays through the assembled app: the replay catalog - // declares flash image-capable (success) or text-only (refusal), and the + // declares the vision model image-capable and Flash text-only, and the // real read_image tool executes against the workspace fixture and the real - // attachment store. Both boot the same composed header (the tool registers - // with the attachment store, independent of route), so they share one class. + // attachment store. The success route selects the vision model while the + // refusal route retains text-only Flash, so each pins its exact header. { name: 'read-image', hasModelTurn: true, recorded: false, pinsHeader: true, headerClass: 'image', - // The overlay adds no prompt section (read_image carries no guidance), so - // the composed system prompt is byte-identical to the default class; only - // the tool-schema sidecar is class-specific. - systemPromptSource: 'text-turn', configPath: IMAGE_CONFIG, }, { name: 'read-image-text-route', hasModelTurn: true, recorded: false, - headerClass: 'image', + pinsHeader: true, + headerClass: 'image-text-route', + systemPromptSource: 'text-turn', + toolSchemasSource: 'read-image', configPath: IMAGE_TEXT_ROUTE_CONFIG, }, // Authored keyless replay of the oversized-image refusal: admission rejects @@ -688,7 +688,7 @@ defineAcpSnapshotSuite({ hasPwsh, }) -it('pins pi-ai image offload in the request sent by the assembled app', async () => { +it('pins native DeepSeek image offload in the request sent by the assembled app', async () => { const requests: Record[] = [] const server = createServer((request: IncomingMessage, response: ServerResponse) => { let body = '' @@ -697,13 +697,21 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () request.on('end', () => { requests.push(JSON.parse(body) as Record) response.writeHead(200, { 'content-type': 'text/event-stream' }) - response.end([ - 'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ].join('\n\n')) + const events = requests.length === 1 + ? [ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"native-read-image","type":"function","function":{"name":"read_image","arguments":"{\\"file_path\\":\\"red.png\\"}"}}]},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ] + : [ + 'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ] + response.end(events.join('\n\n')) }) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) @@ -722,7 +730,7 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () { type: 'image', data: image, mimeType: 'image/png' }, { type: 'text', text: ' with the newer image ' }, { type: 'image', data: image, mimeType: 'image/png' }, - { type: 'text', text: ', then reply with DONE.' }, + { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, ], }, ], @@ -734,13 +742,14 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () mode: 'record', configPath: IMAGE_OFFLOAD_CONFIG, fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'), + workspaceDir: join(SNAPSHOTS_DIR, 'read-image', 'workspace'), env: { DSH_SNAPSHOT_API_KEY: 'snapshot-key', - DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}/v1`, + DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`, }, }) expect(result.stderr).toBe('') - expect(requests).toHaveLength(1) + expect(requests).toHaveLength(2) const messages = requests[0]?.messages as { content?: unknown }[] | undefined const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted')) expect(offloaded?.content).toMatchInlineSnapshot(` @@ -764,11 +773,62 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () "type": "image_url", }, { - "text": ", then reply with DONE.", + "text": ", then use read_image on red.png and reply with DONE.", "type": "text", }, ] `) + + const followup = structuredClone((requests[1]?.messages as unknown[]).slice(1)) as Array<{ + role?: unknown + content?: unknown + }> + const toolMessage = followup.find(message => message.role === 'tool') + if (toolMessage === undefined || typeof toolMessage.content !== 'string') { + throw new Error('native read_image request has no tool content') + } + const cwdSpellings = [...new Set([result.cwd, ...result.cwdAliases].flatMap(cwd => ( + cwd.startsWith('/private/') ? [cwd, cwd.slice('/private'.length)] : [cwd, `/private${cwd}`] + )))] + let toolContent = toolMessage.content + for (const cwd of cwdSpellings) toolContent = toolContent.replaceAll(cwd, '{{cwd}}') + toolMessage.content = toolContent + expect(followup).toEqual([ + { + role: 'user', + content: `Compare the older image ${OFFLOADED_IMAGE_TEXT} with the newer image ${OFFLOADED_IMAGE_TEXT}, then use read_image on red.png and reply with DONE.`, + }, + { + role: 'user', + content: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n' + + 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n' + + 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).', + }, + { + role: 'assistant', + content: '', + tool_calls: [{ + id: 'native-read-image', + type: 'function', + function: { name: 'read_image', arguments: '{"file_path":"red.png"}' }, + }], + }, + { + role: 'tool', + tool_call_id: 'native-read-image', + content: '{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n', + }, + { + role: 'user', + content: [ + { type: 'text', text: 'Attached image(s) from tool result:' }, + { + type: 'image_url', + image_url: { url: `data:image/png;base64,${image}` }, + }, + ], + }, + ]) } finally { await new Promise(resolve => server.close(() => { resolve() })) } diff --git a/examples/acp-agent/tests/fixtures/image-offload.cordis.yml b/examples/acp-agent/tests/fixtures/image-offload.cordis.yml index 6007c5d44d..530f7b9663 100644 --- a/examples/acp-agent/tests/fixtures/image-offload.cordis.yml +++ b/examples/acp-agent/tests/fixtures/image-offload.cordis.yml @@ -1,5 +1,5 @@ -# Keyless assembled-request snapshot for pi-ai image offload. The local -# provider endpoint is supplied by the snapshot test; the real attachment +# Keyless assembled-request snapshot for native DeepSeek image offload. The +# local provider endpoint is supplied by the snapshot test; the real attachment # store and ACP bridge carry two uploaded images into one model request. - id: base name: '@deepseek-ai/cordis-plugin-include' @@ -8,12 +8,21 @@ patches: - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true + config: + apiKeyEnv: DSH_SNAPSHOT_API_KEY + baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL + thinking: disabled + maxRequestImageBytes: 92 + models: + - id: deepseek-v4-flash-vision-exp + contextWindow: 32768 + maxTokens: 1024 + inputModalities: [text, image] - id: acp-agent name: '@deepseek-ai/dsh-acp-demo' config: - provider: snapshot - model: vision + provider: deepseek-official + model: deepseek-v4-flash-vision-exp persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none workspaceContext: @@ -25,17 +34,3 @@ - insert: - id: attachment-local name: '@deepseek-ai/dsh-attachment-local' - - id: llm-pi-ai - name: '@deepseek-ai/dsh-llm-pi-ai' - config: - providers: - snapshot: - apiKeyEnv: DSH_SNAPSHOT_API_KEY - api: openai-completions - baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL - maxRequestImageBytes: 92 - models: - - id: vision - contextWindow: 32768 - maxTokens: 1024 - input: [text, image] diff --git a/examples/acp-agent/tests/goal-snapshots/goal-round-driver/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-round-driver/session.expected.jsonl index 09b40762e7..ec25708a77 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-round-driver/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-round-driver/session.expected.jsonl @@ -57,6 +57,7 @@ {"type":"user/message","seq":55,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-round-driver snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"step/end","seq":58,"time":0,"data":{"turn":3,"step":1}} -{"type":"turn/end","seq":59,"time":0,"data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} -{"type":"goal/change","seq":60,"time":0,"data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-round-driver snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}} +{"type":"assistant/message","seq":58,"time":0,"data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"interrupted":true},"sourceEventSeqs":[56,57],"surfaceOp":"append"} +{"type":"step/end","seq":59,"time":0,"data":{"turn":3,"step":1}} +{"type":"turn/end","seq":60,"time":0,"data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} +{"type":"goal/change","seq":61,"time":0,"data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-round-driver snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-round-driver/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-round-driver/stdout.expected.jsonl index 9a54cb88c8..c0a4330ea9 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-round-driver/stdout.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-round-driver/stdout.expected.jsonl @@ -3,3 +3,4 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL READY"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL ROUND ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/session.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.jsonl index c6396be679..af088a8ff7 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.jsonl @@ -10,5 +10,6 @@ {"type":"request/context","seq":8,"time":1785730444532,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":1785498791456,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"time":1785730444541,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"step/end","seq":11,"time":1785730444547,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":12,"time":1785730444547,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} +{"type":"assistant/message","seq":11,"time":1786334791338,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"104e9294-f9b8-4248-b7df-0b7e2a069c0a"},"interrupted":true},"sourceEventSeqs":[9,10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1786334791338,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":13,"time":1786334791338,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl index cb25d1c6bb..078b607e91 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl @@ -1,3 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl index cb63f29c34..e8990b8f69 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl @@ -6,13 +6,13 @@ {"type":"user/message","seq":4,"time":1786431644558,"data":{"content":[{"type":"text","text":"Using ONE run_code program, create a one-pixel PNG with Node.js, call read_image on it, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"08e67dbb-9432-4fe4-b7da-4483998c0a31"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1786431644558,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"99b9db8d-e4ec-4ea9-b5e2-1e4c0ff6354b"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1786431644558,"data":{"title":"Using ONE run_code program, create","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1786431644559,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1786431644560,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"request/header","seq":7,"time":1786431644559,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786431644560,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"assistant/chunk","seq":9,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":10,"time":1786431644571,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1786431644572,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"644382c5-5a05-4bda-b8dc-b9195d6a7d8b"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1786431644572,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"ef352c42-b661-4b71-8c6a-7dbbd0a9f591"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1786431644573,"data":{"turn":1,"step":1,"callId":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}} {"type":"tool/code-dispatch-start","seq":15,"time":1786431644697,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"}}} {"type":"tool/code-dispatch","seq":16,"time":1786431644828,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"},"isError":false,"content":[{"type":"text","text":"(no output)"}]}} @@ -28,6 +28,6 @@ {"type":"assistant/chunk","seq":26,"time":1786431644889,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":27,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":28,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":29,"time":1786431644890,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a6da60ea-d420-432b-ba00-9b99af045110"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28],"surfaceOp":"append"} +{"type":"assistant/message","seq":29,"time":1786431644890,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"a721cef2-2c49-4336-8d07-5f6cc15f4b67"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28],"surfaceOp":"append"} {"type":"step/end","seq":30,"time":1786431644890,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":31,"time":1786431644890,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md index e96ddb107c..786aa8fe80 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md @@ -1,6 +1,6 @@ You are an AI agent powered by DeepSeek Harness. -You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. +You are a coding assistant powered by the deepseek-v4-flash-vision-exp model. Your working directory is {{cwd}}. Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl b/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl index 89cffe656d..08d65c2c91 100644 --- a/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl +++ b/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl @@ -6,12 +6,12 @@ {"type":"user/message","seq":4,"time":1783952000003,"data":{"content":[{"type":"text","text":"Inspect this image, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0c0c0c0c-0000-4000-8000-000000000001"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1783952000004,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"0c0c0c0c-0000-4000-8000-000000000002"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1783952000004,"data":{"title":"Inspect this image, then reply","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1783952000005,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1783952000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"request/header","seq":7,"time":1783952000005,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1783952000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"assistant/chunk","seq":9,"time":1783952000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"time":1783952000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":11,"time":1783952000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":13,"time":1783952000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0c0c0c0c-0000-4000-8000-000000000003"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1783952000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"e58e49ab-9c34-4ba0-9276-9429b32c5ea0"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"step/end","seq":14,"time":1783952000010,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":15,"time":1783952000010,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl index 0b979e801f..7c74dd2d71 100644 --- a/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl +++ b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl @@ -6,13 +6,13 @@ {"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1783951000004,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"11a08f07-014a-408b-bfc5-634770ce7179"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1783951000004,"data":{"title":"Use read_image on wide.png in","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1783951000004,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1783951000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"request/header","seq":7,"time":1783951000004,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1783951000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":10,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1783951000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9676ac40-f7a8-4a7b-9326-a45fef18f11e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1783951000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"a25d70ac-2bd6-4e44-9121-ed74975ee229"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1783951000009,"data":{"turn":1,"step":1,"callId":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}} {"type":"tool/result","seq":15,"time":1783951000014,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-dimension"},"content":[{"type":"tool-result","toolCallId":"read-image-dimension","content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/wide.png\": at least one image side exceeds the 2000px limit; downscale the image and read the smaller copy"}],"isError":true}],"role":"user","id":"ee31751e-df5a-458e-8497-8113cf6107ef"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1783951000014,"data":{"turn":1,"step":1}} @@ -21,6 +21,6 @@ {"type":"assistant/chunk","seq":19,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"TOOLARGE"}}}} {"type":"assistant/chunk","seq":20,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":21,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1783951000018,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1c15b391-a95a-4113-9d47-2a1dfc991cf9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} +{"type":"assistant/message","seq":22,"time":1783951000018,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"3a95dd83-34f7-4bc0-afb6-7ba3c9b483be"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} {"type":"step/end","seq":23,"time":1783951000018,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":24,"time":1783951000018,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image/session.jsonl b/examples/acp-agent/tests/snapshots/read-image/session.jsonl index 6acde5a0ff..72064a302d 100644 --- a/examples/acp-agent/tests/snapshots/read-image/session.jsonl +++ b/examples/acp-agent/tests/snapshots/read-image/session.jsonl @@ -6,13 +6,13 @@ {"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1786344283033,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"eecd1df6-153c-4a34-b198-42bfc9f9701e"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1786344283033,"data":{"title":"Use read_image to look at","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1786344283034,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1786344283034,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"request/header","seq":7,"time":1786344283034,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786344283034,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":10,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1786344283039,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"41e9fb55-6edb-419d-b76c-554daa5a1c5d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1786344283039,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"2b71c837-237d-4d92-a857-8b8ad1a3f237"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1786344283039,"data":{"turn":1,"step":1,"callId":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}} {"type":"tool/result","seq":15,"time":1786344283069,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-call"},"content":[{"type":"tool-result","toolCallId":"read-image-call","content":[{"type":"text","text":"{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"isError":false}],"role":"user","id":"0b5779fc-523e-4275-9a32-8eb5e39f521e"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1786344283069,"data":{"turn":1,"step":1}} @@ -21,6 +21,6 @@ {"type":"assistant/chunk","seq":19,"time":1786344283078,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":20,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":21,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1786344283079,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"73a87a50-8e0b-42af-8c54-d9b6fbe375f1"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} +{"type":"assistant/message","seq":22,"time":1786344283079,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"5a45946c-b9f4-4f2c-a7c3-2569e541ec1d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} {"type":"step/end","seq":23,"time":1786344283079,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":24,"time":1786344283079,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md new file mode 100644 index 0000000000..2d4ef255b8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md @@ -0,0 +1,24 @@ +You are an AI agent powered by DeepSeek Harness. + +You are a coding assistant powered by the deepseek-v4-flash-vision-exp model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. diff --git a/knip.json b/knip.json index 7969b4f230..9edea9ab23 100644 --- a/knip.json +++ b/knip.json @@ -502,6 +502,16 @@ "tests/**/*.ts" ] }, + "packages/context/file-reference": { + "ignoreDependencies": [ + "zod" + ] + }, + "packages/context/session-reference": { + "ignoreDependencies": [ + "zod" + ] + }, "packages/session/session-checkpoint-policy": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index c72b4b9da3..71e2a21e43 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -464,7 +464,9 @@ describe('ACP prompt lifecycle', () => { await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] })) .resolves.toEqual({ stopReason: 'end_turn' }) - await vi.waitFor(() => { expect(messageText(harness!)).toBe('next') }) + // 'partial' is the cancelled turn's finalized prefix update; 'next' proves + // the second prompt settled independently of the aborted turn's late end. + await vi.waitFor(() => { expect(messageText(harness!)).toBe('partialnext') }) }) it('a retry turn adopts the prompt instead of rejecting at the failed turn end', async () => { diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 0bc596bf71..b4415a6457 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -58,37 +58,41 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^" + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 210ec0bf80..6a5164f160 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -4,16 +4,20 @@ import type { Context } from '@deepseek-ai/cordis' import commandsRemote from '@deepseek-ai/dsh-commands/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' +import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' +import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' export type { TypertClientRemote as ClientRemote } from '@deepseek-ai/dsh-typert-protocol' export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type {} from '@deepseek-ai/dsh-commands/remote' +export type {} from '@deepseek-ai/dsh-file-reference/remote' export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' +export type {} from '@deepseek-ai/dsh-session-reference/remote' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. export type { ApiRemoteForwardedEvent } from '../types.ts' @@ -86,6 +90,10 @@ export type { // reason: a Client contribution names what it sends without importing a Host // package, and this assembly is where both planes legitimately meet. export type { JsonValue } from '@deepseek-ai/dsh-session/types' +// Reference-discovery result vocabulary for the fileReferences and +// sessionReferenceResolver namespaces. +export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +export type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' declare module '@deepseek-ai/cordis' { interface Context { @@ -106,7 +114,8 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - commandsRemote, goalsRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, + commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, + pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index a0161dd047..49c7276d42 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -24,6 +24,12 @@ "path": "../../credentials/credentials" }, + { + "path": "../../context/file-reference" + }, + { + "path": "../../context/session-reference" + }, { "path": "../../extensions/cordis-host-runner" }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 203f8ff017..d1a9c3f69d 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -79,6 +79,12 @@ writeEveryEvents: 200 writeIntervalMs: 5000 + - id: session-reference + name: '@deepseek-ai/dsh-session-reference' + + - id: file-reference-local + name: '@deepseek-ai/dsh-file-reference-local' + # Whole-log turn/step counts for the chat stats strip (the sessionStats # projection key); the projection registry itself is a base-layer row. - id: session-stats @@ -226,7 +232,7 @@ name: '@deepseek-ai/dsh-client-ui-workspace' # Input triggers: the '/' | '@' pipeline (ui-input-trigger), the command surface over - # it (ui-commands), and the two reference sources (ui-skill / ui-subagent). + # it (ui-commands), and the reference sources (ui-skill / ui-reference). - id: ui-input-trigger name: '@deepseek-ai/dsh-client-ui-input-trigger' @@ -239,6 +245,9 @@ - id: ui-subagent name: '@deepseek-ai/dsh-client-ui-subagent' + - id: ui-reference + name: '@deepseek-ai/dsh-client-ui-reference' + # Background jobs: the session-header list over the jobsBySession mirror. - id: ui-jobs name: '@deepseek-ai/dsh-client-ui-jobs' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 3cf68746c0..6180ce7de7 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -76,6 +76,7 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-skill": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-reference": "workspace:^", "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", @@ -95,8 +96,11 @@ "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-log-export": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index a1de337e80..3928f30f1b 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.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 packages/client/README.md -README.md: cff74ddb048df8b37643f4c44f01e2bda4160a77 -README.zh.md: 5e5d623509b9152fb7b59ea228cfa42720b737af +README.md: fe57d58a0f4fa1c9bff2699ffb363c80197fc5ed +README.zh.md: c721710c9f20ba10f20392c207e1de4169ec0e12 diff --git a/packages/client/README.md b/packages/client/README.md index cff74ddb04..fe57d58a0f 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -29,6 +29,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-commands/`](ui-commands/README.md) | Provides session-aware command discovery and dispatch. | | [`ui-input-trigger/`](ui-input-trigger/README.md) | Coordinates inline command and reference suggestions. | | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. | +| [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source. | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. | | [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs in the conversation header. | | [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 5e5d623509..c721710c9f 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -29,6 +29,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-commands/`](ui-commands/README.md) | 提供会话感知的命令发现与分发。 | | [`ui-input-trigger/`](ui-input-trigger/README.md) | 协调内联命令和引用建议。 | | [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill(技能)引用。 | +| [`ui-reference/`](ui-reference/README.md) | 统一的 Web `@file` / `@session` 引用 source。 | | [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent(子 agent)导航、子级 transcript(文本记录)的状态和内联引用。 | | [`ui-jobs/`](ui-jobs/README.md) | 在会话标题栏列出当前会话的后台任务。 | | [`ui-model-selection/`](ui-model-selection/README.md) | 在对话界面中提供模型选择。 | diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 1064e82e37..cfcae2eb1f 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1563,6 +1563,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // live under one workspace, whose account carries them in attach order. const wid = (raw: string): WorkspaceId => raw as WorkspaceId const fixtureEpoch = new Date(Date.now() - 300_000).toISOString() + const FIXTURE_HOME = '/home/fixture' const workspaces: WorkspaceView[] = options.empty ? [] : [{ workspaceId: wid('fx-ws-fixture'), path: '/tmp/fixture', @@ -1570,6 +1571,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { sessionIds: [sid('fx-alpha'), sid('fx-beta'), sid('fx-gamma')], createdAt: fixtureEpoch, updatedAt: fixtureEpoch, + }, { + workspaceId: wid('fx-ws-home'), + path: `${FIXTURE_HOME}/Documents/project`, + title: 'project', + sessionIds: [], + createdAt: fixtureEpoch, + updatedAt: fixtureEpoch, }] let nextWorkspace = 1 // Registry-global archive set mirroring the host: archived sessions keep @@ -1580,7 +1588,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // deterministic content mirroring the design mock so assembled Web tests // and snapshots can walk it. Leaves are materialized lazily: a child listed // by its parent lists as empty until something is created inside it. - const FIXTURE_HOME = '/home/fixture' const directoryTree = new Map([ ['/', ['home']], ['/home', ['fixture']], @@ -1859,6 +1866,51 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) /** Canonical fixture implementation of the generated Goal Remote contract. */ + /** Canonical fixture implementation of the generated reference-discovery Remote contracts. */ + const referenceRemotes = { + files(id: SessionId, query: string): RpcResult<{ path: string; kind: 'file' | 'directory' }[]> { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const needle = query.toLocaleLowerCase() + const items = [ + { path: 'notes', kind: 'directory' as const }, + { path: 'README.md', kind: 'file' as const }, + { path: 'notes/demo.txt', kind: 'file' as const }, + ].filter(item => item.path.toLocaleLowerCase().includes(needle)) + return { ok: true, value: items } + }, + sessions(id: SessionId, query: string): RpcResult<{ + sessionId: SessionId + label: string + cwd?: string + createdAt: number + mention: string + }[]> { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const needle = query.toLocaleLowerCase() + const value = sessions + .filter(item => item.sessionId !== id) + .filter(item => String(item.sessionId).toLocaleLowerCase().includes(needle) + || item.cwd?.toLocaleLowerCase().includes(needle) === true) + .map((item) => { + const label = item.sessionId === sid('fx-beta') ? 'Fixture child session' : String(item.sessionId) + const encoded = btoa(JSON.stringify(item.sessionId)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/u, '') + return { + sessionId: item.sessionId, + label, + ...item.cwd === undefined ? {} : { cwd: item.cwd }, + createdAt: item.updatedAt, + mention: `@[${label}](dsh-session:${encoded})`, + } + }) + return { ok: true, value } + }, + } + const goalRemotes = { create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> { const missing = requireGoalSession(id) @@ -2565,7 +2617,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, host: { describe: request => ok(request, { - version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, canOpenPath: true, + version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true, }), // Deterministic native pick: the keyless lanes drive the full // pick-then-adopt path without an OS chooser (design-mock content, @@ -3046,6 +3098,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { args: { agentId: SessionId line?: string + query?: string images?: readonly unknown[] ref?: { id: string; revision: number } request?: { objective?: string; maxGoalRounds?: number } @@ -3055,6 +3108,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { switch (endpoint) { case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId)) case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string, args.images ?? [])) + case 'fileReferences/list': return Promise.resolve(referenceRemotes.files(sessionId, args.query ?? '')) + case 'sessionReferenceResolver/candidates': return Promise.resolve(referenceRemotes.sessions(sessionId, args.query ?? '')) case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { objective: args.request?.objective as string, ...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds }, diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index c4470b13aa..9847d48cdf 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -62,7 +62,7 @@ export interface ConnectionHandle { readonly api: IApiClient /** Whether the current page authority is loopback; non-browser contexts default to true. */ readonly isLoopback: boolean - /** Generation-scoped Host facts, including native path-open capability. */ + /** Generation-scoped Host facts, including the account home and native path-open capability. */ readonly hostDescription: HostDescriptionSource /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index e5290c0dbb..7965d627f4 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -80,7 +80,7 @@ describe('connection lifecycle', () => { try { await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff expect(connected).toBe(0) // never announced during the failed generation - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) await vi.waitFor(() => { expect(connected).toBe(1) }) } finally { controller.stop() @@ -102,7 +102,7 @@ describe('connection lifecycle', () => { }, }) } - return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) } let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) @@ -188,7 +188,7 @@ describe('connection lifecycle', () => { describeCalls++ return describeCalls === 1 ? firstDescribe.promise - : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) } const states: ConnectionState[] = [] let connected = 0 @@ -201,7 +201,7 @@ describe('connection lifecycle', () => { try { await vi.waitFor(() => { expect(api.openMuxCount).toBe(1) }) api.endStreams() - firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) await vi.waitFor(() => { expect(describeCalls).toBe(2) }) await vi.waitFor(() => { expect(connected).toBe(1) }) @@ -283,7 +283,7 @@ describe('connection lifecycle', () => { controller.start() try { await vi.waitFor(() => { expect(describeCalls).toBe(3) }) - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) await vi.waitFor(() => { expect(connected).toBe(1) }) expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission } finally { diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index bee4fc0ce0..7c9dc6accb 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -74,10 +74,11 @@ export class FakeApiClient implements IApiClient { version: string cwd: string attachedSessions: number + home: string canOpenPath: boolean }>> = () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true, + version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index 4fe4a4ff2d..ed5089d345 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -523,7 +523,9 @@ describe('createFixtureApi', () => { it('describe answers the fixture identity', async () => { const api = createFixtureApi() const response = await api.host.describe(req({})) - expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } }) + expect(response.result).toMatchObject({ + ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1, home: '/home/fixture' }, + }) const empty = await createFixtureApi({ empty: true }).host.describe(req({})) expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } }) }) @@ -548,10 +550,16 @@ describe('createFixtureApi', () => { const api = createFixtureApi() const listed = await api.workspace.list(req({})) if (!listed.result.ok) throw new Error('list failed') - expect(listed.result.value.items).toEqual([expect.objectContaining({ - workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture', - sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'], - })]) + expect(listed.result.value.items).toEqual([ + expect.objectContaining({ + workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture', + sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'], + }), + expect.objectContaining({ + workspaceId: 'fx-ws-home', path: '/home/fixture/Documents/project', title: 'project', + sessionIds: [], + }), + ]) // path collision → the existing entity comes back, created:false, no frame. const reused = await api.workspace.create(req({ path: '/tmp/fixture' })) if (!reused.result.ok) throw new Error('reuse failed') diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 837d27ae0a..3e383dff61 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.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 packages/client/runtime/README.md -README.md: cad8a08f015a7115ebff76cfce4d3ab5eb7a175e -README.zh.md: 262ab1eeb8ce2d09e801e2af790046d8cdc11e55 +README.md: eae49163c14f122a3603bcaa8a61aaac7a9a7270 +README.zh.md: 9e8f82a0bcf0d2021c9b16bd51acf3c72dd04cb2 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index cad8a08f01..eae49163c1 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -26,6 +26,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base SlotRegistry gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; ui-renderer creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. +`abbreviateHomePath` is the display-only POSIX home abbreviation used by Web Workspace hover cards and Tool summaries; a Windows drive or UNC path stays verbatim, and a missing, empty, or filesystem-root home leaves the path unchanged. + `indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives. `SessionListState.jobsBySession` mirrors the Host's `session/jobs` frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: `session/subscribed` drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and `host/session-removed` drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order. @@ -54,7 +56,7 @@ The Chat builder keeps one mutable keyed store per Session. Content updates noti ## Trajectory request data -Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan. +Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A cancellation-finalized `assistant/message` retains its durable result seq and provider provenance but does not complete the request; `step/end` classifies that request as an error. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan. ## Code Mode child-call tree diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 262ab1eeb8..9e8f82a0bc 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -26,6 +26,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线 SlotRegistry 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;ui-renderer 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或条目 store。 +`abbreviateHomePath` 是 Web Workspace 悬停卡片与 Tool 摘要使用的仅展示 POSIX 家目录缩写;Windows 盘符或 UNC 路径保持原样,缺失、空或文件系统根的 home 不改写路径。 + `indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。 `SessionListState.jobsBySession` 按 last-wins 镜像宿主的 `session/jobs` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。 @@ -54,7 +56,7 @@ Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新 ## Trajectory 请求数据 -Trajectory Definition 组装出一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。 +Trajectory Definition 组装出一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。由取消定稿的 `assistant/message` 会保留持久结果 seq 和提供方信息,但不会将请求标记为完成;`step/end` 会把该请求归类为错误。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。 ## Code Mode 子调用树 diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index c3ef72e253..e07267d487 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -38,7 +38,11 @@ export interface ISession { * @param mode - 'queue' appends a turn; 'steer' interrupts the running one. * @returns acceptance, or the business error (also mirrored into snapshot.promptError). */ - prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise> + prompt( + content: PromptContentPart[], + mode: 'queue' | 'steer', + signal?: AbortSignal, + ): Promise> /** * Resolve one durable image referenced by this session. * @param attachmentId - opaque id found in the folded session log. diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index bc8eb73330..90a2b2f5f3 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -46,7 +46,7 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' export { DirectoryBrowseError, WorkspaceCreateError, WorkspaceRuntime } from './workspaces/service.ts' -export { resolveWorkspacePath } from './workspaces/path.ts' +export { abbreviateHomePath, resolveWorkspacePath } from './workspaces/path.ts' // Contract only: the scope implementation and its Host transport belong to // dsh-client-ui-settings (see that package's settings-scope.ts). export type { @@ -85,7 +85,7 @@ export { } from './sessions/conversation.ts' export { emptyAssistantBlock } from './sessions/partial.ts' export { isTokenDelta } from './sessions/assistant-timing.ts' -export { contextForm, contextProvenance } from './sessions/context-provenance.ts' +export { contextForm, contextProvenance, sessionRecallLabels } from './sessions/context-provenance.ts' export { displayFailureMessage } from './sessions/failure-display.ts' export type { ConversationContext, ConversationContextOriginKind, diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts index 5912ea388a..dbd3b2dd30 100644 --- a/packages/client/runtime/src/client/sessions/context-provenance.ts +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -58,6 +58,19 @@ function joined(names: string[]): string | null { return names.length > 0 ? names.join(', ') : null } +/** + * The referenced-session labels of one durable `session-reference` recall + * source, in first-seen order; empty for every other source shape, including + * a foreign or older log whose reference entries carry no readable label. + * @param source - the logged `user/message` source, exactly as recorded. + * @returns distinct non-empty reference labels. + */ +export function sessionRecallLabels(source: unknown): string[] { + const record = asRecord(source) + if (record === null || readString(record, 'kind') !== 'session-reference') return [] + return collect(record, 'references', 'label') +} + /** * Project one durable message source onto its transcript role and producer name. * diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 698d8ab88f..44b6f7f42d 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -92,14 +92,14 @@ export interface AssistantTiming { completedTime: number } -/** A finalized (or interruption-frozen) assistant message. */ +/** A finalized assistant message or an interruption-frozen streaming prefix. */ export interface AssistantMessageNode { kind: 'assistant' seq: number /** - * Stable identity of the finalized model output, carried from the - * `assistant/message` event. Absent on interruption-frozen partials: those - * were never finalized, so they address no durable message. + * Stable identity carried from the `assistant/message` event. Absent only on + * synthetic interruption fallbacks assembled from chunks without a durable + * assistant message. */ messageId?: MessageId /** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */ @@ -112,8 +112,9 @@ export interface AssistantMessageNode { requestConfig?: AssistantRequestConfig /** Timing derived from the recorded step/chunk/message event sequence. */ timing?: AssistantTiming - /** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker. - * Synthetic seq (fractional, derived from the turn/end seq) keeps it ordered inside the flow. */ + /** Prefix of an aborted turn, rendered with a 已停止 marker. A durable + * finalized prefix uses its event seq; a chunk-only fallback uses a fractional + * seq derived from the closing boundary to keep it ordered inside the flow. */ interrupted?: true } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index f38e3c757b..02939cf28f 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -187,7 +187,11 @@ export class Session implements SessionFace { * @param mode - queue appends after the current turn; steer interrupts it. * @returns the prompt result (also mirrored into promptError on failure). */ - async prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise> { + async prompt( + content: PromptContentPart[], + mode: 'queue' | 'steer', + signal?: AbortSignal, + ): Promise> { this.promptError = null this.lastAgentError = null // Synchronous, before the first await: the blank → engaging edge must be @@ -204,7 +208,7 @@ export class Session implements SessionFace { mode, content, clientTimeZone: resolvedClientTimeZone(), - })).result + }, signal)).result } else if (this.address.mode === 'one-shot') { result = { ok: false, @@ -231,7 +235,7 @@ export class Session implements SessionFace { ? [{ type: 'text' as const, text: part.text }] : []), clientTimeZone: resolvedClientTimeZone(), - })).result + }, signal)).result result = routed.ok ? { ok: true, value: { accepted: true } } : routed } } diff --git a/packages/client/runtime/src/client/workspaces/path.ts b/packages/client/runtime/src/client/workspaces/path.ts index d1512177ad..8bb3aa6645 100644 --- a/packages/client/runtime/src/client/workspaces/path.ts +++ b/packages/client/runtime/src/client/workspaces/path.ts @@ -5,9 +5,32 @@ * @returns an absolute path when a workspace root is available, otherwise the original path. */ export function resolveWorkspacePath(cwd: string | undefined, path: string): string { - if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path + if (path.startsWith('/') || isWindowsStylePath(path)) return path if (cwd === undefined || cwd === '') return path const base = cwd.replace(/[/\\]+$/, '') const rel = path.replace(/^[/\\]+/, '') return `${base}/${rel}` } + +/** Drive-letter or UNC path; Web display must not rewrite these as `~`. */ +function isWindowsStylePath(value: string): boolean { + return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\') +} + +/** + * Display-only POSIX home abbreviation. Windows drive and UNC paths stay + * verbatim, including when `home` itself is a Windows path. A missing, empty, + * or filesystem-root `home` leaves `path` unchanged so `/` cannot become `~`. + * @param path - absolute or already-short display path. + * @param home - host account home from `host.describe`; absent skips abbreviation. + * @returns `~` or `~/…` for the POSIX home and its descendants, otherwise `path`. + */ +export function abbreviateHomePath(path: string, home?: string): string { + if (home === undefined || home === '') return path + if (isWindowsStylePath(path) || isWindowsStylePath(home)) return path + const root = home.replace(/\/+$/, '') + if (root === '' || root === '/') return path + if (path.replace(/\/+$/, '') === root) return '~' + if (path.startsWith(`${root}/`)) return `~${path.slice(root.length)}` + return path +} diff --git a/packages/client/runtime/tests/client-apply.client.spec.ts b/packages/client/runtime/tests/client-apply.client.spec.ts index 1026c55569..199181fe2e 100644 --- a/packages/client/runtime/tests/client-apply.client.spec.ts +++ b/packages/client/runtime/tests/client-apply.client.spec.ts @@ -91,7 +91,7 @@ describe('runtime client apply', () => { expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new') // Mux sink and onConnected route without throwing (manager semantics own the behavior). bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never }) - bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }) + bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }) }) it('selects the recent Workspace once when the first baselines have no current session', async () => { @@ -104,7 +104,7 @@ describe('runtime client apply', () => { })) bench.api.onList = () => Promise.resolve(ok({ items: [] })) - bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }) + bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }) await flushMicrotasks() const sessions = bench.ctx.get('sessions') as SessionRuntime diff --git a/packages/client/runtime/tests/context-provenance.client.spec.ts b/packages/client/runtime/tests/context-provenance.client.spec.ts index e243660d60..b64902719c 100644 Binary files a/packages/client/runtime/tests/context-provenance.client.spec.ts and b/packages/client/runtime/tests/context-provenance.client.spec.ts differ diff --git a/packages/client/runtime/tests/fake-api.client.ts b/packages/client/runtime/tests/fake-api.client.ts index 33a0efbbfd..e5bdaa5f66 100644 --- a/packages/client/runtime/tests/fake-api.client.ts +++ b/packages/client/runtime/tests/fake-api.client.ts @@ -108,10 +108,11 @@ export class FakeApiClient implements IApiClient { version: string cwd: string attachedSessions: number + home: string canOpenPath: boolean }>> = () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true, + version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) diff --git a/packages/client/runtime/tests/path.client.spec.ts b/packages/client/runtime/tests/path.client.spec.ts new file mode 100644 index 0000000000..455df0d112 --- /dev/null +++ b/packages/client/runtime/tests/path.client.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { abbreviateHomePath, resolveWorkspacePath } from '../src/client/workspaces/path.ts' + +describe('abbreviateHomePath', () => { + it('collapses a POSIX home and its descendants', () => { + expect(abbreviateHomePath('/Users/u', '/Users/u')).toBe('~') + expect(abbreviateHomePath('/Users/u/', '/Users/u')).toBe('~') + expect(abbreviateHomePath('/Users/u/Documents/project', '/Users/u')).toBe('~/Documents/project') + expect(abbreviateHomePath('/Users/u/Documents/project/', '/Users/u/')).toBe('~/Documents/project/') + }) + + it('keeps prefix-adjacent names and non-home paths', () => { + expect(abbreviateHomePath('/Users/u2/a.ts', '/Users/u')).toBe('/Users/u2/a.ts') + expect(abbreviateHomePath('/etc/hosts', '/Users/u')).toBe('/etc/hosts') + expect(abbreviateHomePath('src/a.ts', '/Users/u')).toBe('src/a.ts') + expect(abbreviateHomePath('~/already', '/Users/u')).toBe('~/already') + }) + + it('does not abbreviate when home is missing, empty, or the filesystem root', () => { + expect(abbreviateHomePath('/Users/u/a.ts')).toBe('/Users/u/a.ts') + expect(abbreviateHomePath('/Users/u/a.ts', '')).toBe('/Users/u/a.ts') + expect(abbreviateHomePath('/etc/hosts', '/')).toBe('/etc/hosts') + expect(abbreviateHomePath('/etc/hosts', '///')).toBe('/etc/hosts') + }) + + it('leaves Windows drive and UNC paths verbatim', () => { + expect(abbreviateHomePath('C:\\Users\\u\\project', 'C:\\Users\\u')).toBe('C:\\Users\\u\\project') + expect(abbreviateHomePath('C:/Users/u/project', '/Users/u')).toBe('C:/Users/u/project') + expect(abbreviateHomePath('/Users/u/project', 'C:\\Users\\u')).toBe('/Users/u/project') + expect(abbreviateHomePath('\\\\server\\share\\u', '\\\\server\\share\\u')).toBe('\\\\server\\share\\u') + }) +}) + +describe('resolveWorkspacePath', () => { + it('joins a relative path under cwd and passes absolute paths through', () => { + expect(resolveWorkspacePath('/w', 'src/a.ts')).toBe('/w/src/a.ts') + expect(resolveWorkspacePath('/w/', '/abs/a.ts')).toBe('/abs/a.ts') + expect(resolveWorkspacePath(undefined, 'src/a.ts')).toBe('src/a.ts') + expect(resolveWorkspacePath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts') + }) +}) diff --git a/packages/client/runtime/tests/wire-events.client.spec.ts b/packages/client/runtime/tests/wire-events.client.spec.ts index f6798bd914..13365c1462 100644 --- a/packages/client/runtime/tests/wire-events.client.spec.ts +++ b/packages/client/runtime/tests/wire-events.client.spec.ts @@ -127,7 +127,7 @@ describe('wire event bridge', () => { const bench = await mount() let resets = 0 bench.ctx.on('connection/reset', () => { resets++ }) - const description = { version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true } + const description = { version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true } bench.sinks?.onConnected?.(description) bench.sinks?.onConnected?.(description) // second generation after a reconnect expect(resets).toBe(2) diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 72f0346680..30c157edf1 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -57,7 +57,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ +export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|file-reference|session|llm|tools|brand)(\/|$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 080051a1b8..e1d7e10227 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md -README.md: d9b774bdf5bfc2beaa33fe0d3ada8263b863798d -README.zh.md: 94da3def811fb901132f53fd6dbf4de0ccd6b3c8 +README.md: 7793273e053142b232cf6e810cd915ddbdeb90b0 +README.zh.md: f45dfa6d72ecf5b49a12c9550d41dbcbc35f9be0 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index d9b774bdf5..7793273e05 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -22,7 +22,7 @@ Logged non-user messages render as a default-collapsed disclosure whose header n A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). -The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent. +The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent. A path click through the injected `openFile` asks the Host to open that path (relative paths resolve against the session cwd). A Host or OS refusal opens an in-page dialog with the thrown reason and a Retry of the same path; Cancel, Escape, the close control, and a mask click dismiss it ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md)). The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 94da3def81..f45dfa6d72 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -20,7 +20,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 -聊天视图保留工具的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.md),由后者渲染运行时已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。 +聊天视图保留工具的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.md),由后者渲染运行时已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。经注入的 `openFile` 点击路径会请 Host 打开该路径(相对路径按会话 cwd 解析)。Host 或操作系统拒绝时,页面内对话框展示抛出的原因,并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框([决策](../../../.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md))。 聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 463c481eef..59b010f93a 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -337,6 +337,7 @@ export function apply(ctx: Context): void { inputTriggers.toggleSource('command', { trigger: '/', query: '', + quoted: false, position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline', span: { ...selection, draftRev: snapshot.draftRev }, }) @@ -396,10 +397,7 @@ export function apply(ctx: Context): void { fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner), openFile: (path) => { const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd - void workspaces.openPath(resolveWorkspacePath(cwd, path)).catch(() => { - // Host/OS open failures stay silent in the chat row; the native - // app surfaces its own error dialog when the path is unusable. - }) + return workspaces.openPath(resolveWorkspacePath(cwd, path)) }, loadOlder: () => { void scoped.loadOlder() }, loadImage: attachment => conversation.resolveImage(sessionId, attachment), diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index d16608c856..ffee12984f 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -190,3 +190,8 @@ .toBottom:hover { background: var(--dsw-alias-button-floating-hover); } + +/* Host open-path refusal: same dialog family as the workspace folder error. */ +.modalAction { + min-width: 72px; +} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 4d9df510e1..80f9bc2d73 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -1,7 +1,8 @@ // ChatView: the default conversation view — one stable keyed parent list over // final business Nodes, plus paging, pending steering and bottom-follow. // Each row dispatches through 'conversation.chat.node'; ui-tool owns the -// tool-call renderer and its recursive root/subcall composition. +// tool-call renderer and its recursive root/subcall composition. A Host +// open-path refusal from the injected opener is an in-page dialog here. // // Scroll: when nested under `[data-conversation-scroll]` (active conversation // column), that host is the scrollport and this view is flow content; when @@ -14,7 +15,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client' -import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps, RenderMessageImages } from '../contract/slots.ts' import { PendingSteeringBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' @@ -95,6 +96,17 @@ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollP } } +/** Host/OS refusal text for the file-open dialog; empty throws keep a locale fallback. */ +function openFailureMessage(error: unknown, fallback: string): string { + const message = error instanceof Error ? error.message : String(error) + return message === '' ? fallback : message +} + +/** ProducedFiles opens the session workspace as `.`. */ +function isFolderOpenPath(path: string): boolean { + return path === '.' +} + function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { @@ -159,6 +171,40 @@ export function ChatView({ const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) const selectedCallId = useStore(s => s.selection?.callId) + const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null) + const [fileOpenBusy, setFileOpenBusy] = useState(false) + // Close/retry must ignore a settlement that started before the latest + // gesture; otherwise a cancelled in-flight refusal reopens the dialog. + const fileOpenRequest = useRef(0) + + const requestOpenFile = useCallback((path: string) => { + const id = ++fileOpenRequest.current + setFileOpenBusy(true) + void openFile(path).then( + () => { + if (id !== fileOpenRequest.current) return + setFileOpenError(null) + setFileOpenBusy(false) + }, + (error: unknown) => { + if (id !== fileOpenRequest.current) return + setFileOpenError({ + path, + message: openFailureMessage( + error, + t(isFolderOpenPath(path) ? 'fileOpen.folderUnknown' : 'fileOpen.unknown'), + ), + }) + setFileOpenBusy(false) + }, + ) + }, [openFile, t]) + + const closeFileOpenError = useCallback(() => { + fileOpenRequest.current += 1 + setFileOpenError(null) + setFileOpenBusy(false) + }, []) const pendingSteering = useMemo( () => inbox.filter(item => item.placement === 'steering'), @@ -390,7 +436,7 @@ export function ChatView({ useSession={useSession} selectedCallId={selectedCallId} cwd={cwd} - openFile={openFile} + openFile={requestOpenFile} inspectCall={inspectCall} forkAt={forkAt} renderMessageImages={renderMessageImages} @@ -431,6 +477,44 @@ export function ChatView({ )} + {fileOpenError !== null && ( + { requestOpenFile(fileOpenError.path) }} + t={t} + /> + )} ) } + +/** In-page Host open-path refusal: the wire reason plus a retry of the same path. */ +function FileOpenErrorDialog({ + path, message, busy, onClose, onRetry, t, +}: { + path: string + message: string + busy: boolean + onClose: () => void + onRetry: () => void + t: ChatViewSlotProps['t'] +}) { + return ( + + + + + )} + /> + ) +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 36824d228d..6aa1386a3e 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -28,6 +28,11 @@ color: var(--dsw-alias-label-primary); } +.referenceSummary { + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} .contextRow { padding: 2px 0; } @@ -269,7 +274,8 @@ } /* Reference chip projection inside a user bubble (`name` model - spans render as chips; free geometry — no textarea pairing here). */ + spans and metadata-confirmed sessions render as chips; free geometry means + the textarea overlay's metric pairing does not apply here). */ .refChip { display: inline-block; margin: 0 2px; diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index ecb0d11caa..4cb553d38e 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -152,21 +152,36 @@ function TurnMaxTokensItem({ t }: { * scan as the composer, minus the lexicon: sent tokens were validated at * compose time, so shape alone decorates). */ -function projectUserText(text: string): ReactNode { +function projectUserText(text: string, sessionLabels: readonly string[]): ReactNode { + const ranges: { start: number; end: number; label: string; kind: 'session' | 'plain' }[] = [] + for (const rawLabel of [...new Set(sessionLabels)].sort((a, b) => b.length - a.length)) { + const label = `@${rawLabel}` + let start = text.indexOf(label) + while (start >= 0) { + ranges.push({ start, end: start + label.length, label, kind: 'session' }) + start = text.indexOf(label, start + label.length) + } + } const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g - const parts: ReactNode[] = [] - let cursor = 0 let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { const tokenStart = m.index + (m[1]?.length ?? 0) const label = m[2] ?? '' + ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' }) + } + ranges.sort((a, b) => a.start - b.start || b.end - a.end) + const parts: ReactNode[] = [] + let cursor = 0 + for (const range of ranges) { + if (range.start < cursor) continue + const { start: tokenStart, end, label, kind } = range if (tokenStart > cursor) parts.push() parts.push( - + {label} , ) - cursor = tokenStart + label.length + cursor = end } if (parts.length === 0) return if (cursor < text.length) parts.push() @@ -175,7 +190,7 @@ function projectUserText(text: string): ReactNode { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, t, + content, renderMessageImages, actions, pending = false, referenceLabels = [], t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -183,6 +198,8 @@ function UserStyleBubble({ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean + /** Exact session mention labels associated by the adjacent recall node. */ + referenceLabels?: readonly string[] t: ChatViewSlotProps['t'] }): ReactNode { const { text, images, rest } = contentParts(content) @@ -193,9 +210,14 @@ function UserStyleBubble({
{renderMessageImages({ images, align: 'end' })} {showBubble &&
- {projectUserText(text)} + {projectUserText(text, referenceLabels)} {rest.map((block, i) => )}
} + {referenceLabels.length > 0 && ( +
+ {t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) })} +
+ )}
{actions?.(text)} @@ -240,6 +262,7 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({ ( void /** * Open a tool-arg filesystem path with the host OS default application - * (relative paths resolve against the session cwd). + * (relative paths resolve against the session cwd). Always returns a + * promise: fulfills when the Host opens the path, rejects when it cannot + * hand the path off (the chat view shows that reason and a retry). */ - openFile: (path: string) => void + openFile: (path: string) => Promise loadOlder: () => void /** Resolve a session-authorized historical image for inline display. */ loadImage: (attachment: ImageAttachmentRef) => Promise diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts index 880bbafa44..df8f87ee53 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -163,6 +163,7 @@ function finalNode( firstTokenTime: state.firstTokenTime ?? null, completedTime: event.time, }, + ...event.data.interrupted === true ? { interrupted: true } : {}, } } const location = context.start?.location ?? context.matches.at(-1)?.location diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts index 085127f9c5..3f9f2b5ef1 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts @@ -3,19 +3,29 @@ import type { ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { - contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent, + contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent, sessionRecallLabels, } from '@deepseek-ai/dsh-client-runtime/client' import type { InboxState } from './inbox.ts' import { chatNode } from './common.ts' -type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode +interface ReferencedUserMessageNode extends UserMessageNode { + /** Labels cited by the immediately preceding session-reference context. */ + readonly referenceLabels?: readonly string[] +} + +interface ReferencedSteeringMessageNode extends SteeringMessageNode { + /** Labels cited by the immediately preceding session-reference context. */ + readonly referenceLabels?: readonly string[] +} + +type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ChatNodeDataMap { /** Ordinary turn-opening user message. */ - user: UserMessageNode + user: ReferencedUserMessageNode /** User message admitted into an active turn. */ - steering: SteeringMessageNode + steering: ReferencedSteeringMessageNode /** Non-user context injected into model history. */ context: ContextMessageNode } @@ -51,6 +61,11 @@ export const messageDefinition: ConversationNodeDefinition = { } } const claimed = reader.previous('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true + const previous = reader.previous('input-message') + const labels = previous?.state.kind === 'context' && previous.state.seq + 1 === event.seq + ? sessionRecallLabels(previous.state.source) + : [] + const referenceLabels = labels.length === 0 ? {} : { referenceLabels: labels } return claimed ? { kind: 'steering', @@ -59,6 +74,7 @@ export const messageDefinition: ConversationNodeDefinition = { time: event.time, content: event.data.content, source: event.data.source, + ...referenceLabels, } : { kind: 'user', @@ -66,6 +82,7 @@ export const messageDefinition: ConversationNodeDefinition = { time: event.time, content: event.data.content, source: event.data.source, + ...referenceLabels, } }, update: context => context.state, diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 91a2494a4d..910acc9511 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -232,7 +232,7 @@ export interface InputState { export interface SubmitAttempt { readonly seq: number readonly signal: AbortSignal - /** Draft at enter time; rollback restores it only while the live draft still equals it. */ + /** Draft at enter time; settlement clears it only after acceptance. */ readonly draftSnapshot: string /** Default-message delivery intent retained while slash adjudication is pending. */ readonly mode: InputSubmitMode @@ -271,10 +271,7 @@ export type InputEvent = | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome } | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } - /** - * An ordinary (default-sink) send was accepted: clear the draft as a COMMIT — - * undo must not resurrect sent content (mirrors submit-settled's success arm). - */ + /** Commit an image-only send whose empty draft did not need an attempt. */ | { readonly type: 'send-committed' } | { readonly type: 'release' } @@ -286,5 +283,5 @@ export type InputEvent = export type InputEffect = | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } - | { readonly type: 'default-sink'; readonly draft: string; readonly mode: InputSubmitMode } + | { readonly type: 'default-sink'; readonly attempt: SubmitAttempt; readonly draft: string; readonly mode: InputSubmitMode } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 2783aea854..e6a75f2ef8 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -10,7 +10,7 @@ import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, - ReferenceInsert, InputTriggerController, SubmitImageAttachment, TokenSpan, + ReferenceInsert, InputTriggerController, SubmitImageAttachment, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, @@ -45,7 +45,12 @@ export interface SessionInputDeps { */ steerQueue?: (() => void) | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ - defaultSink(text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode): void + defaultSink( + text: string, + imageIds: readonly DraftAttachmentId[], + mode: InputSubmitMode, + signal: AbortSignal, + ): Promise /** Command-plane image plumbing (the hub owns the conversation face and the copy). */ commandImages: { /** Resolve ordered draft ids to wire payloads without sending them; rejects when an id no longer resolves. */ @@ -95,6 +100,8 @@ export class SessionInputShell implements SessionInput { private noticeSeq = 0 private lastDraft = '' private imageIds: readonly DraftAttachmentId[] = [] + /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ + private imageSendInFlight = false private disposed = false /** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */ private mirrorFn: ((text: string) => void) | undefined @@ -150,16 +157,6 @@ export class SessionInputShell implements SessionInput { this.publish() } - /** - * Restore a failed attempt before any images added after its admission. - * @param ids - failed attempt image ids. - */ - restoreImages(ids: readonly DraftAttachmentId[]): void { - const current = new Set(this.imageIds) - this.imageIds = [...ids.filter(id => !current.has(id)), ...this.imageIds] - this.publish() - } - /** * Clear the draft as a successful-send commit: no undo unit is recorded and * the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content @@ -211,7 +208,19 @@ export class SessionInputShell implements SessionInput { */ submit(mode: InputSubmitMode = 'queue'): void { if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) { - if (this.snapshot.phase === 'plain') this.deps.defaultSink('', [...this.imageIds], mode) + if (this.snapshot.phase === 'plain' && !this.imageSendInFlight) { + const imageIds = [...this.imageIds] + this.imageSendInFlight = true + void this.deps.defaultSink('', imageIds, mode, new AbortController().signal).then((outcome) => { + this.imageSendInFlight = false + if (this.disposed) return + if (outcome.kind === 'success') this.commitSend(imageIds) + else if (outcome.text !== undefined) this.notify('error', outcome.text) + }, (error: unknown) => { + this.imageSendInFlight = false + if (!this.disposed) this.notify('error', error instanceof Error ? error.message : String(error)) + }) + } return } // Claimed pre-gate: a claim that does not declare image acceptance never @@ -350,13 +359,21 @@ export class SessionInputShell implements SessionInput { * a scan-derived decoration, never state. * @param text - the plain reference text to splice in (e.g. `/name `). * @param span - pick-time span snapshot (draftRev CAS). + * @param keepCompleting - re-track at the caret after the splice so an open + * token (a directory pick's trailing slash) reopens the menu. * @returns whether the text was applied. */ - insertText(text: string, span: TokenSpan): boolean { + insertText(text: string, span: TokenSpan, keepCompleting = false): boolean { const snapshot = this.core.state if (span.draftRev !== snapshot.draftRev) return false const draft = snapshot.draft this.setDraft(draft.slice(0, span.start) + text + draft.slice(span.end)) + if (keepCompleting) { + // Machine-driven draft replacement never passes through onChange, so + // re-track at the caret inside the still-open token (see space()). + const next = this.snapshot + this.deps.inputTriggers?.()?.track(next.draft, span.start + text.length, { tier: guardOf(next.phase) }, next.draftRev) + } return true } @@ -421,7 +438,7 @@ export class SessionInputShell implements SessionInput { return } case 'default-sink': { - this.sinkSerialized(fx.draft, fx.mode) + this.sinkSerialized(fx.attempt, fx.draft, fx.mode) return } default: @@ -436,11 +453,11 @@ export class SessionInputShell implements SessionInput { * send — notice + draft and chips retained, never a silent downgrade to * the clipboard text. Chip-free drafts skip the async detour. */ - private sinkSerialized(draft: string, mode: InputSubmitMode): void { + private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: InputSubmitMode): void { const imageIds = [...this.imageIds] const occurrences = this.core.state.occurrences if (occurrences.length === 0) { - this.deps.defaultSink(draft.trim(), imageIds, mode) + this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds) return } const inputTriggers = this.deps.inputTriggers?.() @@ -460,13 +477,45 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + 1 } out += draft.slice(cursor) - this.deps.defaultSink(out.trim(), imageIds, mode) + this.settleSubmit(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal), imageIds) }, (error: unknown) => { controller.abort() - if (this.disposed) return + if (this.dead(attempt)) return const message = error instanceof Error ? error.message : String(error) - this.notify('error', message) + this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message })) + }, + ) + } + + /** Settle one admission attempt; successful sends consume only their captured images. */ + private settleSubmit( + attempt: SubmitAttempt, + pending: Promise, + imageIds: readonly DraftAttachmentId[] = [], + ): void { + pending.then( + (outcome) => { + if (this.dead(attempt)) return + if (outcome.kind === 'success' && imageIds.length > 0) { + const submitted = new Set(imageIds) + this.imageIds = this.imageIds.filter(id => !submitted.has(id)) + } + this.run(this.core.dispatch({ + type: 'submit-settled', + attempt, + ok: outcome.kind === 'success', + outcome, + })) + }, + (error: unknown) => { + if (this.dead(attempt)) return + this.run(this.core.dispatch({ + type: 'submit-settled', + attempt, + ok: false, + message: error instanceof Error ? error.message : String(error), + })) }, ) } @@ -519,6 +568,7 @@ export class SessionInputShell implements SessionInput { } this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome, + ...(outcome.kind === 'error' && outcome.text === undefined ? { message: 'command failed' } : {}), })) }, (error: unknown) => { diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 789f18766c..7e3b95a314 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -9,7 +9,7 @@ * real host entity, so the sink is one unconditional prompt path. */ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { InputTriggerController, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { InputTriggerController, SubmitImageAttachment, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import { queueReadFaceOf } from '../queue/store.ts' import type { ComposerKeyboard, DraftAttachmentId, SessionInputResolver, SessionInput } from './contract.ts' @@ -29,7 +29,8 @@ interface ConversationAttachmentFace { text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode, - ): Promise + signal?: AbortSignal, + ): Promise serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise releaseDraftImage(id: DraftAttachmentId): void } @@ -76,7 +77,7 @@ export class InputHub implements SessionInputResolver { inputTriggers: () => this.controller(actx), popup: () => this.popup(actx), queue: queueReadFaceOf(session), - defaultSink: (text, imageIds, mode) => { this.sink(session, text, imageIds, mode) }, + defaultSink: (text, imageIds, mode, signal) => this.sink(session, text, imageIds, mode, signal), steerQueue: () => { void this.steerQueue(session, shell) }, commandImages: { serialize: ids => this.conversation().serializeDraftImages(ids), @@ -105,7 +106,7 @@ export class InputHub implements SessionInputResolver { actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined), actx.on('slash/input-insert-text', req => - shell.insertText(req.text, req.span) ? true : undefined), + shell.insertText(req.text, req.span, req.continue === true) ? true : undefined), ] return () => { for (const off of offs) off() @@ -166,20 +167,10 @@ export class InputHub implements SessionInputResolver { text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode, - ): void { - if (text === '' && imageIds.length === 0) return - const shell = this.shells.get(session.sessionId) - // Commit, not an editable clear: undo must not resurrect sent content. - shell?.commitSend(imageIds) - void this.conversation().sendSession(session, text, imageIds, mode).catch(() => { - if (this.shells.get(session.sessionId) === shell) { - shell?.restoreImages(imageIds) - if (shell?.snapshot.draft === '') shell.setDraft(text) - return - } - const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined - for (const id of imageIds) conversation?.releaseDraftImage(id) - }) + signal: AbortSignal, + ): Promise { + if (text === '' && imageIds.length === 0) return Promise.resolve({ kind: 'success' }) + return this.conversation().sendSession(session, text, imageIds, mode, signal) } /** diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index f42b827d52..0f124e57ff 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -481,7 +481,9 @@ export class InputMachine { this.phase = 'adjudicating' return [{ type: 'adjudicate', attempt, draft: this.draft }] } - return [{ type: 'default-sink', draft: this.draft, mode }] + const attempt = this.beginAttempt(mode) + this.phase = 'submitting' + return [{ type: 'default-sink', attempt, draft: this.draft, mode }] } private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { @@ -499,11 +501,18 @@ export class InputMachine { } // 'handled' (source dealt internally), {insert} (no enter-time span // semantics), or a miss: all land plain; only the miss flows to the sink. + if (outcome === undefined) { + this.phase = 'submitting' + return [{ + type: 'default-sink', + attempt, + draft: attempt.draftSnapshot, + mode: attempt.mode, + }] + } this.inflight = undefined this.phase = 'plain' - return outcome === undefined - ? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: attempt.mode }] - : [] + return [] } private onAdjudicationFailed(attempt: SubmitAttempt, message: string): InputEffect[] { @@ -522,7 +531,13 @@ export class InputMachine { this.phase = 'plain' this.claim = undefined this.occurrences = [] - this.adopt('') + // Text appended after the sent snapshot during the Host round-trip + // survives the commit; edits interleaved with committed content cannot + // be separated from it, so only a pure suffix is retained. + const snapshot = flight.attempt.draftSnapshot + this.adopt(this.draft !== snapshot && this.draft.startsWith(snapshot) + ? this.draft.slice(snapshot.length) + : '') // Committed content is gone for good: undo must not resurrect a sent draft. this.log = [] this.redoStack = [] @@ -532,24 +547,24 @@ export class InputMachine { ? [{ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text }] : [] } - const text = ev.message ?? ev.outcome?.text ?? 'command failed' - // Drift guard: keep the enter-time draft (same claim) only while the - // live draft still equals it; user input typed during flight wins. + const text = ev.message ?? ev.outcome?.text + // Keep the same command claim only while the live draft still equals the + // enter-time draft; user input typed during flight wins. // Claimed re-entry additionally requires the watch to hold — an // enter-path snapshot may carry leading whitespace the token never had. if (this.draft === flight.attempt.draftSnapshot && this.claim !== undefined && this.draft.startsWith(this.claim.token)) { this.phase = 'claimed' - return [{ type: 'notice', level: 'error', text }] + return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } this.phase = 'plain' this.claim = undefined - return [{ type: 'notice', level: 'error', text }] + return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } - /** Ordinary send accepted: clear as a commit (no undo unit; sent content - * must not be resurrectable — same discipline as submit-settled success). */ + /** Cut undo state after an accepted image-only send. */ private onSendCommitted(): InputEffect[] { + if (this.phase !== 'plain') return [] this.claim = undefined this.occurrences = [] this.adopt('') diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 6ad7fc0e7b..f4e7a7c59a 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -92,9 +92,15 @@ export const zh = { 'chat.loadError': '历史加载失败:{message}({code})', 'chat.loadOlder': '加载更早', 'chat.toBottom': '回到底部', + 'fileOpen.title': '无法打开文件', + 'fileOpen.unknown': '无法打开此文件', + 'fileOpen.folderTitle': '无法打开文件夹', + 'fileOpen.folderUnknown': '无法打开此文件夹', 'message.extraBlock': '附加内容块', 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', + 'message.referenceSummary': '引用会话 · {labels}', + 'message.referenceSeparator': '、', 'message.context.instructions.loaded': '已载入', 'message.context.instructions.added': '已新增', 'message.context.instructions.updated': '已更新', @@ -263,9 +269,15 @@ export const en = { 'chat.loadError': 'Failed to load history: {message} ({code})', 'chat.loadOlder': 'Load earlier', 'chat.toBottom': 'Back to bottom', + 'fileOpen.title': 'Couldn’t open file', + 'fileOpen.unknown': 'Couldn’t open this file', + 'fileOpen.folderTitle': 'Couldn’t open folder', + 'fileOpen.folderUnknown': 'Couldn’t open this folder', 'message.extraBlock': 'Extra content block', 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', + 'message.referenceSummary': 'Referenced session · {labels}', + 'message.referenceSeparator': ', ', 'message.context.instructions.loaded': 'loaded', 'message.context.instructions.added': 'added', 'message.context.instructions.updated': 'updated', diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 9a25e13dc0..a37816472c 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -13,7 +13,7 @@ import type { Context } from '@deepseek-ai/cordis' // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' -import type { SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { SubmitImageAttachment, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ComposerAttachment } from './contract/slots.ts' import type { QueueAction, QueueItemId } from './contract/queue.ts' @@ -139,22 +139,26 @@ export class ConversationController extends Service implements IConversation { * @param text - serialized prompt text. * @param imageIds - ordered draft-local attachment ids. * @param mode - queue or steer delivery selected by composer policy. + * @param signal - optional cancellation for the complete Host admission. + * @returns the Host admission outcome; local attachment preparation failures reject. */ async sendSession( session: SessionFace, text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode, - ): Promise { + signal?: AbortSignal, + ): Promise { const attachments = this.draftImages(imageIds) if (attachments.length !== imageIds.length) { throw new Error('conversation.sendSession: one or more draft images are no longer available') } const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] - const result = await session.prompt(content, mode) - if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`) + const result = await session.prompt(content, mode, signal) + if (!result.ok) return { kind: 'error' } this.releaseDraftImages(attachments) + return { kind: 'success' } } /** diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 9cec01789a..1f61399fda 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -2,7 +2,7 @@ // apply inject factories exercised end to end against the terminal thin // API: the strict session API (views triple, draft mirror), the // provide-channel input face (machine-sink submit choreography incl. -// optimistic clear + failure restore), the resident API (selectWorkspace +// transactional clear + failure retention), the resident API (selectWorkspace // draft carrying), the composer-bar stop face, openDetails = select action + // layout orchestration, and the closeDetails details API. Complements // chat-apply.spec.tsx (registration) and selection-survival.spec.tsx (store @@ -150,7 +150,7 @@ describe('conversation slot inject API', () => { await b.runtime.dispose() }) - it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => { + it('the provide-channel input face submits through the machine sink: trim, transactional clear, failure retains the draft', async () => { const b = await bench() const { injected } = b.conversationApi(ROOT) const { state, actions } = b.inputApi(ROOT) @@ -159,20 +159,23 @@ describe('conversation slot inject API', () => { actions.submit() expect(b.sessionFake.prompt).not.toHaveBeenCalled() expect(state.getSnapshot().draft).toBe(' ') - // Success: cleared and stays cleared. + // Success: the draft clears only after the sink settles. actions.setDraft('hello') actions.submit() - expect(state.getSnapshot().draft).toBe('') - await Promise.resolve() - expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue') - // Failure: restored (draft still empty when the rejection lands). + await vi.waitFor(() => { + expect(state.getSnapshot().draft).toBe('') + }) + expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal)) + // Failure: the draft is retained through the round-trip. b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) actions.setDraft('retry me') actions.submit() await vi.waitFor(() => { - expect(state.getSnapshot().draft).toBe('retry me') + expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2) }) - // Failure landing after new typing: no clobber (restore fills empty only). + await new Promise(r => setTimeout(r, 0)) + expect(state.getSnapshot().draft).toBe('retry me') + // Failure landing after new typing: no clobber (the interleaved edit wins). b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) actions.submit() actions.setDraft('typed during flight') @@ -233,13 +236,21 @@ describe('conversation slot inject API', () => { it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => { const b = await bench() const { injected } = b.chatViewApi(ROOT) - injected.openFile('src/a.ts') + await injected.openFile('src/a.ts') await vi.waitFor(() => { expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] }) }) await b.runtime.dispose() }) + it('openFile rejects when the Host cannot open the path', async () => { + const b = await bench() + b.runtime.workspaces.stub('openPath', () => Promise.reject(new Error('xdg-open is not available'))) + const { injected } = b.chatViewApi(ROOT) + await expect(injected.openFile('src/a.ts')).rejects.toThrow('xdg-open is not available') + await b.runtime.dispose() + }) + it('routes workspace switching through the runtime owner, carrying the draft', async () => { const b = await bench() const resident = b.residentApi(ROOT) diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx index efb8aa5f39..c0a75162c3 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx @@ -48,10 +48,11 @@ const RETRY_ID = 'retry-fixture' as Extract { + it('renders an adjacent session mention as a chip even without trailing whitespace', () => { + const view = render( + , + ) + expect(view.container.querySelector('[data-ref-chip="session"]')?.textContent).toBe('@你好') + expect(view.getByText('这个在讲啥')).toBeTruthy() + expect(view.getByText('引用会话 · 你好')).toBeTruthy() + }) + it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { diff --git a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx index d26fa4f5bb..b81dff8e6e 100644 --- a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx @@ -4,7 +4,7 @@ // ObservableSnapshot fake, no wire or Tool presentation plugin. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { useEffect } from 'react' import type { AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, @@ -152,7 +152,7 @@ function emptyWorkspaces() { function makeHarness(init?: Partial) { const { set, source } = makeSource(init) const openDetails = vi.fn<(t: SelectionTarget) => void>() - const openFile = vi.fn<(path: string) => void>() + const openFile = vi.fn<(path: string) => Promise>().mockResolvedValue(undefined) const loadOlder = vi.fn() const inspectCall = vi.fn<(callId: string) => void>() // In-memory scroll memory matching the apply.ts per-session map contract. @@ -348,7 +348,7 @@ describe('Chat node rendering', () => { resolve: (value) => { if (value !== 'report.html') return undefined return { - open: () => { h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) }, + open: () => { void h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) }, label: '打开 site/report.html', title: 'site/report.html', } @@ -967,10 +967,113 @@ describe('ChatView', () => { }) const owner = calls[0]?.owner as RoutedChatNodeOwner expect((owner.node.data as { readonly root: ToolCallBlock }).root).toBe(block) - expect(owner.openFile).toBe(h.openFile) + expect(owner.openFile).not.toBe(h.openFile) + owner.openFile('src/a.ts') + expect(h.openFile).toHaveBeenCalledWith('src/a.ts') expect(owner.inspectCall).toBe(h.inspectCall) }) + it('shows a Host open refusal with the reason and retries the same path', async () => { + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce(new Error('xdg-open is not available')) + .mockResolvedValueOnce(undefined) + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') }) + await waitFor(() => { + expect(screen.getByRole('dialog', { name: '无法打开文件' })).toBeTruthy() + }) + expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('xdg-open is not available') + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) }) + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeNull() + }) + expect(openFile).toHaveBeenCalledTimes(2) + expect(openFile).toHaveBeenNthCalledWith(1, 'src/a.ts') + expect(openFile).toHaveBeenNthCalledWith(2, 'src/a.ts') + }) + + it('keeps a non-Error Host refusal visible and dismisses it on cancel', async () => { + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce('permission denied') + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('notes.md') }) + await waitFor(() => { + expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('permission denied') + }) + fireEvent.click(screen.getByRole('button', { name: '取消' })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(openFile).toHaveBeenCalledTimes(1) + }) + + it('substitutes the unknown-open copy when the Host refusal has no text', async () => { + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce(new Error('')) + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('empty.ts') }) + await waitFor(() => { + expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('无法打开此文件') + }) + }) + + it('names a workspace-folder Host refusal as a folder', async () => { + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce(new Error('')) + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('.') }) + await waitFor(() => { + expect(screen.getByRole('dialog', { name: '无法打开文件夹' }).textContent).toContain('无法打开此文件夹') + }) + }) + + it('ignores a Host refusal that settles after the dialog is dismissed', async () => { + let rejectRetry!: (error: unknown) => void + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce(new Error('first refusal')) + .mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectRetry = reject + })) + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') }) + await waitFor(() => { + expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('first refusal') + }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) }) + fireEvent.click(screen.getByRole('button', { name: '取消' })) + expect(screen.queryByRole('dialog')).toBeNull() + await act(async () => { rejectRetry(new Error('late refusal')) }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('ignores a Host open that succeeds after the dialog is dismissed', async () => { + let resolveRetry!: () => void + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce(new Error('first refusal')) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveRetry = () => { resolve() } + })) + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') }) + await waitFor(() => { + expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('first refusal') + }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) }) + fireEvent.click(screen.getByRole('button', { name: '取消' })) + await act(async () => { resolveRetry() }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + it('prepend preserves a semantic row; a trailing user node force-scrolls', () => { const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true }) const view = render() diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts index c6a33c2f76..b1e8ac6bc7 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts @@ -160,6 +160,20 @@ describe('built-in conversation node Definitions', () => { expect(interrupted?.data).toMatchObject({ status: 'interrupted' }) expect((interrupted?.data as AssistantChatData).finalNode?.interrupted).toBe(true) + const markedValue = assembler([ + at(20, 'turn/start', { turn: 3 }), + at(21, 'step/start', { turn: 3, step: 1 }), + at(22, 'assistant/message', { + turn: 3, + step: 1, + message: assistantMessage('assistant-3', 'cut short'), + interrupted: true, + }, { surfaceOp: 'append' }), + ]) + const marked = node(snapshot(markedValue), 'assistant-step') + expect(marked?.data).toMatchObject({ status: 'interrupted', blocks: [{ kind: 'text', text: 'cut short' }] }) + expect((marked?.data as AssistantChatData).finalNode?.interrupted).toBe(true) + const hiddenValue = assembler([ at(20, 'turn/start', { turn: 3 }), at(21, 'step/start', { turn: 3, step: 1 }), @@ -514,6 +528,31 @@ describe('built-in conversation node Definitions', () => { }) }) + it('associates session-reference labels inside the adjacent direct-message node', () => { + const referenceSource = { + kind: 'session-reference', + form: 'recall', + version: 1, + references: [ + { sessionId: 'source-a', label: 'Research' }, + { sessionId: 'source-b', label: 'Review' }, + ], + } + const value = assembler([ + at(1, 'user/message', { + ...textMessage('reference-context', 'snapshot'), + source: referenceSource, + }, { surfaceOp: 'append' }), + at(2, 'user/message', textMessage('citing-user', '@Research and @Review'), { surfaceOp: 'append' }), + at(4, 'user/message', textMessage('later-user', 'unrelated'), { surfaceOp: 'append' }), + ]) + + const current = snapshot(value) + const users = [...current.nodes.values()].filter(candidate => candidate.kind === 'user') + expect(users[0]?.data).toMatchObject({ referenceLabels: ['Research', 'Review'] }) + expect(users[1]?.data).not.toHaveProperty('referenceLabels') + }) + it('keeps replacement copies out of Chat business nodes', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index c1b57872bd..ecf06f6c38 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -13,6 +13,7 @@ import { import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { SessionInputShell } from '../src/client/input/facade.ts' import type { ComposerAttachment, ComposerAttachmentsOwnerProps, @@ -104,7 +105,12 @@ function row(id: string): ConversationSnapshot['queue'][number] { /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ function bench(over?: BenchOptions) { - const sink = vi.fn() + const sink = vi.fn<( + text: string, + imageIds: readonly DraftAttachmentId[], + mode: 'queue' | 'steer', + signal: AbortSignal, + ) => Promise>(() => Promise.resolve({ kind: 'success' })) const lex = over?.lexicon const session = createSnapshotStore(snapshotOf({ running: over?.running ?? false, @@ -209,9 +215,11 @@ function bench(over?: BenchOptions) { } function attachmentOwner(slotCalls: readonly { key: string; owner: unknown }[]): ComposerAttachmentsOwnerProps { - const call = slotCalls.find(candidate => candidate.key === 'conversation.input.attachments') - if (call === undefined) throw new Error('attachment slot was not rendered') - return call.owner as ComposerAttachmentsOwnerProps + for (let i = slotCalls.length - 1; i >= 0; i -= 1) { + const call = slotCalls[i] + if (call?.key === 'conversation.input.attachments') return call.owner as ComposerAttachmentsOwnerProps + } + throw new Error('attachment slot was not rendered') } describe('image draft rail', () => { @@ -333,18 +341,28 @@ describe('image draft rail', () => { expect(attachmentOwner(result.slotCalls).canAcceptDrop).toBe(false) }) - it('sends an image-only draft and exposes removal through the attachment slot', () => { + it('sends an image-only draft and exposes removal through the attachment slot', async () => { const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) - const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' } - const result = bench({ attachments: [attachment] }) + const extra = new File([Uint8Array.of(2)], 'extra.png', { type: 'image/png' }) + const attachments = [ + { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }, + { kind: 'image' as const, id: 'draft-2' as DraftAttachmentId, file: extra, previewUrl: 'blob:draft-2' }, + ] + const result = bench({ attachments }) const { view, textarea, sink, removeImage } = result expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false) - fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue') const owner = attachmentOwner(result.slotCalls) - expect(owner.attachments).toEqual([attachment]) - owner.onRemoveImage(attachment.id) - expect(removeImage).toHaveBeenCalledWith('draft-1') + act(() => { owner.onRemoveImage('draft-2' as DraftAttachmentId) }) + expect(removeImage).toHaveBeenCalledWith('draft-2') + let settle!: (outcome: SubmitOutcome) => void + sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) + fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal)) + expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]]) + await act(async () => { settle({ kind: 'success' }) }) + await vi.waitFor(() => { + expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) + }) }) it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => { @@ -439,8 +457,11 @@ describe('Enter semantics', () => { it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { const { textarea, sink } = bench({ draft: 'hello' }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('hello', [], 'queue') + expect(sink).toHaveBeenCalledWith('hello', [], 'queue', expect.any(AbortSignal)) + // The submitting-phase lock, not draft emptiness, suppresses the repeat: + // the draft is still uncleared while the sink round-trip is in flight. fireEvent.keyDown(textarea, { key: 'Enter', repeat: true }) + fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledTimes(1) const empty = bench({ draft: ' ' }) fireEvent.keyDown(empty.textarea, { key: 'Enter' }) @@ -464,15 +485,15 @@ describe('Enter semantics', () => { it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => { const idle = bench({ draft: 'hello' }) fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true }) - expect(idle.sink).toHaveBeenCalledWith('hello', [], 'queue') + expect(idle.sink).toHaveBeenCalledWith('hello', [], 'queue', expect.any(AbortSignal)) const busyCtrl = bench({ running: true, draft: 'steer with ctrl' }) fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true }) - expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', [], 'steer') + expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', [], 'steer', expect.any(AbortSignal)) const busyMeta = bench({ running: true, draft: 'steer with cmd' }) fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true }) - expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', [], 'steer') + expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', [], 'steer', expect.any(AbortSignal)) }) it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => { @@ -537,7 +558,7 @@ describe('Enter semantics', () => { const steerQueue = vi.fn() const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue }) fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }) - expect(sink).toHaveBeenCalledWith('插话', [], 'steer') + expect(sink).toHaveBeenCalledWith('插话', [], 'steer', expect.any(AbortSignal)) expect(steerQueue).not.toHaveBeenCalled() }) @@ -585,7 +606,7 @@ describe('running and lock semantics', () => { expect(textarea.disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队消息2' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue') + expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal)) expect(button.getAttribute('aria-label')).toBe('停止生成') fireEvent.click(button) expect(stop).toHaveBeenCalledTimes(1) @@ -594,17 +615,17 @@ describe('running and lock semantics', () => { it('running plain Enter follows the busy-state Steer preference', () => { const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer') + expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer', expect.any(AbortSignal)) }) it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => { const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' }) fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true }) - expect(meta.sink).toHaveBeenCalledWith('排到下一轮', [], 'queue') + expect(meta.sink).toHaveBeenCalledWith('排到下一轮', [], 'queue', expect.any(AbortSignal)) const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' }) fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true }) - expect(ctrl.sink).toHaveBeenCalledWith('also queue', [], 'queue') + expect(ctrl.sink).toHaveBeenCalledWith('also queue', [], 'queue', expect.any(AbortSignal)) }) it('running continuable subagent keeps Send beside an independent Stop', () => { @@ -624,7 +645,7 @@ describe('running and lock semantics', () => { expect(interruptButton).not.toBeNull() expect(textarea.disabled).toBe(false) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue') + expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal)) fireEvent.click(interruptButton!) expect(stop).toHaveBeenCalledTimes(1) }) @@ -681,11 +702,11 @@ describe('running and lock semantics', () => { } const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent }) fireEvent.keyDown(plain.textarea, { key: 'Enter' }) - expect(plain.sink).toHaveBeenCalledWith('plain', [], 'queue') + expect(plain.sink).toHaveBeenCalledWith('plain', [], 'queue', expect.any(AbortSignal)) const accelerated = bench({ running: true, draft: 'accelerated', subagent }) fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true }) - expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'queue') + expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'queue', expect.any(AbortSignal)) }) it('disabled (session removed) locks the textarea and chrome', () => { @@ -698,7 +719,7 @@ describe('running and lock semantics', () => { it('idle primary sends and disables on empty draft', () => { const { button, sink } = bench({ draft: 'go' }) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('go', [], 'queue') + expect(sink).toHaveBeenCalledWith('go', [], 'queue', expect.any(AbortSignal)) const empty = bench() expect(empty.button.disabled).toBe(true) }) diff --git a/packages/client/ui-conversation/tests/input-machine.client.spec.ts b/packages/client/ui-conversation/tests/input-machine.client.spec.ts index 89627646b3..192d4c7efa 100644 --- a/packages/client/ui-conversation/tests/input-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.client.spec.ts @@ -72,16 +72,17 @@ describe('input-machine: plain × enter', () => { it('non-command text falls to the default sink', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'hello world' }) - expect(m.dispatch({ type: 'enter', mode: 'queue' })) - .toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'queue' }]) - expect(m.state.phase).toBe('plain') + const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + expect(effect).toMatchObject({ draft: 'hello world', mode: 'queue' }) + expect(effect.attempt.draftSnapshot).toBe('hello world') + expect(m.state.phase).toBe('submitting') }) it('retains an explicit steer mode on the default sink effect', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'steer now' }) - expect(m.dispatch({ type: 'enter', mode: 'steer' })) - .toEqual([{ type: 'default-sink', draft: 'steer now', mode: 'steer' }]) + expect(effectAt(m.dispatch({ type: 'enter', mode: 'steer' }), 0, 'default-sink')) + .toMatchObject({ draft: 'steer now', mode: 'steer' }) }) it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => { @@ -104,8 +105,8 @@ describe('input-machine: plain × enter', () => { it('a non-whitespace prefix before "/" is not leading — default sink', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' }) - expect(m.dispatch({ type: 'enter', mode: 'queue' })) - .toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }]) + expect(effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')) + .toMatchObject({ draft: '第一行\n/goal x', mode: 'queue' }) }) }) @@ -134,9 +135,12 @@ describe('input-machine: adjudication outcomes', () => { it('undefined outcome falls back to the default sink', () => { const m = new InputMachine() const attempt = enterAdjudicating(m, '/unknown thing', 'steer') - expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined })) - .toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }]) - expect(m.state.phase).toBe('plain') + expect(effectAt( + m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }), + 0, + 'default-sink', + )).toMatchObject({ attempt, draft: '/unknown thing', mode: 'steer' }) + expect(m.state.phase).toBe('submitting') }) it("'handled' lands plain with zero effects (popup shell path)", () => { @@ -484,6 +488,22 @@ describe('input-machine: undo / redo', () => { expect(m.dispatch({ type: 'undo' })).toEqual([]) expect(m.state.draft).toBe('') }) + + it('keeps a suffix typed during the round-trip and drops interleaved edits with the commit', () => { + const m = new InputMachine() + m.dispatch({ type: 'draft-changed', draft: 'hello' }) + const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + m.dispatch({ type: 'draft-changed', draft: 'hello world' }) + m.dispatch({ type: 'submit-settled', attempt: effect.attempt, ok: true }) + expect(m.state.draft).toBe(' world') + + const n = new InputMachine() + n.dispatch({ type: 'draft-changed', draft: 'hello' }) + const second = effectAt(n.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + n.dispatch({ type: 'draft-changed', draft: 'hXello' }) + n.dispatch({ type: 'submit-settled', attempt: second.attempt, ok: true }) + expect(n.state.draft).toBe('') + }) }) describe('input-machine: paste plane', () => { diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 21caccca1a..71f96b8d4e 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -80,7 +80,7 @@ function bench(over?: { submit?: (args: string) => Promise serialize?: (ids: readonly DraftAttachmentId[]) => Promise }) { - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) const serialize = vi.fn(over?.serialize ?? (() => Promise.resolve([]))) const release = vi.fn() const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, commandImages: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) @@ -104,13 +104,15 @@ function bench(over?: { } describe('matrix row: plain', () => { - it('enter falls to the default sink; no claim on the currency; edits free', () => { + it('enter falls to the default sink; no claim on the currency; edits free', async () => { const { textarea, shell, sink } = bench() fireEvent.change(textarea, { target: { value: '普通消息' } }) expect(shell.snapshot.claim).toBeUndefined() fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue') - expect(shell.snapshot.phase).toBe('plain') + expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue', expect.any(AbortSignal)) + expect(shell.snapshot.phase).toBe('submitting') + await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') }) + expect(shell.snapshot.claim).toBeUndefined() }) }) @@ -299,7 +301,7 @@ describe('matrix row: locked (session disabled)', () => { expect((textarea).disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队', [], 'queue') + expect(sink).toHaveBeenCalledWith('排队', [], 'queue', expect.any(AbortSignal)) }) }) diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts new file mode 100644 index 0000000000..70adc2ab19 --- /dev/null +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -0,0 +1,204 @@ +/** + * Reference-submit transaction coverage: chips serialize through their + * owner, stay resident through Host rejection, and clear only after an + * accepted prompt. + */ +import { describe, expect, it, vi } from 'vitest' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { InputTriggerController, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import { SessionInputShell } from '../src/client/input/facade.ts' +import type { DraftAttachmentId } from '../src/client/input/contract.ts' +import { PLACEHOLDER } from '../src/client/input/machine.ts' + +const mention = '@[Research](dsh-session:InNvdXJjZSI)' +const commandImages = { + serialize: () => Promise.resolve([]), + release: () => {}, + unsupportedNotice: (token: string) => `${token.trim()} images-unsupported`, +} + +function chip(shell: SessionInputShell): void { + shell.setDraft('@res') + const accepted = shell.insertReference({ + source: 'reference', + ref: mention, + label: '@Research', + clipboardText: mention, + }, { + start: 0, + end: 4, + draftRev: shell.snapshot.draftRev, + }) + expect(accepted).toBe(true) +} + +describe('reference submission', () => { + it('retains the chip on Host failure and clears it only after a later accepted retry', async () => { + const serializeReference = vi.fn(() => Promise.resolve(mention)) + const sink = vi.fn<( + _text: string, + _imageIds: readonly DraftAttachmentId[], + _mode: 'queue' | 'steer', + _signal: AbortSignal, + ) => Promise>() + .mockResolvedValueOnce({ kind: 'error', text: 'snapshot unavailable' }) + .mockResolvedValueOnce({ kind: 'success' }) + const inputTriggers = { + serializeReference, + track: vi.fn(), + } as unknown as InputTriggerController + const shell = new SessionInputShell({ + actx: {} as ClientContext, + inputTriggers: () => inputTriggers, + defaultSink: sink, + commandImages, + }) + chip(shell) + expect(shell.snapshot).toMatchObject({ + draft: `${PLACEHOLDER} `, + occurrences: [{ source: 'reference', ref: mention, label: '@Research' }], + }) + + shell.submit('queue') + expect(shell.snapshot.phase).toBe('submitting') + await vi.waitFor(() => { + expect(shell.snapshot.phase).toBe('plain') + }) + expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal)) + expect(shell.snapshot).toMatchObject({ + draft: `${PLACEHOLDER} `, + occurrences: [{ source: 'reference', ref: mention, label: '@Research' }], + }) + expect(shell.notices.getSnapshot()).toMatchObject({ + level: 'error', + text: 'snapshot unavailable', + }) + + shell.submit('queue') + await vi.waitFor(() => { + expect(shell.snapshot.draft).toBe('') + }) + expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal)) + expect(shell.snapshot.occurrences).toEqual([]) + expect(serializeReference).toHaveBeenCalledTimes(2) + }) + + it('blocks submission and retains the chip when its owner cannot serialize it', async () => { + const sink = vi.fn() + const inputTriggers = { + serializeReference: () => Promise.reject(new Error('reference codec unavailable')), + track: vi.fn(), + } as unknown as InputTriggerController + const shell = new SessionInputShell({ + actx: {} as ClientContext, + inputTriggers: () => inputTriggers, + defaultSink: sink, + commandImages, + }) + chip(shell) + shell.submit() + await vi.waitFor(() => { + expect(shell.snapshot.phase).toBe('plain') + }) + expect(sink).not.toHaveBeenCalled() + expect(shell.snapshot.draft).toBe(`${PLACEHOLDER} `) + expect(shell.snapshot.occurrences).toHaveLength(1) + expect(shell.notices.getSnapshot()).toMatchObject({ + level: 'error', + text: 'reference codec unavailable', + }) + }) + + it('aborts Host-side preparation when the input shell is disposed', () => { + let signal: AbortSignal | undefined + const shell = new SessionInputShell({ + actx: {} as ClientContext, + defaultSink: (_text, _imageIds, _mode, received) => { + signal = received + return new Promise(() => {}) + }, + commandImages, + }) + shell.setDraft('send this') + shell.submit() + expect(signal?.aborted).toBe(false) + shell.dispose() + expect(signal?.aborted).toBe(true) + expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe('send this') + }) + + it('retains a rejected default message without duplicating its prompt error notice', async () => { + const shell = new SessionInputShell({ + actx: {} as ClientContext, + defaultSink: () => Promise.resolve({ kind: 'error' }), + commandImages, + }) + shell.setDraft('retry this') + shell.submit() + await vi.waitFor(() => { + expect(shell.snapshot.phase).toBe('plain') + }) + expect(shell.snapshot.draft).toBe('retry this') + expect(shell.notices.getSnapshot()).toBeNull() + }) +}) + +describe('submit transaction hardening', () => { + it('sends one image-only prompt per settlement, ignoring Enter during the round-trip', async () => { + let settle!: (outcome: SubmitOutcome) => void + const sink = vi.fn(() => new Promise((resolve) => { settle = resolve })) + const shell = new SessionInputShell({ + actx: {} as ClientContext, + defaultSink: sink, + commandImages, + }) + expect(shell.addImages(['img-1' as DraftAttachmentId])).toBe(true) + shell.submit('queue') + shell.submit('queue') + expect(sink).toHaveBeenCalledTimes(1) + settle({ kind: 'success' }) + await vi.waitFor(() => { + expect(shell.snapshot.imageIds).toEqual([]) + }) + + expect(shell.addImages(['img-2' as DraftAttachmentId])).toBe(true) + shell.submit('queue') + expect(sink).toHaveBeenCalledTimes(2) + }) + + it('retains an image-only rejection without duplicating its prompt error notice', async () => { + const sink = vi.fn(() => Promise.resolve({ kind: 'error' })) + const shell = new SessionInputShell({ + actx: {} as ClientContext, + defaultSink: sink, + commandImages, + }) + const imageId = 'img-1' as DraftAttachmentId + shell.addImages([imageId]) + shell.submit() + await Promise.resolve() + await Promise.resolve() + expect(shell.snapshot.imageIds).toEqual([imageId]) + expect(shell.notices.getSnapshot()).toBeNull() + }) + + it('re-tracks at the caret when a continuing insert-text splice lands (directory descent)', () => { + const track = vi.fn() + const shell = new SessionInputShell({ + actx: {} as ClientContext, + inputTriggers: () => ({ track } as unknown as InputTriggerController), + defaultSink: vi.fn(), + commandImages, + }) + shell.setDraft('@sr') + const applied = shell.insertText('@src/', { start: 0, end: 3, draftRev: shell.snapshot.draftRev }, true) + expect(applied).toBe(true) + expect(shell.snapshot.draft).toBe('@src/') + expect(track).toHaveBeenCalledWith('@src/', 5, { tier: 'plain' }, shell.snapshot.draftRev) + + track.mockClear() + shell.insertText(' plain ', { start: 0, end: 0, draftRev: shell.snapshot.draftRev }) + expect(track).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index ea828b6bce..6231519bc2 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -119,7 +119,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo register?.(inputTriggers) const actx = sessions.scope(sessionId)! const controller = inputTriggers.sessionOf(actx) - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) const serialize = vi.fn((ids: readonly DraftAttachmentId[]) => Promise.resolve(ids.map(() => PNG))) const release = vi.fn() const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink, commandImages: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) @@ -165,6 +165,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo controller.toggleSource('command', { trigger: '/', query: '', + quoted: false, position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline', span: { ...selection, draftRev: snapshot.draftRev }, }) @@ -262,7 +263,7 @@ describe('scenario D: execute-kind /compact', () => { act(() => { b2.shell.setDraft('/compact 现在') }) fireEvent.keyDown(b2.textarea, { key: 'Enter' }) // execute with trailing → matchEnter answers undefined → default sink. - await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', [], 'queue') }) + await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', [], 'queue', expect.any(AbortSignal)) }) expect(b2.executed).toHaveLength(0) }) }) @@ -343,8 +344,8 @@ describe('scenario I: unknown /xyz + enter', () => { const b = await bench() act(() => { b.shell.setDraft('/xyz 干点啥') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', [], 'queue') }) - expect(b.shell.snapshot.phase).toBe('plain') + await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', [], 'queue', expect.any(AbortSignal)) }) + await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') }) expect(b.execute).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index d8b475579f..20a72b1503 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -31,7 +31,7 @@ import type { ViewTab } from '../src/client/contract/views.ts' /** Machine-backed wiring over a sink spy. */ function fakeWiring() { - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink, commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) return { wiring: shell, sink, shell } } @@ -309,7 +309,7 @@ describe('ConversationRoot resident composer', () => { fireEvent.change(box, { target: { value: 'ordinary revised' } }) expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) - expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue') + expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue', expect.any(AbortSignal)) expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true) expect(b.view.queryByText('Root')).toBeNull() }) diff --git a/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx b/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx index 1e6d41c261..6f90658c5f 100644 --- a/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx +++ b/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx @@ -45,7 +45,7 @@ describe('view-ring type negatives (compile-time; body never runs)', () => { // @ts-expect-error openDetails takes a SelectionTarget, not a string props.openDetails('nope') // @ts-expect-error openFile takes a path string, not a SelectionTarget - props.openFile({ turnSeq: 1, callId: 'c' }) + void props.openFile({ turnSeq: 1, callId: 'c' }) return null } void chatProps diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 1521010497..b37e65c9ea 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -286,7 +286,7 @@ describe('ProducedFiles row', () => { ): Pick => { const description = canOpenPath === undefined ? undefined - : { version: 'test', cwd: '/workspace', attachedSessions: 1, canOpenPath } + : { version: 'test', cwd: '/workspace', attachedSessions: 1, home: '/h', canOpenPath } return { isLoopback, useHostDescription: selector => selector(description), diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 9c73093c09..b6948f35ae 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -50,7 +50,8 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -61,7 +62,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-file-reference": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-input-trigger/src/client/MenuView.module.css b/packages/client/ui-input-trigger/src/client/MenuView.module.css index 527d1b7c37..49ff264929 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.module.css +++ b/packages/client/ui-input-trigger/src/client/MenuView.module.css @@ -60,6 +60,20 @@ background: var(--dsw-alias-interactive-bg-hover); } +.sectionTitle { + flex: none; + min-height: 26px; + padding: 6px 10px 2px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + font-weight: 500; + line-height: 18px; +} + +.sectionTitle:not(:first-child) { + margin-top: 4px; +} + .itemIcon { display: inline-flex; flex: none; diff --git a/packages/client/ui-input-trigger/src/client/MenuView.tsx b/packages/client/ui-input-trigger/src/client/MenuView.tsx index 30ba9c8d29..f0edeed1cc 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.tsx +++ b/packages/client/ui-input-trigger/src/client/MenuView.tsx @@ -87,25 +87,29 @@ export function MenuView({ menu, onPick, onDismiss, t }: MenuViewProps) { : group.items.map((item, index) => { const active = highlight !== null && highlight.source === group.source && highlight.index === index return ( - + + {item.section !== undefined && item.section !== group.items[index - 1]?.section + ?
{item.section}
+ : null} + +
) })} diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 8dd49fd541..32c9a3cc68 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -101,6 +101,7 @@ export class InputTriggerController { const prev = this.menu.getSnapshot() const same = !launched && prev.open && prev.hit !== null && prev.hit.trigger === hit.trigger && prev.hit.query === hit.query + && prev.hit.quoted === hit.quoted && prev.hit.span.start === hit.span.start && prev.hit.span.end === hit.span.end this.hit = hit if (same) return @@ -324,7 +325,11 @@ export class InputTriggerController { return actx.bail(actx, 'slash/input-begin-command', { claim: outcome.claim, span }) === true } if ('text' in outcome) { - return actx.bail(actx, 'slash/input-insert-text', { text: outcome.text, span }) === true + return actx.bail(actx, 'slash/input-insert-text', { + text: outcome.text, + span, + ...outcome.continue === true ? { continue: true } : {}, + }) === true } return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true } @@ -367,7 +372,12 @@ export class InputTriggerController { const projection = this.project() for (const source of roster) { void source - .candidates(projection, { query: hit.query, position: hit.position, signal: controller.signal }) + .candidates(projection, { + query: hit.query, + quoted: hit.quoted, + position: hit.position, + signal: controller.signal, + }) .then( (items) => { if (controller.signal.aborted) return diff --git a/packages/client/ui-input-trigger/src/core/contract.ts b/packages/client/ui-input-trigger/src/core/contract.ts index 9ce06e0b67..f936e1c0eb 100644 --- a/packages/client/ui-input-trigger/src/core/contract.ts +++ b/packages/client/ui-input-trigger/src/core/contract.ts @@ -11,6 +11,8 @@ export interface TriggerHit { readonly trigger: TriggerChar /** Text between the trigger char and the caret, live-filtered. */ readonly query: string + /** True only for an open quoted `@file` token. */ + readonly quoted: boolean /** leading = draft trimmed (whitespace incl. newlines) starts with the token. */ readonly position: TriggerPosition /** Token span; draftRev injected by the caller. */ @@ -19,8 +21,9 @@ export interface TriggerHit { /** * Detect a trigger token at the caret under the given guard tier. - * Word-boundary rule: the char before the trigger is start-of-line, - * whitespace, or punctuation; `user@host` and URL '/' do not trigger. + * `@` uses the shared file-reference start/whitespace grammar; `/` accepts + * punctuation boundaries with URL carve-outs. `user@host` and URL `/` do not + * trigger. * Returns null when no trigger is live at the caret. */ export type DetectTrigger = (draft: string, caret: number, guard: TriggerGuard) => TriggerHit | null diff --git a/packages/client/ui-input-trigger/src/core/detect.ts b/packages/client/ui-input-trigger/src/core/detect.ts index 6a1b69110b..0368b46000 100644 --- a/packages/client/ui-input-trigger/src/core/detect.ts +++ b/packages/client/ui-input-trigger/src/core/detect.ts @@ -3,6 +3,7 @@ * the caret for a live trigger char under the guard tier and applies the * word-boundary rules. Zero React / DOM / cordis. */ +import { activeAtToken } from '@deepseek-ai/dsh-file-reference/grammar' import type { TriggerChar } from '../types.ts' import type { DetectTrigger } from './contract.ts' @@ -29,10 +30,10 @@ function boundaryOk(draft: string, index: number, char: TriggerChar): boolean { } /** - * Detect a trigger token at the caret. Scans left from the caret and stops - * at the first whitespace (the token under edit never spans whitespace); - * trigger chars failing the guard tier or the word boundary are treated as - * ordinary token chars and the scan continues (`user@host`, URL slashes). + * Detect a trigger token at the caret. `@` first uses the shared grammar, + * including an open quoted token that may span whitespace. Slash detection + * scans left to the first whitespace; slashes failing the word boundary are + * treated as ordinary token chars and the scan continues (URL slashes). * Guard tiers: plain = both chars live; claimed = '/' fully suppressed, * '@' live; frozen = none. * @@ -46,15 +47,27 @@ function boundaryOk(draft: string, index: number, char: TriggerChar): boolean { */ export const detectTrigger: DetectTrigger = (draft, caret, guard) => { if (guard.tier === 'frozen') return null + const at = activeAtToken(draft, caret) + if (at !== undefined) { + const start = caret - at.prefix.length + return { + trigger: '@', + query: at.query, + quoted: at.quoted, + position: draft.search(/\S/) === start ? 'leading' : 'inline', + span: { start, end: caret, draftRev: 0 }, + } + } for (let i = caret - 1; i >= 0; i--) { const ch = draft.charAt(i) if (WHITESPACE.test(ch)) return null - if (ch !== '/' && ch !== '@') continue - if (guard.tier === 'claimed' && ch === '/') continue + if (ch !== '/') continue + if (guard.tier === 'claimed') continue if (!boundaryOk(draft, i, ch)) continue return { trigger: ch, query: draft.slice(i + 1, caret), + quoted: false, position: draft.search(/\S/) === i ? 'leading' : 'inline', span: { start: i, end: caret, draftRev: 0 }, } diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index bbbbeefd88..6d0edcb645 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -1,6 +1,6 @@ /** * Frozen cross-package contract for the input trigger pipeline. Types only — - * no runtime code. Sources (ui-commands / ui-skill / ui-subagent) and the + * no runtime code. Sources (ui-commands / ui-skill / ui-reference) and the * conversation input layer import from here; changes require main-thread * arbitration. * @@ -35,6 +35,10 @@ export interface InputTriggerCandidate { readonly description?: string readonly icon?: string readonly hint?: string + /** Optional visual group heading shared by adjacent candidates. */ + readonly section?: string + /** Opaque source-owned pick payload. */ + readonly value?: string } /** Pick-moment snapshot of the trigger token span. CAS: stale draftRev ⇒ the whole action no-ops. */ @@ -110,7 +114,7 @@ export interface SubmitOutcome { export type PickOutcome = | { readonly claim: CommandClaim } | { readonly insert: ReferenceInsert } - | { readonly text: string } + | { readonly text: string; readonly continue?: boolean } | 'handled' | undefined @@ -127,6 +131,8 @@ export interface SubmitEnvelope { /** Candidate request passed to a source. The signal is superseded on query change / menu close. */ export interface CandidateRequest { readonly query: string + /** Whether the active @file token is an open quoted path. */ + readonly quoted?: boolean readonly position: TriggerPosition readonly signal: AbortSignal } @@ -256,6 +262,8 @@ export interface InsertTextRequest { /** Literal replacement for the trigger token span (e.g. `/name `). */ readonly text: string readonly span: TokenSpan + /** Keep completion open after the splice (directory descent): the input re-tracks at the caret. */ + readonly continue?: boolean } declare module '@deepseek-ai/cordis' { diff --git a/packages/client/ui-input-trigger/tests/core-detect.client.spec.ts b/packages/client/ui-input-trigger/tests/core-detect.client.spec.ts index 013645b91e..44dcd45497 100644 --- a/packages/client/ui-input-trigger/tests/core-detect.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/core-detect.client.spec.ts @@ -91,6 +91,17 @@ describe('detectTrigger guard tiers', () => { }) describe('detectTrigger span and query', () => { + it('keeps an open quoted @file token active across spaces', () => { + const draft = 'read @"docs/design notes' + expect(atEnd(draft)).toMatchObject({ + trigger: '@', + query: 'docs/design notes', + quoted: true, + position: 'inline', + span: { start: 5, end: draft.length }, + }) + }) + it('spans trigger char to caret with a placeholder draftRev', () => { const hit = detectTrigger('say /goal', 9, plain) expect(hit?.span).toEqual({ start: 4, end: 9, draftRev: 0 }) diff --git a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts b/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts index 6f1d5101bd..52872848d9 100644 --- a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts @@ -7,6 +7,7 @@ import { exactMatch, MENU_CLOSED, menuReduce, seedGroups } from '../src/core/men const hit = (query = ''): TriggerHit => ({ trigger: '/', query, + quoted: false, position: 'leading', span: { start: 0, end: 1 + query.length, draftRev: 1 }, }) diff --git a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx index 92909412bf..399ad7f90e 100644 --- a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx +++ b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx @@ -19,6 +19,7 @@ import { MenuView } from '../src/client/MenuView.tsx' const hit: TriggerHit = { trigger: '/', query: 'g', + quoted: false, position: 'leading', span: { start: 0, end: 2, draftRev: 1 }, } @@ -99,6 +100,31 @@ describe('MenuView', () => { expect(titles(view.container)).toEqual(['命令', 'mystery', '技能']) }) + it('renders contiguous candidate sections once without changing option indexes', () => { + const { onPick } = mount(openState({ + groups: [{ + source: 'reference', + status: 'ready', + items: [ + { name: 'Folder · src/', section: '文件与文件夹' }, + { name: 'File · README.md', section: '文件与文件夹' }, + { name: 'Session · Research', section: 'Session 对话' }, + ], + }], + highlight: { source: 'reference', index: 0 }, + })) + expect(screen.getAllByText('文件与文件夹')).toHaveLength(1) + expect(screen.getAllByText('Session 对话')).toHaveLength(1) + const options = screen.getAllByRole('option') + expect(options.map(option => option.textContent)).toEqual([ + 'Folder · src/', + 'File · README.md', + 'Session · Research', + ]) + fireEvent.mouseDown(options[2]!) + expect(onPick).toHaveBeenCalledWith('reference', 2) + }) + it('exposes the highlight via aria-activedescendant and aria-selected', () => { mount(openState({ highlight: { source: 'command', index: 1 } })) const listbox = screen.getByRole('listbox') diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index 6bf3c02017..dca9cab05f 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -359,6 +359,7 @@ describe('programmatic source launcher', () => { const hit = { trigger: '/' as const, query: '', + quoted: false, position: 'leading' as const, span: { start: 2, end: 5, draftRev: 7 }, } @@ -385,6 +386,7 @@ describe('programmatic source launcher', () => { const hit = { trigger: '/' as const, query: '', + quoted: false, position: 'leading' as const, span: { start: 0, end: 0, draftRev: 1 }, } @@ -496,6 +498,18 @@ describe('pick / scoped input events', () => { expect(controller.menu.getSnapshot().open).toBe(false) }) + it('forwards a continuing text outcome so a directory pick keeps completion open', async () => { + const { controller, actx } = pickBench(() => ({ text: '@src/', continue: true })) + const texts: Array<{ text: string; continue?: boolean }> = [] + actx.on('slash/input-insert-text', (req) => { + texts.push(req) + return true + }) + await tick() + controller.pick('command', 0) + expect(texts).toEqual([{ text: '@src/', continue: true, span: { start: 0, end: 2, draftRev: 3 } }]) + }) + it('a text outcome the input declines answers false on the space path', async () => { const src: InputTriggerSource = { trigger: '/', diff --git a/packages/client/ui-input-trigger/tsconfig.json b/packages/client/ui-input-trigger/tsconfig.json index f039a29de6..0f479a45f4 100644 --- a/packages/client/ui-input-trigger/tsconfig.json +++ b/packages/client/ui-input-trigger/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../runtime" }, + { + "path": "../../context/file-reference" + }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index b19242fb44..76b9d3cb2a 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -11,7 +11,10 @@ align-items: center; gap: 4px; min-width: 0; + /* Legacy fallback, then a row-relative cap that grants long names more + room without letting this trailing control consume the composer. */ max-width: 220px; + max-width: min(360px, 45cqw); height: 28px; padding: 0 4px 0 8px; border: none; @@ -69,7 +72,11 @@ z-index: 20; display: flex; flex-direction: column; - width: min(240px, calc(100vw - 32px)); + /* Let the visible pane's rows set the card width, but keep pathological ids + and narrow screens inside a bounded card where ellipsis remains. */ + width: max-content; + min-width: min(240px, calc(100vw - 32px)); + max-width: min(420px, calc(100vw - 32px)); max-height: min(360px, calc(100vh - 96px)); overflow: hidden; padding: 4px; @@ -150,10 +157,12 @@ } .option { + box-sizing: border-box; display: flex; align-items: center; gap: 8px; - width: 100%; + width: auto; + min-width: 100%; min-height: 38px; padding: 6px 8px; border: none; @@ -218,10 +227,12 @@ padding, 8px gap, 10px radius; 14/22 label in primary, value in the #81858C tertiary tone, right chevron drilling into the sub-list. */ .cell { + box-sizing: border-box; display: flex; align-items: center; gap: 8px; - width: 100%; + width: auto; + min-width: 100%; height: 40px; padding: 0 10px; border: none; @@ -239,19 +250,17 @@ } .cellLabel { + flex: 0 0 auto; + white-space: nowrap; +} + +.cellValue { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} - -.cellValue { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + text-align: right; color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml new file mode 100644 index 0000000000..a2ccb405e1 --- /dev/null +++ b/packages/client/ui-reference/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/client/ui-reference/README.md +README.md: 12a8e69624c0d7fe28c10ae708466fdda4a8480d +README.zh.md: bfaee51af3947fe794afab3a99df194c543b0082 diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md new file mode 100644 index 0000000000..12a8e69624 --- /dev/null +++ b/packages/client/ui-reference/README.md @@ -0,0 +1,25 @@ +# `@deepseek-ai/dsh-client-ui-reference` + +English | [中文](README.zh.md) + +Unified Web `@file` and `@session` source. The browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together for an unquoted token, deterministically orders files before sessions with locale-registered folder/file/session labels, renders the rows under non-selectable file and session section headings, and degrades either failed candidate domain independently. An open `@"…` token searches files only. + +File picks insert the natural text defined by the shared `@path` grammar. A file closes completion and adds a trailing space; a directory keeps the menu active at its trailing slash so the user can descend another level. Paths containing whitespace use `@"path with spaces"`, and a quote the user opened explicitly remains quoted. + +Session picks insert an atomic composer chip whose hidden `ref` and clipboard representation are the canonical `@[label](dsh-session:…)` mention returned by the Host. The visible chip uses `@label`; serialization never reconstructs identity from that label. Ordinary send carries the canonical mention through `session.prompt`; the session-reference service validates it and captures model context at `agent/pre-step`. + +The `/client` export is the plugin body (`apply`/`inject`) only; candidate encoding stays internal to the registration effect. + +## Model Experience + +Indirectly, through `@deepseek-ai/dsh-file-reference-local` for path guidance and `@deepseek-ai/dsh-session-reference` for prepared session snapshots. + +#### KV Cache effect + +Candidate browsing has no model effect. A selected file or session changes only the new user-message suffix and any Host-prepared session-reference prefix attached to that message; earlier target history remains unchanged. + +## Known Limitations and Deferred Work + +- **Candidate failure is intentionally quiet** — one unavailable or failed Remote discovery call yields no rows for that domain. A session-reference preparation failure occurs after prompt acceptance and terminates that agent turn. +- **No browser-side file scan** — Web completion requires a mounted Host `ctx.fileReferences` provider; the browser cannot fall back to its own filesystem. +- **Session search remains metadata-only** — discovery filters session id, cwd, and the latest log-backed title through `ctx.sessionReferenceResolver`; message bodies and full transcripts are not searched. diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md new file mode 100644 index 0000000000..bfaee51af3 --- /dev/null +++ b/packages/client/ui-reference/README.zh.md @@ -0,0 +1,25 @@ +# `@deepseek-ai/dsh-client-ui-reference` + +[English](README.md) | 中文 + +统一的 Web `@file` 与 `@session` source。对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 和 `sessionReferenceResolver/candidates` Remote 调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签;各行分别渲染在不可选择的文件与会话分组标题下,任一候选领域的失败都会独立降级。尚未闭合的 `@"…` token 只搜索文件。 + +选择文件会插入共享 `@path` 语法所定义的自然文本。文件会关闭补全并追加一个尾随空格;目录则让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。 + +选择会话会插入一个原子的输入框 chip,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` mention。可见 chip 使用 `@label`;序列化永远不会根据该标签重建身份。普通发送会通过 `session.prompt` 携带规范 mention,session-reference 服务会在 `agent/pre-step` 校验它并捕获模型上下文。 + +`/client` 只导出插件主体(`apply`/`inject`);候选编码保留在注册 effect 内部。 + +## 模型体验 + +间接影响模型体验:路径指引由 `@deepseek-ai/dsh-file-reference-local` 提供,准备后的会话快照由 `@deepseek-ai/dsh-session-reference` 提供。 + +#### KV 缓存影响 + +浏览候选项不会影响模型。选择文件或会话只会改变新用户消息的后缀,以及附加到该消息、由宿主准备的会话引用前缀;目标会话更早的历史保持不变。 + +## 已知限制与暂缓事项 + +- **候选失败有意保持静默**:Remote 发现调用不可用或失败时,该领域不产生候选行。会话引用准备失败发生在提示词接受后,并会终止该 agent 轮次。 +- **浏览器侧不扫描文件**:Web 补全需要挂载宿主 `ctx.fileReferences` 提供方;浏览器无法回退到自身文件系统。 +- **会话搜索仍仅使用元数据**:发现流程通过 `ctx.sessionReferenceResolver` 筛选 session id、cwd 和以日志为依据的最新标题;不搜索消息主体或完整 transcript(文本记录)。 diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json new file mode 100644 index 0000000000..de87daa5b1 --- /dev/null +++ b/packages/client/ui-reference/package.json @@ -0,0 +1,77 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-reference", + "description": "Unified Web @file and @session reference source", + "version": "0.1.0-rc.7", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-reference" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-input-trigger" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts new file mode 100644 index 0000000000..fb356e73a8 --- /dev/null +++ b/packages/client/ui-reference/src/client/index.ts @@ -0,0 +1,126 @@ +/** + * Unified Web `@` reference source. File and session discovery run through + * the cancellable generated Remote namespaces in parallel with deterministic + * ordering and labels. + * + * @module @deepseek-ai/dsh-client-ui-reference/client + */ +// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. +import type {} from '@deepseek-ai/dsh-api-remotes/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ClientSessionContext, InputTriggerServiceContract, InputTriggerSource, +} from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' +import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' +import { en, NS, zh, type ReferenceKey } from './locales.ts' + +/** Required services: the trigger registry, the Remote namespaces, and the copy. */ +export const inject = [ + 'inputTriggers', 'locale', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', +] + +/** + * Register the combined `@file` / `@session` source. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-reference: dictionaries') + const t = ctx.locale.bind(NS) + const source: InputTriggerSource = { + trigger: '@', + name: 'reference', + async candidates(session: ClientSessionContext, { query, quoted, signal }) { + const files = ctx.remote.fileReferences.list(session.sessionId, query, signal).then( + result => result.ok ? result.value : [], + () => [], + ) + const sessions = quoted === true + ? Promise.resolve([] as SessionReferenceMentionCandidate[]) + : ctx.remote.sessionReferenceResolver.candidates(session.sessionId, query, signal).then( + result => result.ok ? result.value : [], + () => [], + ) + const [fileItems, sessionItems] = await Promise.all([files, sessions]) + if (signal.aborted) return [] + return [ + ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, t)), + ...sessionItems.map(candidate => sessionCandidate(candidate, t)), + ] + }, + onPick({ candidate }) { + const value = parseCandidate(candidate.value) + if (value?.kind === 'file') { + return { + text: value.mention + (value.fileKind === 'file' ? ' ' : ''), + ...value.fileKind === 'directory' ? { continue: true } : {}, + } + } + if (value?.kind === 'session') { + return { + insert: { + source: 'reference', + ref: value.mention, + label: `@${value.label}`, + clipboardText: value.mention, + }, + } + } + return undefined + }, + codec: { + clipboardText: ref => ref, + serialize: ref => Promise.resolve(ref), + }, + } + const inputTriggers = ctx.get('inputTriggers') as InputTriggerServiceContract + ctx.effect(() => inputTriggers.registerSource(source), 'ui-reference: @ source') +} + +type Translate = (key: ReferenceKey) => string + +type ReferenceCandidateValue = + | { kind: 'file'; fileKind: FileReferenceCandidate['kind']; mention: string } + | { kind: 'session'; label: string; mention: string } + +function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean, t: Translate) { + const mention = formatFileMention(candidate, preserveQuote) + if (mention === undefined) return [] + const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1) + const directory = candidate.kind === 'directory' + const value: ReferenceCandidateValue = { + kind: 'file', + fileKind: candidate.kind, + mention, + } + return [{ + name: `${t(directory ? 'candidate.folder' : 'candidate.file')} · ${name}${directory ? '/' : ''}`, + description: candidate.path, + section: t('section.files'), + value: JSON.stringify(value), + }] +} + +function sessionCandidate(candidate: SessionReferenceMentionCandidate, t: Translate) { + const location = candidate.cwd ?? t('candidate.noCwd') + const description = `${candidate.label === candidate.sessionId ? '' : `${candidate.sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}` + const value: ReferenceCandidateValue = { + kind: 'session', + label: candidate.label, + mention: candidate.mention, + } + return { + name: `${t('candidate.session')} · ${candidate.label}`, + description, + section: t('section.sessions'), + value: JSON.stringify(value), + } +} + +function parseCandidate(value: string | undefined): ReferenceCandidateValue | undefined { + if (value === undefined) return undefined + return JSON.parse(value) as ReferenceCandidateValue +} diff --git a/packages/client/ui-reference/src/client/locales.ts b/packages/client/ui-reference/src/client/locales.ts new file mode 100644 index 0000000000..41ec525e75 --- /dev/null +++ b/packages/client/ui-reference/src/client/locales.ts @@ -0,0 +1,36 @@ +/** `reference` namespace dictionaries for the unified `@` source. */ + +import type {} from '@deepseek-ai/dsh-client-ui-slots' + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'reference' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'section.files': '文件与文件夹', + 'section.sessions': 'Session 对话', + 'candidate.file': '文件', + 'candidate.folder': '文件夹', + 'candidate.session': 'Session', + 'candidate.noCwd': '(无工作目录)', +} satisfies Record + +/** The reference namespace key union. */ +export type ReferenceKey = keyof typeof zh + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The unified `@` reference menu's copy. */ + reference: ReferenceKey + } +} + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'section.files': 'Files & folders', + 'section.sessions': 'Session conversations', + 'candidate.file': 'File', + 'candidate.folder': 'Folder', + 'candidate.session': 'Session', + 'candidate.noCwd': '(no cwd)', +} satisfies Record diff --git a/packages/client/ui-reference/src/index.ts b/packages/client/ui-reference/src/index.ts new file mode 100644 index 0000000000..4e0edf6e4b --- /dev/null +++ b/packages/client/ui-reference/src/index.ts @@ -0,0 +1,9 @@ +/** + * File/session reference plugin, node half. Pure UI plugin: the empty apply + * exists so the plugin appears in the host cordis.yml / Loader; the browser + * half ships via exports["./client"], discovered through the package.json + * `dsh.client` declaration. + */ + +/** Host plugin body — no host-side behavior for this source plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-reference/src/invariant.ts b/packages/client/ui-reference/src/invariant.ts new file mode 100644 index 0000000000..708530fb16 --- /dev/null +++ b/packages/client/ui-reference/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-reference`. + * @module @deepseek-ai/dsh-client-ui-reference/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-reference' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-reference-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a single slash-source registration whose disposal is + * proven by the HMR-safety spec — it emits no cordis events and owns no + * cross-plugin mutable state. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts new file mode 100644 index 0000000000..d75d68d50e --- /dev/null +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -0,0 +1,309 @@ +/** + * Web reference source coverage: Remote-backed file/session discovery, + * deterministic ordering and labels, quoted-path suppression, pick projections, codec + * round-trip, and registration lifecycle. + */ +import { Context, Service } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { + CandidateRequest, ClientSessionContext, InputTriggerCandidate, InputTriggerSource, +} from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' +import { apply, inject } from '../src/client/index.ts' +import { apply as nodeApply } from '../src/index.ts' + +const sid = (value: string): SessionId => value as SessionId +const session: ClientSessionContext = { sessionId: sid('target') } + +type RemoteEnvelope = + | { ok: true; value: T } + | { ok: false; error: { code: string; message: string; details: object } } + +type RemoteLookup = ( + agentId: SessionId, + query: string, + signal?: AbortSignal, +) => Promise> + +function request( + query: string, + options: { quoted?: boolean; signal?: AbortSignal } = {}, +): CandidateRequest { + return { + query, + quoted: options.quoted ?? false, + position: 'inline', + signal: options.signal ?? new AbortController().signal, + } +} + +async function bench( + files: RemoteLookup = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [ + { path: 'src', kind: 'directory' as const }, + { path: 'docs/a b.md', kind: 'file' as const }, + ], + })), + sessions: RemoteLookup = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 1_700_000_000_000, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], + })), +): Promise<{ ctx: Context; fiber: ReturnType; source: InputTriggerSource }> { + const ctx = new Context() + let source: InputTriggerSource | undefined + ctx.provide('inputTriggers', { + registerSource(candidate: InputTriggerSource) { + source = candidate + return () => { source = undefined } + }, + }) + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + ctx.provide('remote.fileReferences', { list: files }) + ctx.provide('remote.sessionReferenceResolver', { candidates: sessions }) + ctx.provide('locale', new LocaleRuntime(ctx)) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + if (source === undefined) throw new Error('reference source was not registered') + return { ctx, fiber, source } +} + +describe('apply', () => { + it('declares its services and releases the @ reference registration on disposal', async () => { + expect(inject).toEqual([ + 'inputTriggers', 'locale', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', + ]) + const { fiber } = await bench() + let registered: InputTriggerSource | undefined + const ctx = new Context() + ctx.provide('inputTriggers', { + registerSource(source: InputTriggerSource) { + registered = source + return () => { registered = undefined } + }, + }) + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + ctx.provide('remote.fileReferences', { list: () => Promise.resolve({ ok: true, value: [] }) }) + ctx.provide('remote.sessionReferenceResolver', { candidates: () => Promise.resolve({ ok: true, value: [] }) }) + ctx.provide('locale', new LocaleRuntime(ctx)) + const ownFiber = ctx.plugin({ inject: [...inject], apply }) + await ownFiber.await() + expect(registered).toMatchObject({ trigger: '@', name: 'reference' }) + await ownFiber.dispose() + expect(registered).toBeUndefined() + await fiber.dispose() + }) + + it('the node half applies without host-side behavior', () => { + expect(() => { nodeApply() }).not.toThrow() + }) +}) + +describe('candidates', () => { + it('starts both Remote lookups together and renders files before sessions with stable labels', async () => { + let releaseFiles!: () => void + let releaseSessions!: () => void + const files = vi.fn(() => new Promise<{ + ok: true + value: { path: string; kind: 'file' | 'directory' }[] + }>((resolve) => { + releaseFiles = () => { + resolve({ + ok: true, + value: [ + { path: 'src', kind: 'directory' }, + { path: 'docs/a b.md', kind: 'file' }, + ], + }) + } + })) + const sessions = vi.fn(() => new Promise<{ + ok: true + value: { + sessionId: SessionId + label: string + cwd: string + createdAt: number + mention: string + }[] + }>((resolve) => { + releaseSessions = () => { + resolve({ + ok: true, + value: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 1_700_000_000_000, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], + }) + } + })) + const { source } = await bench(files, sessions) + const pending = source.candidates(session, request('re')) + expect(files).toHaveBeenCalledTimes(1) + expect(sessions).toHaveBeenCalledTimes(1) + releaseSessions() + releaseFiles() + await expect(pending).resolves.toEqual([ + expect.objectContaining({ + name: 'Folder · src/', + description: 'src', + section: 'Files & folders', + }), + expect.objectContaining({ + name: 'File · a b.md', + description: 'docs/a b.md', + section: 'Files & folders', + }), + expect.objectContaining({ + name: 'Session · Research', + description: 'source · /project · 2023-11-14T22:13:20.000Z', + section: 'Session conversations', + }), + ]) + }) + + it('suppresses sessions for an open quoted path and degrades each failed domain independently', async () => { + const files = vi.fn() + .mockResolvedValueOnce({ + ok: true as const, + value: [{ path: 'README.md', kind: 'file' as const }], + }) + .mockRejectedValueOnce(new Error('file scan failed')) + const sessions = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 0, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], + })) + const { source } = await bench(files, sessions) + const quoted = await source.candidates(session, request('READ', { quoted: true })) + expect(quoted).toEqual([expect.objectContaining({ name: 'File · README.md' })]) + expect(source.onPick({ + candidate: quoted[0]!, + session, + position: 'inline', + via: 'menu', + span: { start: 0, end: 6, draftRev: 1 }, + })).toEqual({ text: '@"README.md" ' }) + expect(sessions).not.toHaveBeenCalled() + await expect(source.candidates(session, request('research'))).resolves.toEqual([ + expect.objectContaining({ name: 'Session · Research' }), + ]) + }) + + it('drops a completed result when the query signal was superseded', async () => { + const controller = new AbortController() + const { source } = await bench() + const pending = source.candidates(session, request('', { signal: controller.signal })) + controller.abort() + await expect(pending).resolves.toEqual([]) + }) + + it('treats Remote failures as empty domains and filters paths that cannot be mentioned', async () => { + const files = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [{ path: 'bad\nname', kind: 'file' as const }], + })) + const sessions = vi.fn() + .mockRejectedValueOnce(new Error('session lookup failed')) + .mockResolvedValueOnce({ + ok: false as const, + error: { code: 'internal', message: 'session lookup failed', details: {} }, + }) + const { source } = await bench(files, sessions) + await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) + + files.mockResolvedValueOnce({ + ok: false as const, + error: { code: 'internal', message: 'file lookup failed', details: {} }, + } as never) + await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) + }) + + it('omits redundant session ids and labels sessions without a cwd', async () => { + const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const sessions = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [{ + sessionId: sid('same'), + label: 'same', + createdAt: 0, + mention: '@[same](dsh-session:InNhbWUi)', + }], + })) + const { source } = await bench(files, sessions) + await expect(source.candidates(session, request('same'))).resolves.toEqual([ + expect.objectContaining({ + name: 'Session · same', + description: '(no cwd) · 1970-01-01T00:00:00.000Z', + }), + ]) + }) +}) + +describe('pick and codec', () => { + const pick = (source: InputTriggerSource, candidate: InputTriggerCandidate) => source.onPick({ + candidate, + session, + position: 'inline', + via: 'menu', + span: { start: 0, end: 1, draftRev: 1 }, + }) + + it('inserts files as path text, keeping directory completion open', async () => { + const { source } = await bench() + const [directory, file] = await source.candidates(session, request('')) + expect(pick(source, directory!)).toEqual({ text: '@src/', continue: true }) + expect(pick(source, file!)).toEqual({ text: '@"docs/a b.md" ' }) + const [quotedDirectory] = await source.candidates(session, request('', { quoted: true })) + expect(pick(source, quotedDirectory!)).toEqual({ text: '@"src/', continue: true }) + }) + + it('inserts sessions as atomic chips whose clipboard and model forms are canonical mentions', async () => { + const { source } = await bench() + const candidates = await source.candidates(session, request('')) + const candidate = candidates.find(item => item.name === 'Session · Research')! + const mention = '@[Research](dsh-session:InNvdXJjZSI)' + expect(pick(source, candidate)).toEqual({ + insert: { + source: 'reference', + ref: mention, + label: '@Research', + clipboardText: mention, + }, + }) + expect(source.codec?.clipboardText(mention)).toBe(mention) + await expect(source.codec?.serialize(mention, new AbortController().signal)).resolves.toBe(mention) + }) + + it('ignores candidates that do not carry a source-owned value', async () => { + const { source } = await bench() + expect(pick(source, { name: 'foreign candidate' })).toBeUndefined() + }) +}) diff --git a/packages/client/ui-reference/tsconfig.json b/packages/client/ui-reference/tsconfig.json new file mode 100644 index 0000000000..4fdd19dbc2 --- /dev/null +++ b/packages/client/ui-reference/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, + { + "path": "../../context/file-reference" + }, + { + "path": "../../context/session-reference" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-input-trigger" + }, + { + "path": "../ui-slots" + } + ] +} diff --git a/packages/client/ui-reference/tsdown.config.ts b/packages/client/ui-reference/tsdown.config.ts new file mode 100644 index 0000000000..1c70dc948a --- /dev/null +++ b/packages/client/ui-reference/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-reference', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 04d15c7dec..f7d9c79e21 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -61,8 +61,8 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 93a86a8801..4ff9c91055 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -1,21 +1,8 @@ -/** - * Subagent reference plugin, browser half: registers the '@' source — - * candidates filtered from the session list snapshot's running children - * (zero RPC; the list rides the plugin's root-context sessions service, the - * scoped session comes from the per-call projection), pick inserts the - * literal `@label ` text (plain-text-reference decision, see - * .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md: - * the draft carries plain text, chip - * visuals are derived by scanning against the source lexicon, and the - * prompt ships the same literal). Consumption semantics stay with future - * business work. No adjudication hooks: subagent - * references never enter command adjudication. - */ +/** Web subagent catalog, navigation, and addressed-session composer owner. */ import type { ClientContext, SessionId, SubagentAddress, } from '@deepseek-ai/dsh-client-runtime/client' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { ClientSessionContext, InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { SubagentCatalogAction, type SubagentCatalogInjected } from './SubagentCatalogAction.tsx' import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, @@ -37,8 +24,8 @@ export type { SubagentReadOnlyComposerProps, SubagentReadOnlyMatch, } from './SubagentReadOnlyComposer.tsx' -/** Required services for references, conversation slots, and session navigation. */ -export const inject = ['inputTriggers', 'sessions', 'slots', 'locale'] +/** Required services for conversation slots and session navigation. */ +export const inject = ['sessions', 'slots', 'locale'] /** Claim the composer for one-shot history or an unavailable continuation owner. */ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { @@ -53,49 +40,12 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc } /** - * Client plugin body: register the '@' subagent source over the root session list. + * Client plugin body: register the subagent catalog and read-only composer seats. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-subagent: dictionaries') const sessions = ctx.sessions - // Child labels live on the session list (parentId lineage + displayTitle), - // not the conversation snapshot — the list store is the zero-RPC candidate feed. - const childLabels = (session: ClientSessionContext, query: string): string[] => { - const { byId } = sessions.list.getSnapshot() - return Object.values(byId) - .filter(child => child.parentId === session.sessionId && child.running && child.displayTitle.includes(query)) - .map(child => child.displayTitle) - } - const source: InputTriggerSource = { - trigger: '@', - name: 'subagent', - candidates(session, { query }) { - return Promise.resolve(childLabels(session, query).map(name => ({ name }))) - }, - lexicon(session) { - // The list snapshot is always warm — the full running-children roster. - return childLabels(session, '') - }, - subscribeLexicon(_session, listener) { - // The roll derives from the list snapshot, so its change feed IS the list's. - return sessions.list.subscribe(listener) - }, - onPick({ candidate }) { - // Plain-text reference: the literal lands in the draft - // and ships to the model verbatim (trailing space closes the token). - return { text: `@${candidate.name} ` } - }, - codec: { - clipboardText: ref => `@${ref}`, - // TODO: serialize returns the raw label until the '@' consumption - // feature defines a model representation. - serialize: ref => Promise.resolve(`@${ref}`), - }, - } - const inputTriggers = ctx.get('inputTriggers') as InputTriggerServiceContract - ctx.effect(() => inputTriggers.registerSource(source), 'ui-subagent: @ source') - const catalogActions = (_parentSessionId: SessionId): SubagentCatalogInjected => ({ openChild(address: SubagentAddress) { sessions.openSubagent(address) diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index ccd48bb7c0..0758235aec 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -1,16 +1,4 @@ -/** - * ui-subagent browser half: source registration (duplicate-name proof) + - * fiber-teardown removal (HMR safety) against the real InputTriggerService, then - * the source behavior contract driven directly on the captured source with - * real ClientSessionContext projections — zero-RPC candidates from the root - * session list (running children of the projected session, label-contains - * filtering, childless session → empty), the synchronous lexicon roster, - * pick → plain-text outcome (the plain-text-reference decision: - * .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md), - * and the reference codec's two - * projections. Direct driving is deliberate: this spec owns only the - * source's own contract. - */ +/** ui-subagent browser half: catalog actions and read-only composer routing. */ import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { describe, expect, it } from 'vitest' @@ -19,8 +7,6 @@ import { type SessionSummary, type SubagentAddress, } from '@deepseek-ai/dsh-client-runtime/client' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { ClientSessionContext, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { SubagentCatalogAction, type SubagentCatalogInjected, @@ -41,20 +27,17 @@ function summary(partial: Partial & { id: SessionId }): SessionS const sid = (id: string) => id as SessionId -/** Fake root sessions face: the list snapshot the source closes over. */ +/** Fake root sessions face for catalog actions. */ function sessionsWith(sessions: SessionSummary[]) { const byId: Record = {} for (const s of sessions) byId[s.id] = s const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState - const subs = new Set<() => void>() const actionCalls: { method: string; args: unknown[] }[] = [] return { list: { getSnapshot: () => snapshot, - subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } }, + subscribe: () => () => {}, }, - notify: () => { for (const fn of [...subs]) fn() }, - listenerCount: () => subs.size, actionCalls, openSubagent: (address: SubagentAddress) => { actionCalls.push({ method: 'openSubagent', args: [address] }) @@ -80,26 +63,18 @@ async function provideSlotFaces(ctx: Context): Promise { } as never, () => null) } -/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */ +/** Boot the plugin over fake sessions and slot faces. */ async function fullBench(sessions: SessionSummary[]) { const ctx = new Context() - let captured: InputTriggerSource | undefined const face = sessionsWith(sessions) - ctx.provide('inputTriggers', { registerSource: (src: InputTriggerSource) => { captured = src; return () => {} } }) ctx.provide('sessions', face) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) - // ui-theme's Appearance row binds a durable scope through these two. ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await provideSlotFaces(ctx) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() await ctx.plugin({ inject: [...inject], apply }).await() - return { source: captured!, face, ctx } -} - -/** Source-only bench for the behavior-contract suites. */ -async function bench(sessions: SessionSummary[]): Promise { - return (await fullBench(sessions)).source + return { face, ctx } } const FAMILY: SessionSummary[] = [ @@ -112,40 +87,9 @@ const FAMILY: SessionSummary[] = [ summary({ id: sid('c5'), parentId: sid('parent'), displayTitle: 'scout', running: true }), ] -const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) }) - -const req = (query: string) => - ({ query, position: 'inline' as const, signal: new AbortController().signal }) - describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['inputTriggers', 'sessions', 'slots', 'locale']) - }) - - it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => { - const ctx = new Context() - await ctx.plugin(InputTriggerService).await() - ctx.provide('sessions', sessionsWith(FAMILY)) - ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) - // ui-theme's Appearance row binds a durable scope through these two. - ctx.provide('remote', { $on: () => () => {} } as never) - ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) - await provideSlotFaces(ctx) - await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - const inputTriggers = ctx.get('inputTriggers') as InputTriggerService - const rival = { - trigger: '@' as const, - name: 'subagent', - candidates: () => Promise.resolve([]), - onPick: () => undefined, - } - // Live registration holds the (trigger, name) seat… - expect(() => inputTriggers.registerSource(rival)).toThrow(/already registered/) - // …and fiber teardown releases it. - await fiber.dispose() - expect(() => inputTriggers.registerSource(rival)).not.toThrow() + expect(inject).toEqual(['sessions', 'slots', 'locale']) }) it('registers catalog actions and selects read-only subagent composers from session facts', async () => { @@ -194,74 +138,3 @@ describe('apply', () => { expect(select(owner({ address, parentAvailable: false }, true))).toBeNull() }) }) - -describe('candidates', () => { - it('returns running children of the projected session, filtered by label containment', async () => { - const source = await bench(FAMILY) - await expect(source.candidates(proj('parent'), req('worker'))).resolves.toEqual([ - { name: 'worker-1' }, { name: 'worker-2' }, - ]) - }) - - it('matches every running child on an empty query (containment, not prefix)', async () => { - const source = await bench(FAMILY) - await expect(source.candidates(proj('parent'), req(''))).resolves.toEqual([ - { name: 'worker-1' }, { name: 'worker-2' }, { name: 'scout' }, - ]) - }) - - it('is candidate-less for a session with no children', async () => { - const source = await bench(FAMILY) - await expect(source.candidates(proj('childless'), req(''))).resolves.toEqual([]) - }) -}) - -describe('lexicon', () => { - it('synchronously serves the projected session\'s full running-children roster', async () => { - const source = await bench(FAMILY) - expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout']) - expect(source.lexicon!(proj('childless'))).toEqual([]) - }) - - it('subscribeLexicon forwards the session-list change feed and unsubscribes cleanly', async () => { - const { source, face } = await fullBench(FAMILY) - let notified = 0 - const off = source.subscribeLexicon!(proj('parent'), () => { notified += 1 }) - expect(face.listenerCount()).toBe(1) - face.notify() - expect(notified).toBe(1) - off() - expect(face.listenerCount()).toBe(0) - face.notify() - expect(notified).toBe(1) - }) -}) - -describe('pick and codec', () => { - it('onPick returns the literal @label text with a closing space', async () => { - const source = await bench(FAMILY) - const outcome = source.onPick({ - candidate: { name: 'worker-1' }, - session: proj('parent'), - position: 'inline', - via: 'menu', - span: { start: 4, end: 8, draftRev: 3 }, - }) - expect(outcome).toEqual({ text: '@worker-1 ' }) - }) - - it('codec projects clipboard `@label` and serializes the same raw label this phase', async () => { - const source = await bench(FAMILY) - expect(source.codec!.clipboardText('worker-1')).toBe('@worker-1') - await expect(source.codec!.serialize('worker-1', new AbortController().signal)) - .resolves.toBe('@worker-1') - }) -}) - -describe('adjudication', () => { - it('never participates: no matchSpace/matchEnter hooks on the subagent source', async () => { - const source = await bench(FAMILY) - expect('matchSpace' in source && source.matchSpace !== undefined).toBe(false) - expect('matchEnter' in source && source.matchEnter !== undefined).toBe(false) - }) -}) diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-subagent/tsconfig.json index 16bc05eb38..ac34489efb 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-subagent/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../ui-primitives" }, - { - "path": "../ui-input-trigger" - }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index b41ee17acc..678a276a73 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.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 packages/client/ui-tool/README.md -README.md: 6e2bef2f5ad4b136510c3acbb8f8b8e83c4c9212 -README.zh.md: 169417747541db9e02cb552b175ca7c100420aeb +README.md: b87236309c9bafe3e35d3d5977d56bd62a24de31 +README.zh.md: 3bae0b3f4cb3ad695371ec7a66fb531a935a4d2f diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 6e2bef2f5a..b87236309c 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -28,7 +28,7 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd`, and plain `openFile`/`inspect` callbacks. The registration receives the normal session slot runtime share. It does not receive React nodes, Runtime services, or root/subcall knowledge. +The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. Path summaries relativize to the session cwd first, then replace a leftover POSIX host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal session slot runtime share. It does not receive React nodes, Runtime services, or root/subcall knowledge. This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. `ui-skill` demonstrates a business-owned registration for `skill`. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 1694177475..3bae0b3f4c 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -28,7 +28,7 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd`,以及普通的 `openFile`、`inspect` 回调。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React node、运行时服务或 root/subcall 知识。 +owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`、`inspect` 回调。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX 宿主家目录写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React node、运行时服务或 root/subcall 知识。 本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index a73e836fda..73f7659b80 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation" @@ -50,6 +51,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index ec2f0b8ec1..a2cc912d0d 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -1,4 +1,5 @@ /** Register the Tool call tree, details renderer, and built-in atomic views. */ +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { ToolCallTree } from './tool/ToolCallTree.tsx' @@ -12,14 +13,16 @@ import { searchToolview } from './tool/toolviews/search-row.tsx' import { todoToolview } from './tool/toolviews/todo-row.tsx' import { webToolview } from './tool/toolviews/web-row.tsx' -/** Required service: the slot registry that owns both Tool render seats. */ -export const inject = ['slots'] +/** Required services: the slot registry and the Host description used for POSIX `~`. */ +export const inject = ['slots', 'connection'] /** * Mount the whole-Tool renderers and built-in atomic Tool registrations. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { + const connection = ctx.get('connection') as ConnectionHandle + const toolInject = () => ({ hooks: { hostDescription: connection.hostDescription } }) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'tool-call', @@ -27,11 +30,13 @@ export function apply(ctx: ClientContext): void { children: { 'tool.call.toolview': { kind: 'keyed', scope: 'session' }, }, + inject: toolInject, }, ToolCallTree)) ctx.slots.inject('conversation.details.tool', () => ctx.slots.register({ name: 'conversation.details.tool', locale: NS, + inject: toolInject, }, ToolDetails)) ctx.plugin(bashToolviewSample) diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index 04af306db4..a6cdd1fa2d 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -1,5 +1,6 @@ /** Tool UI slot declarations and their composed component props. */ -import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -34,6 +35,8 @@ export interface ToolCallOwnerProps { block: ToolCallBlock /** Session workspace root for relative summaries. */ cwd?: string | undefined + /** Host account home; POSIX home-rooted summaries display as `~`. */ + home?: string | undefined /** Open a Tool argument path through the Host. */ openFile: (path: string) => void /** Inspect this call in the trajectory view when available. */ @@ -43,10 +46,21 @@ export interface ToolCallOwnerProps { /** Full props of a registered atomic Tool view. */ export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> +/** Injected Host description for POSIX home-path display. */ +export type ToolHostDescriptionInjected = { + hooks: { + /** Current generation's Host description, bound by the slot renderer. */ + hostDescription: HostDescriptionSource + } +} + /** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */ export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'> & PropsRenderSlots<'tool.call.toolview'> & PropsLocale<'conversation'> + & InjectFace /** Full props of the selected Tool output renderer in the details panel. */ -export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> & PropsLocale<'conversation'> +export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> + & PropsLocale<'conversation'> + & InjectFace diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts index 357506b1db..2079d09a96 100644 --- a/packages/client/ui-tool/src/client/index.ts +++ b/packages/client/ui-tool/src/client/index.ts @@ -1,3 +1,5 @@ /** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */ export { apply, inject } from './apply.ts' -export type { ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolTreeProps } from './contract/slots.ts' +export type { + ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostDescriptionInjected, ToolTreeProps, +} from './contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index db3ed0af06..3ed5fb9216 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -12,12 +12,13 @@ function callName(node: ToolCallBlock): string { /** One atomic call dispatched through the Tool-owned keyed slot. */ const ToolCall = memo(function ToolCall({ - renderSlot, callId, toolName, block, openFile, selected, cwd, inspectCall, t, children, + renderSlot, callId, toolName, block, openFile, selected, cwd, home, inspectCall, t, children, }: Pick & { callId: string toolName: string block: ToolCallBlock selected: boolean + home?: string | undefined children?: ReactNode }) { const owner: ToolCallOwnerProps = useMemo(() => ({ @@ -26,8 +27,9 @@ const ToolCall = memo(function ToolCall({ block, openFile, cwd, + home, inspect: () => { inspectCall(callId) }, - }), [callId, toolName, block, openFile, cwd, inspectCall]) + }), [callId, toolName, block, openFile, cwd, home, inspectCall]) return (
& { block: ToolCallBlock + home?: string | undefined }) { return ( @@ -70,6 +74,7 @@ const ToolCallBranch = memo(function ToolCallBranch({ block={child} selectedCallId={selectedCallId} cwd={cwd} + home={home} openFile={openFile} inspectCall={inspectCall} t={t} @@ -88,8 +93,9 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostDescription, t, }: ToolTreeProps) { + const home = useHostDescription(description => description?.home) const block = node.data.root return ( ) { + const home = useHostDescription(description => description?.home) const terminal = terminalCardModel(block, cwd) if (terminal !== null) { return ( @@ -34,7 +30,7 @@ export function ToolDetails({ block, cwd, t }: ToolDetailsContentProps) { ) } - const read = readCardModel(block, cwd) + const read = readCardModel(block, cwd, home) if (read !== null) return const diff = diffCardModel(block) if (diff !== null) return diff --git a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts index 62a591bb17..e79a6979b8 100644 --- a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts @@ -13,6 +13,7 @@ * until the result arrives. * @module */ +import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts' @@ -52,14 +53,15 @@ export type ReadCardModel = Pick ({ number: line.number, text: line.text })) return { - label: result.title ?? relativizeToCwd(result.path, sessionCwd), + label: result.title ?? abbreviateHomePath(relativizeToCwd(result.path, sessionCwd), home), lines, totalLines: result.totalLines, lang: result.lang, diff --git a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts index ce7463e57f..02d3d56f30 100644 --- a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts @@ -9,6 +9,7 @@ // The block union's defining home is runtime (fold-product types); this // contract only forwards it (type-definition authority stays with the layer // that produces the values). +import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -210,16 +211,19 @@ function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null { * @param toolName - wire tool name (dispatch-supplied; survives windowless results). * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @param cwd - session workspace root; workspace-rooted path summaries display relative to it. + * @param home - host account home; a leftover POSIX home path displays as `~`. * @returns the row model. */ -export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel { +export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string, home?: string): ToolRowModel { const variant = classifyTool(toolName) const done = 'kind' in block const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? '' const state: ToolRowState = !done ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' - const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd) + const base = argsRaw === '' + ? block.callId + : abbreviateHomePath(relativizeToCwd(deriveSummary(variant, argsRaw), cwd), home) const toolTitle = TOOL_TITLES[toolName] // Others keeps the static "Tool call" title (figma literal); the real tool // name rides the mutable summary slot unless the tool owns a specific title. diff --git a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx index ae4d18eec8..99fb68b9af 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx @@ -33,10 +33,10 @@ export interface GenericToolCardProps extends ToolCallOwnerProps { t: ToolTreeProps['t'] } -export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) { - const model = toolRowModel(toolName, block, cwd) +export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, t }: GenericToolCardProps) { + const model = toolRowModel(toolName, block, cwd, home) const terminal = terminalCardModel(block, cwd) - const read = readCardModel(block, cwd) + const read = readCardModel(block, cwd, home) const diff = diffCardModel(block) const search = searchCardModel(block) const web = webCardModel(block) diff --git a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx index 9616dab7cd..ad99ed7999 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx @@ -29,8 +29,8 @@ type FileMutationRowProps = ToolCallViewProps & PropsLocale<'conversation'> * model-facing error text through its Output section and its first line in the * collapsed summary instead. */ -export function FileMutationRow({ toolName, block, cwd, openFile, inspect, t }: FileMutationRowProps) { - const model = toolRowModel(toolName, block, cwd) +export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect, t }: FileMutationRowProps) { + const model = toolRowModel(toolName, block, cwd, home) const diff = diffCardModel(block) return ( * read card as the row's collapsed-by-default card body. The summary path is an * openable host link when the row names a single file. */ -export function ReadRow({ toolName, block, cwd, openFile, inspect, t }: ReadRowProps) { - const model = toolRowModel(toolName, block, cwd) - const read = readCardModel(block, cwd) +export function ReadRow({ toolName, block, cwd, home, openFile, inspect, t }: ReadRowProps) { + const model = toolRowModel(toolName, block, cwd, home) + const read = readCardModel(block, cwd, home) return ( undefined, subscribe: () => () => {} }, + }) // ui-theme's Appearance row binds a durable scope through these two. runtime.provide('remote', { $on: () => () => {} }) runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index cb4325a09e..d73c09013c 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -158,7 +158,11 @@ async function bench(snapshot: ConversationSnapshot) { } ctx.provide('workspaces', workspaces) ctx.provide('layout', layout) - ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('connection', { + api: { settings: {} }, + isLoopback: false, + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + } as never) // ui-theme's Appearance row binds a durable scope through these two. ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 614a9ef7ef..8ae0b21cfc 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -105,6 +105,15 @@ describe('readCardModel', () => { .toBe('/w/app/src/a.ts') }) + it('abbreviates a leftover POSIX home path label', () => { + expect(readCardModel(settled({ resultView: resultRead({ path: '/Users/u/notes.md' }) }), '/tmp/ws', '/Users/u')?.label) + .toBe('~/notes.md') + expect(readCardModel(settled({ resultView: resultRead({ path: '/Users/u/app/src/a.ts' }) }), '/Users/u/app', '/Users/u')?.label) + .toBe('src/a.ts') + expect(readCardModel(settled({ resultView: resultRead({ path: 'C:\\Users\\u\\a.ts' }) }), '/tmp/ws', '/Users/u')?.label) + .toBe('C:\\Users\\u\\a.ts') + }) + it('carries an omitted language through as undefined', () => { const noLang = resultRead() delete (noLang as { lang?: string }).lang @@ -252,7 +261,12 @@ describe('ReadRow keyed toolview', () => { }) describe('DetailsPanel Output section (read)', () => { - function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) { + function mount( + snapshot: ConversationSnapshot, + selection: SelectionTarget | null, + cwd?: string, + description?: Parameters[1], + ) { localStorage.clear() const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) @@ -273,7 +287,7 @@ describe('DetailsPanel Output section (read)', () => { return render( snapshot, subscribe: () => () => {} })} @@ -333,6 +347,15 @@ describe('DetailsPanel Output section (read)', () => { expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('plain result') }) + it('abbreviates a leftover POSIX home path on the read card label', () => { + const view = mount(snapshot({ + nodes: [settled({ resultView: resultRead({ path: '/Users/u/notes.md' }) })], + }), target, '/tmp/ws', { + version: '0', cwd: '/tmp', attachedSessions: 0, home: '/Users/u', canOpenPath: false, + }) + expect(view.getByText('~/notes.md')).toBeTruthy() + }) + it('a running read keeps the 运行中… placeholder (no result view)', () => { const view = mount(snapshot({ runningCalls: [running()] }), target) expect(view.getByText('运行中…')).toBeTruthy() diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index 44f4231802..7f18800303 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -2,6 +2,7 @@ /** ToolCallTree-owned root/subcall markers and selection projection. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' +import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -21,6 +22,7 @@ const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ( function props( block: ToolResultNode, selectedCallId?: string, + description?: HostDescription, ): ToolTreeProps { const snapshot = {} as ConversationSnapshot const useSession = ((selector: (value: ConversationSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession'] @@ -44,6 +46,7 @@ function props( inspectCall: vi.fn(), forkAt: vi.fn(), fileMentions: vi.fn(), + useHostDescription: (selector => selector(description)) as ToolTreeProps['useHostDescription'], t, } as unknown as ToolTreeProps } @@ -78,4 +81,12 @@ describe('ToolCallTree', () => { expect(view.container.querySelector('[data-chat-call-id="parent:code:1:code:1"]')?.getAttribute('data-selected')).toBe('true') expect(nests).toHaveLength(2) }) + + it('abbreviates a POSIX home path in the generic tool summary', () => { + const block = root('w1', { name: 'read', argsRaw: '{"path":"/h/docs/a.ts"}' }) + const view = render() + expect(view.getByText('~/docs/a.ts')).toBeTruthy() + }) }) diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index b2b2cd9ac7..c0332e9ac6 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -1,4 +1,5 @@ /** Test adapter for the production conversation.details.tool registration. */ +import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' import type { ChatConversationViewNode, ChatSnapshot, ConversationNode, RunningToolCall, SessionId, } from '@deepseek-ai/dsh-client-runtime/client' @@ -51,13 +52,22 @@ export function toolChatSnapshot( /** * Bind ui-tool's details renderer to the conversation slot callback shape. * @param t - conversation locale seat used by Tool cards. + * @param description - optional Host description so the details card can abbreviate home paths. * @returns a direct-test renderSlot implementation. */ -export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotProps['renderSlot'] { +export function renderToolDetails( + t: TranslateNS<'conversation'>, + description?: HostDescription, +): DetailsSlotProps['renderSlot'] { return (_key, owner) => { // PropsRenderSlots keeps its key generic even for this one-key share; // recover the concrete owner selected by the adapter's fixed slot. const details = owner as unknown as DetailsToolOwnerProps - return + return selector(description)} + t={t} + /> } } diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index 54808635da..2d1b289b9d 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -5,7 +5,6 @@ import { cleanup, fireEvent, render } from '@testing-library/react' import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { resolveWorkspacePath } from '@deepseek-ai/dsh-client-runtime/client' import { classifyTool, resultText, toolRowModel } from '../src/client/tool/models/tool-call-model.ts' import { ToolRow } from '../src/client/tool/components/ToolRow.tsx' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' @@ -127,13 +126,6 @@ describe('tool-call-model', () => { expect(toolRowModel('bash', running()).filePath).toBeUndefined() }) - it('resolveWorkspacePath joins relative paths under cwd and passes absolute through', () => { - expect(resolveWorkspacePath('/w', 'src/a.ts')).toBe('/w/src/a.ts') - expect(resolveWorkspacePath('/w/', '/abs/a.ts')).toBe('/abs/a.ts') - expect(resolveWorkspacePath(undefined, 'src/a.ts')).toBe('src/a.ts') - expect(resolveWorkspacePath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts') - }) - it('displays workspace-rooted paths relative to the session cwd', () => { const cwd = '/Users/u/ws/' expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts') @@ -144,6 +136,32 @@ describe('tool-call-model', () => { expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md') }) + it('abbreviates leftover POSIX home paths after cwd relativization', () => { + const home = '/Users/u' + const cwd = '/tmp/ws' + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u"}' }), cwd, home).summary).toBe('~') + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/notes.md"}' }), cwd, home).summary) + .toBe('~/notes.md') + // Workspace-relative wins: a home-and-cwd descendant stays short, not `~/…`. + expect(toolRowModel( + 'read', + running({ name: 'read', argsRaw: '{"path":"/Users/u/proj/src/a.ts"}' }), + '/Users/u/proj', + home, + ).summary).toBe('src/a.ts') + // Prefix boundary: `/Users/u2` is not under `/Users/u`. + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u2/a.ts"}' }), cwd, home).summary) + .toBe('/Users/u2/a.ts') + expect(toolRowModel( + 'read', + running({ name: 'read', argsRaw: '{"path":"C:\\\\Users\\\\u\\\\a.ts"}' }), + cwd, + home, + ).summary).toBe('C:\\Users\\u\\a.ts') + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/a.ts"}' }), cwd).summary) + .toBe('/Users/u/a.ts') + }) + it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => { expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}') expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw') diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index 09ec2f0d3f..bdfba88345 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -64,7 +64,11 @@ const LAYOUT_CHILDREN = { */ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { + api: { settings: {} }, + isLoopback: false, + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + }) // ui-theme's Appearance row binds a durable scope through these two. runtime.provide('remote', { $on: () => () => {} }) runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) @@ -203,7 +207,11 @@ describe('keyed toolview hole through the real machinery', () => { describe('registrant declaration injection', () => { it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { + api: { settings: {} }, + isLoopback: false, + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + }) // ui-theme's Appearance row binds a durable scope through these two. runtime.provide('remote', { $on: () => () => {} }) runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-tool/tsconfig.json b/packages/client/ui-tool/tsconfig.json index 2bd46fec7b..1982d6eee1 100644 --- a/packages/client/ui-tool/tsconfig.json +++ b/packages/client/ui-tool/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../runtime" }, diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index c3a40c880f..e14d3ced34 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.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 packages/client/ui-trajectory/README.md -README.md: 9ad42a3d3bcfb8fbf4842a66577e65794a716dfd -README.zh.md: df7661ef7d4861dbc6c8e365a18b3e3252d4d4af +README.md: 13ccc97ae3cefbcddfb4f31cb290a3da314cc86e +README.zh.md: 1f4f1393e296808d75ba7f092ee6caa4035dda2e diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 9ad42a3d3b..13ccc97ae3 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index df7661ef7d..1f4f1393e2 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括持久化的取消定稿前缀、只能从分片恢复的打断前缀和被打断的工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。 ## 模型体验 diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts index 3614d4e0d7..0013315f96 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -223,6 +223,7 @@ function finalNode( firstTokenTime: state.firstTokenTime ?? null, completedTime: event.time, }, + ...(event.data.interrupted === true ? { interrupted: true } : {}), } } const boundary = closedBoundary(context) @@ -264,7 +265,7 @@ function assistantRequest( ...(state.retry.maxRetries === undefined ? {} : { maxRetries: state.retry.maxRetries }), retryDelayMs: state.retry.delayMs, }), - ...(node === undefined || node.interrupted === true + ...(node?.messageId === undefined ? {} : { resultSeq: node.seq, diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts index e6795a25d2..9b095cae7f 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts @@ -149,6 +149,38 @@ describe('Trajectory conversation Definitions', () => { }]) }) + it('classifies a cancellation-finalized prefix as an interrupted request result', () => { + const current = snapshot(assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('interrupted-message', 'cut short'), + interrupted: true, + }), + at(4, 'step/end', { turn: 1, step: 1 }), + at(5, 'turn/end', { + turn: 1, + reason: { kind: 'aborted', reason: { kind: 'user' } }, + }), + ])) + + expect(current.eventNodes).toMatchObject([{ + kind: 'assistant', + seq: 3, + messageId: 'interrupted-message', + interrupted: true, + blocks: [{ kind: 'text', text: 'cut short' }], + }]) + expect(current.requests).toMatchObject([{ + purpose: 'assistant', + resultSeq: 3, + status: 'error', + provenance: { provider: 'test', model: 'test' }, + }]) + }) + it('keeps parallel interrupted roots and nests Code Dispatch results', () => { const current = snapshot(assembler([ at(1, 'turn/start', { turn: 1 }), diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 6d5eff023b..85ddec73ed 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.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 packages/client/ui-workspace/README.md -README.md: 8878aa49dcccd60ddcde5f0a9563bbfbd969c9c0 -README.zh.md: c83f1c53d5471dd8d52b933163a573eb98a70c2e +README.md: 041c886ae3f38415ac01fbfc3546a1f32581257c +README.zh.md: 96d9bc33b8ed47372a0e01c4992dfcef59f2432f diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 8878aa49dc..041c886ae3 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -6,9 +6,9 @@ Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. Creating a Session from a Workspace row first opens that group so the new row remains visible when the Session state arrives. Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. View options combine grouping with one browser-persisted Session order per account: real Workspaces initialize from `WorkspaceView.sessionIds`, while Ungrouped and the cross-Workspace flat list initialize from recency. **Manual** and **Last updated** apply in either presentation. Entering Last updated performs a complete recency sort and later user prompts or steers promote their Session once, while entering Manual preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags for real Workspaces also update the Host Session account, while Ungrouped and flat-list orders remain browser-local because neither has one Workspace account. Flat rows omit the empty leading status slot because they have no parent hierarchy, but retain it when a Session status is visible. Workspace drag order is Host-durable in either Session order mode. -Collapsed search is one header action beside the view and add actions. In the rail, add and search render as 36px controls on the shell's shared horizontal entry path. Activating search expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. +Collapsed search is one header action beside the view and add actions. In the rail, add and search render as 36px controls on the shell's shared horizontal entry path. Activating search expands the field across the header; an outside click collapses only a query that is empty after trimming — except while the rail search gesture is still in flight (until focus lands in the input after the column slide), so the expanding click cannot dismiss the search it opened — while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail shows a POSIX home or descendant as `~` / `~/…` and leaves a Windows path verbatim. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. The card reports the dictionary-driven copied state only after the browser accepts the clipboard write. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index c83f1c53d5..96d9bc33b8 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -6,9 +6,9 @@ 该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。从 Workspace 行创建 Session 时会先打开该分组,使 Session 状态到达后新行保持可见。Workspace 列表基线就绪后,浏览器持久化的展开状态与 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。视图选项把分组方式和每个记账各自的一份浏览器持久化 Session 顺序放在一起:真实 Workspace 从 `WorkspaceView.sessionIds` 初始化,Ungrouped 和跨 Workspace 的单列表则从最近更新时间顺序初始化。**手动排序**和**最近更新**在两种呈现方式下都可用。进入最近更新时会执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入手动排序则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序;真实 Workspace 在手动模式下的拖拽还会更新 Host Session 记账,而 Ungrouped 和单列表因没有单一 Workspace 记账,其顺序始终只保存在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;Session 存在可见状态时仍保留该槽。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。 -折叠搜索是视图和添加操作旁的一枚区头按钮。在轨道中,添加和搜索会渲染为沿外壳共用横向进入路径移动的 36px 控件。激活搜索后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 +折叠搜索是视图和添加操作旁的一枚区头按钮。在轨道中,添加和搜索会渲染为沿外壳共用横向进入路径移动的 36px 控件。激活搜索后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询——但轨道搜索手势仍在进行期间(直至列滑动结束、焦点落入输入框)除外,这样触发展开的那次点击不会收起它刚打开的搜索——而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情把 POSIX 家目录及其后代显示为 `~`/`~/…`,Windows 路径保持原样。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。 Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 8b3456c908..6039350723 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", @@ -49,6 +50,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -57,6 +59,7 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index d0f6835472..9681f676ac 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -218,6 +218,8 @@ type SessionTreeProps = Pick< 'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't' > & { + /** Host account home for POSIX hover-path abbreviation. */ + home?: string | undefined workspaces: readonly WorkspaceView[] /** Explicit persisted zero-or-five-session state by Workspace group. */ groupExpansion: Readonly> @@ -251,7 +253,7 @@ function SessionTree({ onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertWorkspaceBefore, insertSessionBefore, orderBy, groupExpansion, setGroupExpanded, - sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t, + sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current @@ -450,6 +452,7 @@ function SessionTree({ > { if (group.expanded) { @@ -758,9 +761,11 @@ export function WorkspaceBrowser({ searchSessions, searchResultLimit, useDirectoryFlow, + useHostDescription, renderSlot, t, }: WorkspaceBrowserProps) { + const home = useHostDescription(description => description?.home) const workspaces = useWorkspaces(state => state.items) const workspacePhase = useWorkspaces(state => state.phase) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) @@ -817,8 +822,13 @@ export function WorkspaceBrowser({ searchInput.current?.focus({ preventScroll: true }) }, [wide, searchExpanded, searchOnExpand]) + // Outside-click dismissal stays off while the rail gesture is in flight + // (searchOnExpand): the rail click flips the shell wide and mounts this + // listener during its own dispatch, then keeps bubbling to document with + // the now-unmounted rail button as its target — outside searchRoot, so the + // listener would dismiss the search that click just opened. useEffect(() => { - if (!wide || !searchExpanded) return + if (!wide || !searchExpanded || searchOnExpand) return const onClick = (event: MouseEvent): void => { if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return searchInput.current?.blur() @@ -827,7 +837,7 @@ export function WorkspaceBrowser({ } document.addEventListener('click', onClick) return () => { document.removeEventListener('click', onClick) } - }, [normalizedQuery, wide, searchExpanded]) + }, [normalizedQuery, wide, searchExpanded, searchOnExpand]) useEffect(() => { if (normalizedQuery === '') { @@ -1152,6 +1162,7 @@ export function WorkspaceBrowser({ insertWorkspaceBefore={insertWorkspaceBefore} insertSessionBefore={insertSessionBefore} orderBy={orderBy} + home={home} t={t} onRenameRequest={(workspaceId, currentTitle) => { setRenameTarget({ workspaceId, currentTitle }) diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 8027a3623a..bdd85052a0 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -22,7 +22,8 @@ * and a hole has exactly one declaring entry — they carry the same owner * contract and the same occupant. */ -import type { HostObservable, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pull the owner SlotMap merges into programs that resolve the // runtime shares below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' @@ -79,17 +80,18 @@ export type DirectoryPickingInjected = { } /** Component-side view of the picking share: the bound occupancy selector hook. */ -export type DirectoryPickingHooks = { - /** Selector hook over this surface's directory-flow occupancy. */ - useDirectoryFlow: SnapshotSelectorHook -} +export type DirectoryPickingHooks = PropsHooks /** * Browser-private injected share (arrives via the register inject factory). * Data reads use the global framework hooks; these are the Host actions the * browsing region drives. */ -export type WorkspaceBrowserInjected = DirectoryPickingInjected & { +export type WorkspaceBrowserInjected = { + hooks: DirectoryPickingInjected['hooks'] & { + /** Current generation's Host description, bound by the slot renderer. */ + hostDescription: HostDescriptionSource + } /** * Start a New Session in a Workspace: reuse-or-create its blank session and * open it; without an explicit workspace, inherit the current Session @@ -143,7 +145,7 @@ export type WorkspaceBrowserProps = & PropsRenderSlots<'sidebar.workspaces.directoryFlow'> & PropsStore> & Omit - & DirectoryPickingHooks + & PropsHooks & PropsLocale<'workspace'> /** diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 6b14243ecf..93269c22c1 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -8,6 +8,7 @@ * client half (see the contract module doc). Export discipline: * packages/client/AGENTS.md. */ +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -42,7 +43,7 @@ const NS = 'workspace' * provides a waitable service. apply therefore depends on each slot * declaration through `slots.inject()` instead of assuming order. */ -export const inject = ['slots', 'sessions', 'workspaces', 'locale'] +export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection'] /** * Register the browser and picker once their slot declarations are on the @@ -51,6 +52,8 @@ export const inject = ['slots', 'sessions', 'workspaces', 'locale'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + const connection = ctx.get('connection') as ConnectionHandle + const hostDescription = connection.hostDescription ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') const searchSessions: WorkspaceBrowserInjected['searchSessions'] = async (query, signal) => { @@ -99,7 +102,7 @@ export function apply(ctx: ClientContext): void { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => ctx.workspaces.create(input), - hooks: { directoryFlow: browserFlowSource }, + hooks: { directoryFlow: browserFlowSource, hostDescription }, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => ctx.workspaces.create(input), diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 481e0f0e47..d2d121fe4d 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -13,6 +13,7 @@ import { IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' +import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspaceBrowserProps } from '../contract/slots.ts' import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts' import { relativeTime } from '../tree.ts' @@ -50,7 +51,7 @@ function createdLabel(createdAt: number, t: RowTranslate): string { return t('hover.created', { time: `${date} ${pad2(d.getHours())}:${pad2(d.getMinutes())}` }) } -/** Hover-card body: workspace title, full directory path, absolute creation time. */ +/** Hover-card body: workspace title, display directory path, absolute creation time. */ function WorkspaceHoverContent({ label, cwd, createdAt, t }: { label: string cwd: string | undefined @@ -104,10 +105,11 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | * @param props.onToggle - expand/collapse the group. * @param props.onCreate - start a frontend Session inside this Workspace. * @param props.drag - optional workspace-row drag wiring. + * @param props.home - host account home for POSIX hover-path abbreviation. * @param props.t - the browser root's locale seat. * @returns the row element. */ -export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: { +export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, home, t }: { group: GroupNode onToggle: () => void onCreate: () => void @@ -115,6 +117,8 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: actions?: { rename: () => void; delete: () => void } | undefined /** Present only for real Workspace rows in the grouped view. */ drag?: WorkspaceRowDragProps | undefined + /** Host account home; POSIX home-rooted hover paths display as `~`. */ + home?: string | undefined t: RowTranslate }) { const row = group @@ -196,7 +200,12 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: return ( } + content={} disabled={menuOpen} copyText={row.cwd} copyLabel={t('copy')} diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index abba4371c2..3ad62e289f 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -31,6 +31,9 @@ async function bench() { create, startSession, rename, insertSessionBefore, } as never) ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never) + ctx.provide('connection', { + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + } as never) const locale = new LocaleRuntime(ctx) // These specs assert the shipped Chinese copy. There is no jsdom `window` // in this lane, so browser-language detection never runs and the locale @@ -53,7 +56,7 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void { describe('ui-workspace apply', () => { it('declares the services it drives', () => { - expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale']) + expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale', 'connection']) }) it('registers browser and pickers for declarations arriving before or after apply', async () => { @@ -125,6 +128,7 @@ describe('ui-workspace apply', () => { const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) + expect(browser.hooks.hostDescription.getSnapshot()).toBeUndefined() expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) // A flow occupant flips exactly its own surface, and the source notifies. const notified = vi.fn() diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index e61c997de1..c2e201469f 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -31,6 +31,9 @@ beforeEach(() => { localStorage.clear() }) /** Runtime with the locale face installed (the browser entry declares `locale:` — zh default backs the t seat). */ async function createRuntime(): Promise { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + }) const locale = new LocaleRuntime(runtime.ctx) runtime.provide('locale', locale) runtime.slots.installLocale(locale) diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index c7a153ff5f..f86e275c5e 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -304,6 +304,62 @@ describe('workspace browser rows', () => { } }) + it('workspace hover card shows a POSIX home descendant as ~ and still copies the full path', async () => { + vi.useFakeTimers() + const writeText = vi.fn(async () => {}) + const restoreClipboard = installClipboard(writeText) + try { + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: '/home/u/Documents/project', createdAt: 0, label: 'Project', + sessionCount: 0, expanded: false, containsCurrent: false, sessions: [], + } + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('~/Documents/project')).toBeTruthy() + expect(screen.queryByText('/home/u/Documents/project')).toBeNull() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /home/u/Documents/project' })) }) + expect(writeText).toHaveBeenCalledWith('/home/u/Documents/project') + } finally { + restoreClipboard() + vi.useRealTimers() + } + }) + + it('workspace hover card without a directory omits the path and copy action', async () => { + vi.useFakeTimers() + try { + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: undefined, createdAt: 0, label: 'Project', + sessionCount: 0, expanded: false, containsCurrent: false, sessions: [], + } + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getAllByText('Project')).toHaveLength(2) + expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy() + expect(screen.queryByRole('button', { name: /^复制:/ })).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('workspace hover card leaves a Windows path verbatim', async () => { + vi.useFakeTimers() + try { + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: 'C:\\Users\\u\\project', createdAt: 0, label: 'Project', + sessionCount: 0, expanded: false, containsCurrent: false, sessions: [], + } + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('C:\\Users\\u\\project')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + it('ungrouped bucket renders no workspace menu', () => { const group: GroupNode = { key: '', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: 'Ungrouped', diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 06e50419d9..9b5875d8cd 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -80,6 +80,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), + useHostDescription: selector => selector(undefined), renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, t, ...overrides, @@ -95,6 +96,27 @@ function rerender(b: ReturnType, overrides: Partial { + it('workspace hover card shows a POSIX home descendant as ~', () => { + vi.useFakeTimers() + try { + mount({ + useWorkspaces: hook(workspaceState([{ + ...workspace('project', []), + path: '/home/u/Documents/project', + title: 'Project', + }])), + useHostDescription: selector => selector({ + version: '0', cwd: '/tmp', attachedSessions: 0, home: '/home/u', canOpenPath: false, + }), + }) + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('~/Documents/project')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + it('prunes deleted Workspace view state only after the Workspace baseline is ready', async () => { const pending = { ...workspaceState([]), @@ -702,6 +724,28 @@ describe('WorkspaceBrowser', () => { } }) + it('keeps the rail-opened search expanded when the initiating click reaches document', () => { + vi.useFakeTimers() + try { + const b = mount({ wide: false }) + fireEvent.click(screen.getByRole('button', { name: '搜索会话' })) + rerender(b, { wide: true }) + // In the browser the rail click keeps bubbling to document after the + // wide flip mounted the outside-click listener, with the unmounted rail + // button as its target — outside searchRoot. It must not dismiss the + // search it just opened. + fireEvent.click(document.body) + expect(screen.getByRole('button', { name: '搜索会话' }).getAttribute('aria-expanded')).toBe('true') + act(() => { vi.advanceTimersByTime(300) }) + expect(document.activeElement).toBe(screen.getByPlaceholderText('搜索会话…')) + // The gesture has settled: outside clicks dismiss the search again. + fireEvent.click(document.body) + expect(screen.getByRole('button', { name: '搜索会话' }).getAttribute('aria-expanded')).toBe('false') + } finally { + vi.useRealTimers() + } + }) + it('rail add-workspace raises the directory flow in place, with no menu and no expansion', () => { const expandSidebar = vi.fn() mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) }) diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 32e8aa89ec..e419270fb8 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../ui-primitives" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../runtime" }, diff --git a/packages/context/README.i18n.yaml b/packages/context/README.i18n.yaml index 349b7f3784..fbfd8dfa06 100644 --- a/packages/context/README.i18n.yaml +++ b/packages/context/README.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 packages/context/README.md -README.md: fa28751e548dc4aec8e2e2711508816729f5d407 -README.zh.md: 9d12c7a854c6cb008c3806ea246ec5b5667be941 +README.md: ac1f6c302ce557adb71974c4d80b3b61e27f049c +README.zh.md: 98cc4ab9b3a3f84e9f8dfff0a91025ee697ba240 diff --git a/packages/context/README.md b/packages/context/README.md index fa28751e54..ac1f6c302c 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -2,11 +2,13 @@ English | [中文](README.zh.md) -Product plugins that add model-visible request context without defining a tool. `agent-instructions` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context`, `tmux-context`, and `session-reference` are opt-in. +Product plugins that add model-visible request context without defining a tool. `agent-instructions` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context`, `tmux-context`, `session-reference`, `file-reference`, and `file-reference-local` are opt-in. | Package | Role | ctx key | |---|---|---| | [`session-reference/`](session-reference/README.md) | Bounded snapshots of other sessions | `ctx.sessionReferenceResolver` | +| [`file-reference/`](file-reference/README.md) | File-reference discovery seam and `@file` grammar | `ctx.fileReferences` | +| [`file-reference-local/`](file-reference-local/README.md) | Local-filesystem file-reference provider | — | | [`time-context/`](time-context/README.md) | Current-time and elapsed-time context | — | | [`tmux-context/`](tmux-context/README.md) | tmux location context | — | | [`agent-instructions/`](agent-instructions/README.md) | Workspace-instruction context | — | diff --git a/packages/context/README.zh.md b/packages/context/README.zh.md index 9d12c7a854..98cc4ab9b3 100644 --- a/packages/context/README.zh.md +++ b/packages/context/README.zh.md @@ -2,11 +2,13 @@ [English](README.md) | 中文 -在不定义工具的情况下添加模型可见的请求上下文的产品插件。`agent-instructions` 包含在默认 `dsh-agent-spine-demo` 组合包中,可通过组合包配置禁用;`time-context`、`tmux-context` 和 `session-reference` 需主动启用。 +在不定义工具的情况下添加模型可见的请求上下文的产品插件。`agent-instructions` 包含在默认 `dsh-agent-spine-demo` 组合包中,可通过组合包配置禁用;`time-context`、`tmux-context`、`session-reference`、`file-reference` 和 `file-reference-local` 需主动启用。 | 包 | 职责 | ctx key | |---|---|---| | [`session-reference/`](session-reference/README.md) | 其他会话的有界快照 | `ctx.sessionReferenceResolver` | +| [`file-reference/`](file-reference/README.md) | 文件引用发现 seam 与 `@file` 语法 | `ctx.fileReferences` | +| [`file-reference-local/`](file-reference-local/README.md) | 本地文件系统文件引用提供方 | — | | [`time-context/`](time-context/README.md) | 当前时间与耗时上下文 | — | | [`tmux-context/`](tmux-context/README.md) | tmux 位置上下文 | — | | [`agent-instructions/`](agent-instructions/README.md) | 工作区指令上下文 | — | diff --git a/packages/context/file-reference-local/README.i18n.yaml b/packages/context/file-reference-local/README.i18n.yaml new file mode 100644 index 0000000000..475baf10db --- /dev/null +++ b/packages/context/file-reference-local/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/context/file-reference-local/README.md +README.md: 67b07eef4b59fdcc5e21104cac4628feb1c15f4e +README.zh.md: beded13250daf041294e4e2656d0e1374407ff94 diff --git a/packages/context/file-reference-local/README.md b/packages/context/file-reference-local/README.md new file mode 100644 index 0000000000..67b07eef4b --- /dev/null +++ b/packages/context/file-reference-local/README.md @@ -0,0 +1,45 @@ +# `@deepseek-ai/dsh-file-reference-local` + +English | [中文](README.zh.md) + +Local-filesystem implementation of `ctx.fileReferences`. It maintains one bounded `WorkspaceFileSearch` per agent, rooted at that session's `cwd` and falling back to the host process cwd. The index ranks direct directory listings for queries containing `/`, otherwise fuzzy-ranks a bounded recursive index; it never follows directory symlinks. + +Tool-result events invalidate the addressed agent's reusable index so later completion observes likely workspace mutations. Agent disposal releases that index and its scoped prompt contribution; plugin disposal awaits every prompt fiber and releases all cached searches. + +## Configuration + +| Key | Default | Contract | +|---|---:|---| +| `maxResults` | `20` | Maximum ranked candidates returned for one query. | +| `maxEntries` | `10000` | Maximum files and directories indexed per agent workspace. | +| `excludedDirectories` | `[".git", "node_modules"]` | Directory basenames omitted from traversal and candidates. | + +Every numeric value must be a positive safe integer. Excluded names must be non-empty basenames without `/` or `\`. + +## Model Experience + +### File-reference guidance when `read` is available + +#### What the model sees + +When the addressed agent has an effective `read` tool, the provider contributes this stable system-prompt section: + +##### File-reference instruction + +```markdown +Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it. +``` + +#### Token effect + +Conditional and fixed: the one sentence is present while `read` is visible to the addressed agent; candidate lookup itself adds no tokens, and a selected path contributes only its ordinary user-message characters. + +#### KV Cache effect + +The stable sentence joins the system-prompt prefix. Mounting or removing this provider, or changing whether `read` is visible, changes that prefix; queries, candidates, and index invalidations do not. + +## Known Limitations and Deferred Work + +- **Host-local namespace** — the provider scans the Harness host filesystem, so remote or virtual `read` implementations require a provider whose namespace matches the tool. +- **Bounded advisory index** — very large workspaces may omit paths after `maxEntries`, and excluded or unreadable directories do not appear. +- **No ignore-file semantics** — `.gitignore` and other project ignore files do not influence discovery; only configured directory basenames are excluded. diff --git a/packages/context/file-reference-local/README.zh.md b/packages/context/file-reference-local/README.zh.md new file mode 100644 index 0000000000..beded13250 --- /dev/null +++ b/packages/context/file-reference-local/README.zh.md @@ -0,0 +1,45 @@ +# `@deepseek-ai/dsh-file-reference-local` + +[English](README.md) | 中文 + +`ctx.fileReferences` 的本地文件系统实现。它为每个 agent(智能体)维护一个有界的 `WorkspaceFileSearch`,以该会话的 `cwd` 为根目录;缺少该值时回退到宿主进程的 cwd。查询包含 `/` 时,索引会对直接列出的目录项排序;否则会对有界递归索引进行模糊排序。索引永远不会跟随目录符号链接。 + +工具结果事件会使指定 agent 的可复用索引失效,使后续补全能够反映工作区中可能发生的变更。agent 的 dispose(资源释放)会释放该索引及其作用域内的提示词贡献;插件 dispose 会等待所有提示词 fiber,并释放全部缓存的搜索器。 + +## 配置 + +| 配置键 | 默认值 | 契约 | +|---|---:|---| +| `maxResults` | `20` | 单次查询返回的候选项最大数量。 | +| `maxEntries` | `10000` | 每个 agent 工作区建立索引的文件和目录最大数量。 | +| `excludedDirectories` | `[".git", "node_modules"]` | 遍历和候选项中排除的目录基名。 | + +所有数值都必须是正的安全整数。排除名称必须是非空基名,且不能包含 `/` 或 `\`。 + +## 模型体验 + +### `read` 可用时的文件引用指引 + +#### 模型看到什么 + +当指定 agent 有实际生效的 `read` 工具时,提供方会贡献以下稳定的系统提示词段: + +##### 文件引用指令 + +```markdown +Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it. +``` + +#### Token 影响 + +该影响有条件且固定:只要 `read` 对指定 agent 可见,这一句就会存在;候选查询本身不增加 token,所选路径只会贡献普通用户消息中的对应字符。 + +#### KV 缓存影响 + +该稳定句子会加入系统提示词前缀。挂载或移除此提供方,或者改变 `read` 是否可见,都会改变该前缀;查询、候选项和索引失效不会改变前缀。 + +## 已知限制与暂缓事项 + +- **宿主本地命名空间**:提供方扫描 Harness 宿主的文件系统,因此远程或虚拟 `read` 实现需要使用命名空间与该工具一致的提供方。 +- **有界的提示性索引**:超大型工作区可能省略 `maxEntries` 之后的路径;被排除或无法读取的目录不会出现。 +- **没有忽略文件语义**:`.gitignore` 和其他项目忽略文件不会影响发现;系统只排除已配置的目录基名。 diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json new file mode 100644 index 0000000000..e9bd72702d --- /dev/null +++ b/packages/context/file-reference-local/package.json @@ -0,0 +1,58 @@ +{ + "name": "@deepseek-ai/dsh-file-reference-local", + "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", + "version": "0.1.0-rc.7", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/context/file-reference-local" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./search": { + "types": "./lib/types/search.d.ts", + "default": "./lib/types/search.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/context/file-reference-local/src/index.ts b/packages/context/file-reference-local/src/index.ts new file mode 100644 index 0000000000..95d1cfec2d --- /dev/null +++ b/packages/context/file-reference-local/src/index.ts @@ -0,0 +1,140 @@ +/** + * Local-filesystem implementation of `ctx.fileReferences`. + * + * @module @deepseek-ai/dsh-file-reference-local + */ + +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import FileReferenceService, { + FILE_REFERENCE_PROMPT, + type FileReferenceCandidate, +} from '@deepseek-ai/dsh-file-reference' +import type {} from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-tools' +import { + DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + DEFAULT_FILE_SEARCH_MAX_ENTRIES, + DEFAULT_FILE_SEARCH_MAX_RESULTS, + WorkspaceFileSearch, + type FileSearchConfig, +} from './search.ts' + +export { + DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + DEFAULT_FILE_SEARCH_MAX_ENTRIES, + DEFAULT_FILE_SEARCH_MAX_RESULTS, + WorkspaceFileSearch, +} from './search.ts' +export type { FileSearchConfig } from './search.ts' +export { FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-file-reference' +export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' + +/** Local file-reference discovery configuration. */ +export interface Config { + /** Maximum ranked candidates returned for one query. */ + maxResults?: number + /** Maximum indexed files and directories per agent workspace. */ + maxEntries?: number + /** Directory basenames never traversed or offered. */ + excludedDirectories?: string[] +} + +/** Local-filesystem owner of the file-reference discovery service. */ +export class LocalFileReferenceService extends FileReferenceService { + static inject = ['agents'] + static Config: z = z.object({ + maxResults: z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS), + maxEntries: z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES), + excludedDirectories: z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]), + }) + + private readonly config: FileSearchConfig + private readonly searches = new Map() + private readonly promptFibers = new Map>() + private readonly promptDisposals = new Set>() + + constructor(ctx: Context, config: Config = {}) { + super(ctx) + this.config = { + maxResults: config.maxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, + maxEntries: config.maxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, + excludedDirectories: config.excludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + } + validateConfig(this.config) + + const installPrompt = (agent: Agent): void => { + if (this.promptFibers.has(agent)) return + const fiber = agent.ctx.inject(['systemPrompt', 'tools'], (scope) => { + scope.systemPrompt.section({ + name: 'context:file-reference', + order: 99, + text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT, + }) + }) + this.promptFibers.set(agent, fiber) + } + const disposePrompt = (agent: Agent): void => { + const fiber = this.promptFibers.get(agent) + if (fiber === undefined) return + this.promptFibers.delete(agent) + const task = fiber.dispose().catch((error: unknown) => { + ctx.logger.warn(`file-reference-local: prompt cleanup failed: ${error instanceof Error ? error.message : String(error)}`) + }) + this.promptDisposals.add(task) + void task.finally(() => { + this.promptDisposals.delete(task) + }) + } + for (const agent of ctx.agents.list()) installPrompt(agent) + ctx.on('agent/created', ({ agent }) => { installPrompt(agent) }) + ctx.on('agent/disposed', ({ agent }) => { + this.searches.get(agent)?.dispose() + this.searches.delete(agent) + disposePrompt(agent) + }) + ctx.on('session/event', (session, event) => { + if (event.type !== 'tool/result') return + const agent = ctx.agents.get(session.id) + if (agent !== undefined) this.searches.get(agent)?.invalidate() + }) + ctx.effect(() => async () => { + for (const search of this.searches.values()) search.dispose() + this.searches.clear() + const promptFibers = [...this.promptFibers.values()] + this.promptFibers.clear() + await Promise.all([ + ...promptFibers.map(fiber => fiber.dispose()), + ...this.promptDisposals, + ]) + }, 'file-reference-local: search cache') + } + + override list( + agent: Agent, + query: string, + signal: AbortSignal, + ): Promise { + let search = this.searches.get(agent) + if (search === undefined) { + search = new WorkspaceFileSearch(agent.session.header.cwd ?? process.cwd(), this.config) + this.searches.set(agent, search) + } + return search.list(query, signal) + } +} + +function validateConfig(config: FileSearchConfig): void { + if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) { + throw new Error('file-reference-local: maxResults must be a positive safe integer') + } + if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) { + throw new Error('file-reference-local: maxEntries must be a positive safe integer') + } + if (config.excludedDirectories.some(name => name.length === 0 || name.includes('/') || name.includes('\\'))) { + throw new Error('file-reference-local: excludedDirectories entries must be non-empty directory basenames') + } +} + +export default LocalFileReferenceService diff --git a/packages/context/file-reference-local/src/invariant.ts b/packages/context/file-reference-local/src/invariant.ts new file mode 100644 index 0000000000..168f261294 --- /dev/null +++ b/packages/context/file-reference-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-file-reference-local`. + * @module @deepseek-ai/dsh-file-reference-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-file-reference-local' + +/** Cordis companion plugin name. */ +export const name = 'file-reference-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: per-agent indexes are private advisory caches whose + * invalidation and disposal are observed directly through service tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/context/file-reference-local/src/search.ts b/packages/context/file-reference-local/src/search.ts new file mode 100644 index 0000000000..a0257a5d33 --- /dev/null +++ b/packages/context/file-reference-local/src/search.ts @@ -0,0 +1,294 @@ +/** + * Host-workspace discovery for `@file` completion. The index contains paths + * only: selected values remain ordinary prompt text and file contents stay + * behind the model-facing `read` tool. + * + * @module @deepseek-ai/dsh-file-reference-local/search + */ + +import { lstat, readdir } from 'node:fs/promises' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference' + +export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' + +/** Default maximum file and directory candidates rendered for one query. */ +export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20 +/** Default maximum entries retained in one workspace search index. */ +export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 10_000 +/** Directory basenames omitted from traversal unless the deployment overrides them. */ +export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = ['.git', 'node_modules'] as const + +/** Resolved limits and exclusions for one workspace index. */ +export interface FileSearchConfig { + /** Maximum ranked candidates returned for one query. */ + maxResults: number + /** Maximum indexed files and directories. */ + maxEntries: number + /** Directory basenames never traversed or offered. */ + excludedDirectories: readonly string[] +} + +interface IndexedPath extends FileReferenceCandidate {} + +interface RankedPath { + candidate: FileReferenceCandidate + score: number +} + +interface IndexGeneration { + controller: AbortController + promise: Promise +} + +/** + * Cancellable, reusable fuzzy index rooted at one agent working directory. + * Directory-scoped queries list live state; bare fuzzy queries share one + * bounded traversal until the `@` interaction ends or a tool result invalidates it. + */ +export class WorkspaceFileSearch { + private readonly excludedDirectories: ReadonlySet + private generation: IndexGeneration | undefined + private disposed = false + + constructor( + private readonly root: string, + private readonly config: FileSearchConfig, + ) { + if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) { + throw new Error('file search maxResults must be a positive safe integer') + } + if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) { + throw new Error('file search maxEntries must be a positive safe integer') + } + if (config.excludedDirectories.some(name => name.length === 0 || name.includes('/') || name.includes('\\'))) { + throw new Error('file search excludedDirectories entries must be non-empty directory basenames') + } + this.excludedDirectories = new Set(config.excludedDirectories) + } + + /** + * Return ranked path candidates for the current token. + * @param rawQuery - path text following `@` or `@"`. + * @param signal - cancels this caller's wait without killing an index shared by a newer query. + * @returns at most `maxResults` deterministic candidates. + */ + async list(rawQuery: string, signal: AbortSignal): Promise { + signal.throwIfAborted() + if (this.disposed) return [] + const query = rawQuery.replaceAll('\\', '/') + const slash = query.lastIndexOf('/') + if (query === '' || slash >= 0) { + const directory = slash < 0 ? '' : query.slice(0, slash + 1) + const fragment = slash < 0 ? '' : query.slice(slash + 1) + return this.listDirectory(directory, fragment, signal) + } + const indexed = await waitForPromise(this.ensureIndex(), signal) + return rankCandidates( + indexed.filter(candidate => visibleForGlobalQuery(candidate.path, query)), + query, + this.config.maxResults, + ) + } + + /** Discard the current index so the next bare query observes a fresh tree. */ + invalidate(): void { + this.generation?.controller.abort(new Error('file search index invalidated')) + this.generation = undefined + } + + /** Abort traversal and make later queries return no candidates. */ + dispose(): void { + if (this.disposed) return + this.disposed = true + this.invalidate() + } + + private ensureIndex(): Promise { + if (this.generation !== undefined) return this.generation.promise + const controller = new AbortController() + const generation = { + controller, + promise: Promise.resolve([] as IndexedPath[]), + } satisfies IndexGeneration + generation.promise = this.scanWorkspace(controller.signal).catch((error: unknown) => { + /* v8 ignore next -- every owned abort clears `generation` synchronously; this only protects an unexpected scan failure */ + if (this.generation === generation) this.generation = undefined + throw error + }) + this.generation = generation + return generation.promise + } + + private async scanWorkspace(signal: AbortSignal): Promise { + const indexed: IndexedPath[] = [] + const directories: { absolute: string; relative: string }[] = [{ absolute: this.root, relative: '' }] + for (let cursor = 0; cursor < directories.length && indexed.length < this.config.maxEntries; cursor += 1) { + signal.throwIfAborted() + const directory = directories[cursor] + /* v8 ignore next 3 -- cursor is bounded by this exact queue's length. */ + if (directory === undefined) { + throw new Error('file search selected a missing directory') + } + const entries = await readDirectory(directory.absolute, signal) + for (const entry of entries) { + signal.throwIfAborted() + const path = directory.relative === '' ? entry.name : `${directory.relative}/${entry.name}` + if (entry.isDirectory()) { + if (this.excludedDirectories.has(entry.name)) continue + indexed.push({ path, kind: 'directory' }) + directories.push({ absolute: join(directory.absolute, entry.name), relative: path }) + } else if (entry.isFile()) { + indexed.push({ path, kind: 'file' }) + } + if (indexed.length >= this.config.maxEntries) break + } + } + return indexed + } + + private async listDirectory( + displayDirectory: string, + fragment: string, + signal: AbortSignal, + ): Promise { + if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return [] + const absolute = await resolveDisplayDirectory(this.root, displayDirectory, signal) + if (absolute === undefined) return [] + const entries = await readDirectory(absolute, signal) + const candidates: FileReferenceCandidate[] = [] + for (const entry of entries) { + if (entry.name.startsWith('.') && !fragment.startsWith('.')) continue + if (entry.isDirectory()) { + if (this.excludedDirectories.has(entry.name)) continue + candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'directory' }) + } else if (entry.isFile()) { + candidates.push({ path: `${displayDirectory}${entry.name}`, kind: 'file' }) + } + } + return rankCandidates(candidates, fragment, this.config.maxResults) + } +} + +async function resolveDisplayDirectory( + root: string, + displayDirectory: string, + signal: AbortSignal, +): Promise { + const resolvedRoot = resolve(root) + const absolute = resolve(resolvedRoot, displayDirectory === '' ? '.' : displayDirectory) + const fromRoot = relative(resolvedRoot, absolute) + if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`)) return undefined + /* v8 ignore next -- only Windows can produce a cross-volume absolute relative path */ + if (isAbsolute(fromRoot)) return undefined + let current = resolvedRoot + for (const segment of fromRoot.split(sep).filter(Boolean)) { + signal.throwIfAborted() + current = join(current, segment) + try { + const status = await lstat(current) + signal.throwIfAborted() + if (status.isSymbolicLink() || !status.isDirectory()) return undefined + } catch (_error: unknown) { + signal.throwIfAborted() + return undefined + } + } + return absolute +} + +async function readDirectory(absolute: string, signal: AbortSignal) { + signal.throwIfAborted() + try { + const entries = await readdir(absolute, { withFileTypes: true }) + signal.throwIfAborted() + return entries.sort((left, right) => compareText(left.name, right.name)) + } catch (_error: unknown) { + signal.throwIfAborted() + // An unreadable/missing subtree contributes no candidates; other readable + // branches remain useful and autocomplete is advisory. + return [] + } +} + +function visibleForGlobalQuery(path: string, query: string): boolean { + if (query.startsWith('.') || query.includes('/.')) return true + return !path.split('/').some(segment => segment.startsWith('.')) +} + +function rankCandidates( + candidates: readonly FileReferenceCandidate[], + query: string, + limit: number, +): FileReferenceCandidate[] { + const ranked: RankedPath[] = [] + for (const candidate of candidates) { + const score = scoreCandidate(candidate, query) + if (score !== undefined) ranked.push({ candidate, score }) + } + ranked.sort((left, right) => + right.score - left.score + || kindRank(left.candidate.kind) - kindRank(right.candidate.kind) + || (query === '' ? 0 : left.candidate.path.length - right.candidate.path.length) + || compareText(left.candidate.path, right.candidate.path)) + return ranked.slice(0, limit).map(entry => entry.candidate) +} + +function scoreCandidate(candidate: FileReferenceCandidate, query: string): number | undefined { + if (query === '') return 0 + const path = candidate.path.toLowerCase() + const name = path.slice(path.lastIndexOf('/') + 1) + const needle = query.toLowerCase() + const directoryBonus = candidate.kind === 'directory' ? 25 : 0 + if (name === needle) return 1_000 + directoryBonus + if (name.startsWith(needle)) return 900 + directoryBonus + if (name.includes(needle)) return 700 + directoryBonus + if (path.includes(needle)) return 500 + directoryBonus + const subsequence = subsequenceScore(path, needle) + return subsequence === undefined ? undefined : 300 + subsequence + directoryBonus +} + +function subsequenceScore(target: string, query: string): number | undefined { + let targetIndex = 0 + let gap = 0 + for (const character of query) { + const found = target.indexOf(character, targetIndex) + if (found < 0) return undefined + gap += found - targetIndex + targetIndex = found + 1 + } + return Math.max(0, 100 - gap) +} + +function kindRank(kind: FileReferenceCandidate['kind']): number { + return kind === 'directory' ? 0 : 1 +} + +function compareText(left: string, right: string): number { + /* v8 ignore next -- entries and candidates are unique; host enumeration + * order determines which comparison direction sort requests. */ + return left < right ? -1 : left > right ? 1 : 0 +} + +function waitForPromise(promise: Promise, signal: AbortSignal): Promise { + /* v8 ignore next -- `list()` checks this signal immediately before its synchronous call into this helper */ + if (signal.aborted) return Promise.reject(errorReason(signal.reason, 'file search aborted')) + return new Promise((resolvePromise, rejectPromise) => { + const onAbort = (): void => { rejectPromise(errorReason(signal.reason, 'file search aborted')) } + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolvePromise(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + rejectPromise(errorReason(error, 'file search index failed')) + }, + ) + }) +} + +function errorReason(reason: unknown, fallback: string): Error { + return reason instanceof Error ? reason : new Error(fallback, { cause: reason }) +} diff --git a/packages/context/file-reference-local/tests/invariant.spec.ts b/packages/context/file-reference-local/tests/invariant.spec.ts new file mode 100644 index 0000000000..7be7400d60 --- /dev/null +++ b/packages/context/file-reference-local/tests/invariant.spec.ts @@ -0,0 +1,12 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as FileReferenceLocalInvariant from '../src/invariant.ts' + +describe('invariant companion', () => { + it('registers the provider cache ownership under its package name', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(FileReferenceLocalInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/context/file-reference-local/tests/search.spec.ts b/packages/context/file-reference-local/tests/search.spec.ts new file mode 100644 index 0000000000..98bdd8cf66 --- /dev/null +++ b/packages/context/file-reference-local/tests/search.spec.ts @@ -0,0 +1,199 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + activeAtToken, + formatFileMention, + WorkspaceFileSearch, +} from '../src/search.ts' + +const searches: WorkspaceFileSearch[] = [] +const roots: string[] = [] + +async function workspace(): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-')) + roots.push(root) + await mkdir(join(root, 'src'), { recursive: true }) + await mkdir(join(root, 'docs'), { recursive: true }) + await mkdir(join(root, '.hidden'), { recursive: true }) + await mkdir(join(root, 'node_modules', 'ignored-package'), { recursive: true }) + await writeFile(join(root, 'README.md'), 'readme') + await writeFile(join(root, 'src', 'tui.spec.ts'), 'test') + await writeFile(join(root, 'src', 'terminal-view.ts'), 'view') + await writeFile(join(root, 'docs', 'design notes.md'), 'design') + await writeFile(join(root, '.hidden', 'secret.txt'), 'hidden') + await writeFile(join(root, 'node_modules', 'ignored-package', 'index.js'), 'ignored') + try { + await symlink(join(root, 'src', 'tui.spec.ts'), join(root, 'linked-test.ts')) + } catch { + // Windows may deny symlink creation without Developer Mode; the product + // still skips every non-file/non-directory Dirent on platforms that expose one. + } + return root +} + +function search(root: string, overrides: Partial[1]> = {}): WorkspaceFileSearch { + const instance = new WorkspaceFileSearch(root, { + maxResults: overrides.maxResults ?? 20, + maxEntries: overrides.maxEntries ?? 10_000, + excludedDirectories: overrides.excludedDirectories ?? ['.git', 'node_modules'], + }) + searches.push(instance) + return instance +} + +afterEach(async () => { + for (const instance of searches.splice(0)) instance.dispose() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +describe('file-reference grammar', () => { + it('recognizes boundary and quoted mentions without treating emails as references', () => { + expect(activeAtToken('@src/tu', 7)).toEqual({ prefix: '@src/tu', query: 'src/tu', quoted: false }) + expect(activeAtToken('read @"docs/design n', 20)).toEqual({ + prefix: '@"docs/design n', + query: 'docs/design n', + quoted: true, + }) + expect(activeAtToken('mail a@b.test', 13)).toBeUndefined() + expect(activeAtToken('done @src/x" next', 17)).toBeUndefined() + }) + + it('formats files, directories, quotes, and rejects unsafe editor values', () => { + expect(formatFileMention({ path: 'src/index.ts', kind: 'file' }, false)).toBe('@src/index.ts') + expect(formatFileMention({ path: 'src', kind: 'directory' }, false)).toBe('@src/') + expect(formatFileMention({ path: 'docs/design notes.md', kind: 'file' }, false)) + .toBe('@"docs/design notes.md"') + expect(formatFileMention({ path: 'docs/design notes', kind: 'directory' }, false)) + .toBe('@"docs/design notes/') + expect(formatFileMention({ path: 'README.md', kind: 'file' }, true)).toBe('@"README.md"') + expect(formatFileMention({ path: 'bad\nname', kind: 'file' }, false)).toBeUndefined() + expect(formatFileMention({ path: 'bad "name".md', kind: 'file' }, false)).toBeUndefined() + expect(formatFileMention({ path: 'bad"name.md', kind: 'file' }, false)).toBeUndefined() + }) +}) + +describe('WorkspaceFileSearch', () => { + it('lists live directory levels, descends, quotes spaces, and filters hidden/excluded entries', async () => { + const root = await workspace() + const files = search(root) + const signal = new AbortController().signal + + expect(await files.list('', signal)).toEqual([ + { path: 'docs', kind: 'directory' }, + { path: 'src', kind: 'directory' }, + { path: 'README.md', kind: 'file' }, + ]) + expect(await files.list('src/', signal)).toEqual([ + { path: 'src/terminal-view.ts', kind: 'file' }, + { path: 'src/tui.spec.ts', kind: 'file' }, + ]) + expect(await files.list('src/ts', signal)).toEqual([ + { path: 'src/tui.spec.ts', kind: 'file' }, + { path: 'src/terminal-view.ts', kind: 'file' }, + ]) + expect(await files.list('docs/design n', signal)).toEqual([ + { path: 'docs/design notes.md', kind: 'file' }, + ]) + expect(await files.list('node_modules/', signal)).toEqual([]) + expect(await files.list('.hidden/', signal)).toEqual([ + { path: '.hidden/secret.txt', kind: 'file' }, + ]) + const absoluteSrc = `${join(root, 'src').replaceAll('\\', '/')}/` + expect(await files.list(`${absoluteSrc}tui`, signal)).toEqual([ + { path: `${absoluteSrc}tui.spec.ts`, kind: 'file' }, + { path: `${absoluteSrc}terminal-view.ts`, kind: 'file' }, + ]) + expect(await files.list('~/.dsh-file-autocomplete-missing/', signal)).toEqual([]) + expect(await files.list('../', signal)).toEqual([]) + expect(await files.list('README.md/', signal)).toEqual([]) + }) + + it('does not traverse directory symlinks during direct completion', async () => { + const root = await workspace() + const outside = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-outside-')) + roots.push(outside) + await writeFile(join(outside, 'outside-secret.txt'), 'secret') + await symlink( + outside, + join(root, 'escape'), + process.platform === 'win32' ? 'junction' : 'dir', + ) + const files = search(root) + const signal = new AbortController().signal + + expect(await files.list('escape/', signal)).toEqual([]) + expect(await files.list('escape/outside', signal)).toEqual([]) + }) + + it('ranks basename and subsequence fuzzy matches across the bounded workspace index', async () => { + const root = await workspace() + await writeFile(join(root, 'src', 'tspc-helper.ts'), 'helper') + const files = search(root, { maxResults: 2 }) + const signal = new AbortController().signal + + expect(await files.list('tspc', signal)).toEqual([ + { path: 'src/tspc-helper.ts', kind: 'file' }, + { path: 'src/tui.spec.ts', kind: 'file' }, + ]) + expect(await files.list('README.md', signal)).toEqual([ + { path: 'README.md', kind: 'file' }, + ]) + expect(await files.list('terminal', signal)).toEqual([ + { path: 'src/terminal-view.ts', kind: 'file' }, + ]) + expect(await files.list('secret', signal)).toEqual([]) + expect(await files.list('.hidden', signal)).toEqual([ + { path: '.hidden', kind: 'directory' }, + { path: '.hidden/secret.txt', kind: 'file' }, + ]) + }) + + it('invalidates cached traversal, enforces the entry cap, and settles disposal', async () => { + const root = await workspace() + const capped = search(root, { maxEntries: 2 }) + const signal = new AbortController().signal + expect(await capped.list('README', signal)).toEqual([ + { path: 'README.md', kind: 'file' }, + ]) + + const files = search(root) + expect(await files.list('fresh-file', signal)).toEqual([]) + await writeFile(join(root, 'fresh-file.ts'), 'fresh') + expect(await files.list('fresh-file', signal)).toEqual([]) + files.invalidate() + expect(await files.list('fresh-file', signal)).toEqual([ + { path: 'fresh-file.ts', kind: 'file' }, + ]) + files.dispose() + expect(await files.list('fresh-file', signal)).toEqual([]) + files.dispose() + }) + + it('cancels individual callers, skips missing directories, and validates limits', async () => { + const root = await workspace() + expect(() => search(root, { maxResults: 0 })).toThrow('maxResults') + expect(() => search(root, { maxEntries: 1.5 })).toThrow('maxEntries') + expect(() => search(root, { excludedDirectories: ['nested/name'] })).toThrow('basenames') + + const files = search(root) + expect(await files.list('missing/', new AbortController().signal)).toEqual([]) + + const preAborted = new AbortController() + preAborted.abort(new Error('pre-aborted')) + await expect(files.list('tui', preAborted.signal)).rejects.toThrow('pre-aborted') + + files.invalidate() + const running = new AbortController() + const pending = files.list('tui', running.signal) + running.abort(new Error('superseded')) + await expect(pending).rejects.toThrow('superseded') + + files.invalidate() + const nonErrorAbort = new AbortController() + const nonErrorPending = files.list('tui', nonErrorAbort.signal) + nonErrorAbort.abort('cancelled') + await expect(nonErrorPending).rejects.toThrow('file search aborted') + }) +}) diff --git a/packages/context/file-reference-local/tests/service.spec.ts b/packages/context/file-reference-local/tests/service.spec.ts new file mode 100644 index 0000000000..5166871098 --- /dev/null +++ b/packages/context/file-reference-local/tests/service.spec.ts @@ -0,0 +1,162 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-file-reference' +import LocalFileReferenceService, { WorkspaceFileSearch } from '../src/index.ts' + +const roots: string[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + return ctx +} + +async function stubAgent( + ctx: Context, + id = 'file-reference-agent', + includeCwd = true, +): Promise<{ agent: Agent; dispose: () => void }> { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-reference-service-')) + roots.push(root) + await writeFile(join(root, 'README.md'), 'readme') + const session = ctx.sessions.create(SessionId(id), { meta: includeCwd ? { cwd: root } : {} }) + const agent = { + id: session.id, + options: {}, + session, + status: 'idle', + acceptsNextStep: false, + ctx, + followup() {}, + steer() {}, + inject() {}, + send() {}, + updateInbox() { return 'not-found' as const }, + cancel() {}, + whenIdle: () => Promise.resolve(), + } as unknown as Agent + return { agent, dispose: ctx.agents.register(agent) } +} + +describe('LocalFileReferenceService', () => { + it('serves the addressed workspace and installs read-tool guidance for existing agents', async () => { + const ctx = await harness() + const { agent } = await stubAgent(ctx) + const fiber = ctx.plugin(LocalFileReferenceService, { + maxResults: 5, + maxEntries: 100, + excludedDirectories: ['.git'], + }) + await fiber + await expect(ctx.fileReferences.list(agent, 'README', new AbortController().signal)) + .resolves.toEqual([{ path: 'README.md', kind: 'file' }]) + expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain(FILE_REFERENCE_PROMPT) + + ctx.tools.register(defineContentToolFixture({ + name: 'read', + description: 'read a file', + parameters: {}, + execute: () => Promise.resolve([]), + })) + expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain(FILE_REFERENCE_PROMPT) + await fiber.dispose() + expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain(FILE_REFERENCE_PROMPT) + }) + + it('invalidates cached searches after tool results and disposes them with the agent', async () => { + const ctx = await harness() + const { agent, dispose } = await stubAgent(ctx) + const invalidate = vi.spyOn(WorkspaceFileSearch.prototype, 'invalidate') + const close = vi.spyOn(WorkspaceFileSearch.prototype, 'dispose') + await ctx.plugin(LocalFileReferenceService) + await ctx.fileReferences.list(agent, 'README', new AbortController().signal) + + ctx.emit('session/event', agent.session, { type: 'tool/result' } as never) + expect(invalidate).toHaveBeenCalledOnce() + ctx.emit('session/event', agent.session, { type: 'assistant/message' } as never) + expect(invalidate).toHaveBeenCalledOnce() + const orphan = ctx.sessions.create(SessionId('file-reference-orphan')) + ctx.emit('session/event', orphan, { type: 'tool/result' } as never) + expect(invalidate).toHaveBeenCalledOnce() + + dispose() + expect(close).toHaveBeenCalledOnce() + ctx.emit('agent/disposed', { agent }) + }) + + it('installs guidance for agents announced after the service and validates deployment tunables', async () => { + const ctx = await harness() + await ctx.plugin(LocalFileReferenceService) + const { agent } = await stubAgent(ctx) + await expect(ctx.fileReferences.list(agent, '', new AbortController().signal)) + .resolves.toEqual([{ path: 'README.md', kind: 'file' }]) + + const badResults = await harness() + expect(() => new LocalFileReferenceService(badResults, { maxResults: 0 })).toThrow('maxResults') + const badEntries = await harness() + expect(() => new LocalFileReferenceService(badEntries, { maxEntries: 1.5 })).toThrow('maxEntries') + const badExclusion = await harness() + expect(() => new LocalFileReferenceService(badExclusion, { excludedDirectories: ['nested/name'] })) + .toThrow('excludedDirectories') + const fractionalResults = await harness() + expect(() => new LocalFileReferenceService(fractionalResults, { maxResults: 1.5 })).toThrow('maxResults') + const zeroEntries = await harness() + expect(() => new LocalFileReferenceService(zeroEntries, { maxEntries: 0 })).toThrow('maxEntries') + const emptyExclusion = await harness() + expect(() => new LocalFileReferenceService(emptyExclusion, { excludedDirectories: [''] })) + .toThrow('excludedDirectories') + const backslashExclusion = await harness() + expect(() => new LocalFileReferenceService(backslashExclusion, { excludedDirectories: ['nested\\name'] })) + .toThrow('excludedDirectories') + }) + + it('deduplicates lifecycle announcements and falls back to the process cwd', async () => { + const ctx = await harness() + const fiber = ctx.plugin(LocalFileReferenceService) + await fiber + const { agent } = await stubAgent(ctx, 'cwd-fallback', false) + ctx.emit('agent/created', { agent }) + const list = vi.spyOn(WorkspaceFileSearch.prototype, 'list').mockResolvedValue([]) + await expect(ctx.fileReferences.list(agent, '', new AbortController().signal)).resolves.toEqual([]) + await expect(ctx.fileReferences.list(agent, 'src', new AbortController().signal)).resolves.toEqual([]) + expect(list).toHaveBeenCalledTimes(2) + }) + + it('logs rejected prompt cleanup without failing service teardown', async () => { + const ctx = await harness() + const fiber = ctx.plugin(LocalFileReferenceService) + await fiber + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const inject = vi.spyOn(ctx, 'inject') + .mockReturnValueOnce({ dispose: () => Promise.reject(new Error('error cleanup')) } as never) + // Deliberately proves cleanup tolerates JavaScript callers rejecting non-Error values. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + .mockReturnValueOnce({ dispose: () => Promise.reject('string cleanup') } as never) + const first = await stubAgent(ctx, 'cleanup-one') + const second = await stubAgent(ctx, 'cleanup-two') + expect(inject).toHaveBeenCalledTimes(2) + first.dispose() + second.dispose() + await vi.waitFor(() => { + expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: error cleanup') + expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: string cleanup') + }) + await expect(fiber.dispose()).resolves.toBeUndefined() + }) +}) diff --git a/packages/context/file-reference-local/tsconfig.json b/packages/context/file-reference-local/tsconfig.json new file mode 100644 index 0000000000..5b4c223f4a --- /dev/null +++ b/packages/context/file-reference-local/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../file-reference" + } + ] +} diff --git a/packages/context/file-reference/README.i18n.yaml b/packages/context/file-reference/README.i18n.yaml new file mode 100644 index 0000000000..352a6747ad --- /dev/null +++ b/packages/context/file-reference/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 packages/context/file-reference/README.md +README.md: ae0e1ef68f927890af78767660cac094d831380c +README.zh.md: 1645dd345cd52cf5d3e02270c1fbd4c4344ada37 diff --git a/packages/context/file-reference/README.md b/packages/context/file-reference/README.md new file mode 100644 index 0000000000..ae0e1ef68f --- /dev/null +++ b/packages/context/file-reference/README.md @@ -0,0 +1,22 @@ +# `@deepseek-ai/dsh-file-reference` + +English | [中文](README.zh.md) + +File-reference discovery seam and browser-safe `@file` grammar shared by host-backed user interfaces. `ctx.fileReferences.list(agent, query, signal)` returns path-only file or directory candidates for the addressed agent; concrete providers own namespace access, ranking, caching, and invalidation. The same contract is remotely callable as the unary `fileReferences/list` Remote method (`@Remote` on the Service Definition, cancelled through the reserved trailing signal), so browser consumers call `ctx.remote.fileReferences.list` without an API Proxy route. + +`activeAtToken()` recognizes an `@path` or open `@"path with spaces` token only at the start of input or after whitespace, so email-like text does not open completion. `formatFileMention()` emits the matching prompt spelling, appends `/` to directory candidates, preserves an explicitly opened quote, and rejects control characters or embedded quotes that the editor grammar cannot represent safely. + +Selecting a candidate does not read or attach file contents. The exported `FILE_REFERENCE_PROMPT` is stable guidance that a provider may install when the addressed agent can call `read`. + +## Model Experience + +Indirectly, through `@deepseek-ai/dsh-file-reference-local`, which conditionally contributes this package's stable file-reference guidance. + +#### KV Cache effect + +The interface and grammar add no request tokens themselves; a provider-owned prompt section determines cache behavior. + +## Known Limitations and Deferred Work + +- **Path candidates are advisory** — the seam does not prove that a later model-facing filesystem tool can access the same namespace; deployments must align the provider with the effective `read` implementation. +- **No file-content reference object** — selected files remain ordinary prompt text and require an explicit model tool call before their contents become model-visible. diff --git a/packages/context/file-reference/README.zh.md b/packages/context/file-reference/README.zh.md new file mode 100644 index 0000000000..1645dd345c --- /dev/null +++ b/packages/context/file-reference/README.zh.md @@ -0,0 +1,22 @@ +# `@deepseek-ai/dsh-file-reference` + +[English](README.md) | 中文 + +文件引用发现 seam,以及供宿主驱动的用户界面共享、可在浏览器中安全使用的 `@file` 语法。`ctx.fileReferences.list(agent, query, signal)` 为指定 agent(智能体)返回仅含路径的文件或目录候选;具体提供方负责命名空间访问、排序、缓存和失效处理。同一契约以一元 `fileReferences/list` Remote 方法对外可调(`@Remote` 标注在 Service Definition 上,经保留的末位 signal 参数取消),浏览器消费方直接调用 `ctx.remote.fileReferences.list`,无需 API Proxy 路由。 + +`activeAtToken()` 只在输入开头或空白后识别 `@path` 或尚未闭合的 `@"path with spaces` token,因此类似电子邮件的文本不会打开补全。`formatFileMention()` 会生成与提示词匹配的写法,为目录候选追加 `/`,保留显式打开的引号,并拒绝编辑器语法无法安全表示的控制字符或内嵌引号。 + +选择候选项不会读取或附加文件内容。导出的 `FILE_REFERENCE_PROMPT` 是稳定指引;当指定 agent 可以调用 `read` 时,提供方可以安装该指引。 + +## 模型体验 + +间接影响模型体验:`@deepseek-ai/dsh-file-reference-local` 会按条件贡献本包的稳定文件引用指引。 + +#### KV 缓存影响 + +接口和语法本身不会增加请求 token;缓存行为取决于提供方拥有的提示词段。 + +## 已知限制与暂缓事项 + +- **路径候选仅供参考**:该 seam 不保证后续面向模型的文件系统工具能够访问同一命名空间;部署时必须让提供方与实际生效的 `read` 实现对齐。 +- **没有文件内容引用对象**:所选文件仍是普通提示词文本,其内容必须经过模型显式调用工具后才对模型可见。 diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json new file mode 100644 index 0000000000..84a4ef58da --- /dev/null +++ b/packages/context/file-reference/package.json @@ -0,0 +1,70 @@ +{ + "name": "@deepseek-ai/dsh-file-reference", + "description": "File-reference discovery contract and shared @file grammar", + "version": "0.1.0-rc.7", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/context/file-reference" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./grammar": { + "types": "./lib/types/grammar.d.ts", + "default": "./lib/types/grammar.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "zod": "^4.4.3" + } +} diff --git a/packages/context/file-reference/src/grammar.ts b/packages/context/file-reference/src/grammar.ts new file mode 100644 index 0000000000..86bb51a843 --- /dev/null +++ b/packages/context/file-reference/src/grammar.ts @@ -0,0 +1,55 @@ +/** + * Browser-safe `@file` token grammar shared by terminal and web clients. + * + * @module @deepseek-ai/dsh-file-reference/grammar + */ + +import type { FileReferenceCandidate } from './types.ts' + +/** Active `@` token ending at the editor cursor. */ +export interface ActiveAtToken { + /** Complete token replaced when the user accepts a completion. */ + prefix: string + /** Path query after `@` or `@"`. */ + query: string + /** Whether the user opened a quoted path. */ + quoted: boolean +} + +/** + * Extract an `@path` or `@"path with spaces` token at the cursor. An `@` + * inside another token, such as an email address, is not a completion trigger. + * @param line - current editor line. + * @param cursorCol - cursor column within that line. + * @returns the active token, or `undefined` outside an `@` token. + */ +export function activeAtToken(line: string, cursorCol: number): ActiveAtToken | undefined { + const beforeCursor = line.slice(0, cursorCol) + const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(beforeCursor) + if (quoted?.[1] !== undefined && quoted[2] !== undefined) { + return { prefix: quoted[1], query: quoted[2], quoted: true } + } + const plain = /(?:^|\s)(@([^\s]*))$/u.exec(beforeCursor) + if (plain?.[1] === undefined || plain[2] === undefined) return undefined + return { prefix: plain[1], query: plain[2], quoted: false } +} + +/** + * Format a selected path as prompt text. Whitespace uses the quoted + * `@"path"` grammar; a quoted directory keeps that quote open after its + * trailing slash so completion can descend another level. + * @param candidate - selected file or directory. + * @param preserveQuote - retain an explicitly opened quote even when unnecessary. + * @returns the insertion value, or `undefined` for a path the editor grammar cannot represent safely. + */ +export function formatFileMention( + candidate: FileReferenceCandidate, + preserveQuote: boolean, +): string | undefined { + const path = candidate.kind === 'directory' ? `${candidate.path}/` : candidate.path + if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined + const quoted = preserveQuote || /\s/u.test(path) + if (!quoted) return `@${path}` + if (candidate.kind === 'directory') return `@"${path}` + return `@"${path}"` +} diff --git a/packages/context/file-reference/src/index.ts b/packages/context/file-reference/src/index.ts new file mode 100644 index 0000000000..ca8c017236 --- /dev/null +++ b/packages/context/file-reference/src/index.ts @@ -0,0 +1,63 @@ +/** + * File-reference discovery seam shared by host-backed user interfaces. + * + * @module @deepseek-ai/dsh-file-reference + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' + +import type { FileReferenceCandidate } from './types.ts' + +export { activeAtToken, formatFileMention } from './grammar.ts' +export type { ActiveAtToken } from './grammar.ts' +export type { FileReferenceCandidate } from './types.ts' + +/** Model guidance for path-only references selected by a user interface. */ +export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' + +declare module '@deepseek-ai/cordis' { + interface Context { + fileReferences: FileReferenceService + } +} + +/** Host capability for cancellable file-reference discovery. */ +export abstract class FileReferenceService extends TypertRemoteService { + constructor(ctx: Context) { + super(ctx, 'fileReferences') + } + + /** + * List file and directory candidates for one agent's working directory. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ + abstract list( + agent: Agent, + query: string, + signal: AbortSignal, + ): Promise + + /** + * Remote face of {@link list}; the decorator cannot mark the abstract + * member, so this concrete adapter carries the identical contract. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ + @Remote('list') + remoteExportList( + agent: Agent, + query: string, + signal: AbortSignal, + ): Promise { + return this.list(agent, query, signal) + } +} + +export default FileReferenceService diff --git a/packages/context/file-reference/src/invariant.ts b/packages/context/file-reference/src/invariant.ts new file mode 100644 index 0000000000..cf623fd892 --- /dev/null +++ b/packages/context/file-reference/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-file-reference`. + * @module @deepseek-ai/dsh-file-reference/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-file-reference' + +/** Cordis companion plugin name. */ +export const name = 'file-reference-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the interface retains no candidate or lifecycle + * state; concrete providers own their cache and invalidation relationships. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/context/file-reference/src/types.ts b/packages/context/file-reference/src/types.ts new file mode 100644 index 0000000000..3637b2b2c4 --- /dev/null +++ b/packages/context/file-reference/src/types.ts @@ -0,0 +1,13 @@ +/** + * Public file-reference discovery records. This module contains types only so + * generated Remote clients can consume it without Host runtime code. + * @module @deepseek-ai/dsh-file-reference/types + */ + +/** One path-only completion candidate inside the target session cwd. */ +export interface FileReferenceCandidate { + /** User-facing path accepted by normal prompts and filesystem tools. */ + path: string + /** Directories keep completion open; files finish the mention. */ + kind: 'file' | 'directory' +} diff --git a/packages/context/file-reference/tests/invariant.spec.ts b/packages/context/file-reference/tests/invariant.spec.ts new file mode 100644 index 0000000000..1b3f3177bd --- /dev/null +++ b/packages/context/file-reference/tests/invariant.spec.ts @@ -0,0 +1,12 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as FileReferenceInvariant from '../src/invariant.ts' + +describe('invariant companion', () => { + it('registers the stateless seam under its package name', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(FileReferenceInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/context/file-reference/tests/service.spec.ts b/packages/context/file-reference/tests/service.spec.ts new file mode 100644 index 0000000000..1f41ad6f79 --- /dev/null +++ b/packages/context/file-reference/tests/service.spec.ts @@ -0,0 +1,21 @@ +/** The Remote face delegates to the provider's discovery contract unchanged. */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { FileReferenceService } from '../src/index.ts' +import type { FileReferenceCandidate } from '../src/types.ts' + +describe('FileReferenceService', () => { + it('serves the Remote face through the abstract discovery member', async () => { + const candidates: FileReferenceCandidate[] = [{ path: 'src', kind: 'directory' }] + const list = vi.fn((_agent: Agent, _query: string, _signal: AbortSignal) => Promise.resolve(candidates)) + class StubProvider extends FileReferenceService { + list = list + } + const provider = new StubProvider(new Context()) + const agent = { id: 'target' } as unknown as Agent + const signal = new AbortController().signal + await expect(provider.remoteExportList(agent, 'sr', signal)).resolves.toBe(candidates) + expect(list).toHaveBeenCalledWith(agent, 'sr', signal) + }) +}) diff --git a/packages/context/file-reference/tsconfig.json b/packages/context/file-reference/tsconfig.json new file mode 100644 index 0000000000..142d6ed01b --- /dev/null +++ b/packages/context/file-reference/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index e2fed650f2..489b884d57 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-reference/README.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 packages/context/session-reference/README.md -README.md: 1f7df17f512b109e0e29bebf3f7931516914c368 -README.zh.md: 380f1c613621a8e1fd5b4ee27b70f40f4072197b +README.md: 13dfcf2fa99a118efa03f4f183b6b426bf704c2b +README.zh.md: a81f8c62942ca9a9b3b10c1c109c1fb227971ec0 diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index 1f7df17f51..13dfcf2fa9 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -6,15 +6,15 @@ English | [中文](README.zh.md) ## Public API -- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id, cwd, or the latest log-backed title, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses that title as the mention label and falls back to the session id when the title is absent or unreadable; message bodies are not searched. -- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated, identified `UserMessage` context. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`. +- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id, cwd, or the latest log-backed title, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses that title as the mention label and falls back to the session id when the title is absent or unreadable; message bodies are not searched. The unary `sessionReferenceResolver/candidates` Remote method serves the same discovery under the configured candidate limit and attaches each candidate's canonical mention, so browser consumers call `ctx.remote.sessionReferenceResolver.candidates` without an API Proxy route. +- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated, identified `UserMessage` context. The service calls it for canonical mentions in direct user messages after downstream `agent/pre-step` listeners accept the step. - `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text. ## Snapshot semantics -Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compaction` 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. +Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source when the target message reaches `agent/pre-step`. A queued message therefore captures the source state at model-step entry, and the resulting context is immutable after that point. Projection keeps only direct-user `user/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compaction` source marker from the folded current surface. Separately sourced session-reference messages are injected context and are excluded, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, other plugin-generated user messages except marked compact checkpoints, and unfinished assistant chunks are also excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text. -The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. When the agent is idle, the standard TUI installs a one-shot `agent/pre-step` wrapper that adds the snapshot only to an `enter` decision containing the claimed direct prompt. While the agent is running, it calls `inject()` immediately before `steer()`, placing both messages in the next-step inbox for the same later claim. The target log therefore records a sourced context `user/message` followed by the readable direct `user/message`. Later source mutation, compaction, or deletion cannot change target replay. +The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. The service's outer `agent/pre-step` listener post-processes accepted direct user messages, preserves their message ids, and inserts each snapshot immediately before the message that cited it. Queue edits and queue-to-steer relocation need no reference-specific handling because parsing occurs after the final inbox claim. Invalid mentions, failed reads, cancellation, and budget failures end that turn before its messages enter model-visible history. The target log records a sourced context `user/message` followed by the readable direct `user/message`; source mutation after capture cannot change target replay. ## Configuration diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index 380f1c6136..a81f8c6294 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -6,15 +6,15 @@ ## 公开 API -- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id、cwd 或以日志为依据的最新标题进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用该标题作为 mention label;标题不存在或无法读取时回退到会话 id。不搜索消息主体。 -- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合且带标识的 `UserMessage` 上下文。任何无效引用、读取失败、取消或预算失败,都会使准备操作在宿主调用 `followup()` 或 `steer()` 之前失败。 +- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id、cwd 或以日志为依据的最新标题进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用该标题作为 mention label;标题不存在或无法读取时回退到会话 id。不搜索消息主体。一元 `sessionReferenceResolver/candidates` Remote 方法在配置的候选上限内提供同一发现能力,并为每个候选附上规范 mention,浏览器消费方直接调用 `ctx.remote.sessionReferenceResolver.candidates`,无需 API Proxy 路由。 +- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合且带标识的 `UserMessage` 上下文。下游 `agent/pre-step` 监听器接受步骤后,该服务会针对直接用户消息中的规范 mention 调用此方法。 - `encodeSessionReferenceUri()` 与 `decodeSessionReferenceUri()` 实现 `dsh-session:`,因此每个 JavaScript 字符串 id 都能精确往返。`formatSessionReferenceMention()` 发出 `@[label](uri)`,`parseSessionReferenceText()` 将 Markdown mention 或裸规范 URI 替换为可读的 `@label` 文本,并返回结构化引用。解析器会拒绝显式 Markdown mention 中任何格式错误的 URI;只当 scheme 后跟非空、符合 base64url 形状的 payload 时,裸文本才被视为引用,匹配但非规范的候选项仍会失败。空 scheme mention 或只含标点符号的 scheme mention 仍是普通讨论文本。 ## 快照语义 -准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`,入队后绝不重读。它仅投影折叠后当前表层中的用户直接发出的 `user/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compaction` 源标记。对于已经包含固化前缀上下文的源提示词,投影只读取其对模型隐藏的显示内容,以防止快照递归传播。已遮蔽的压缩(compaction)前事件、工具、推理(reasoning)、上下文、除已标记 compact 检查点外的插件生成 user 消息,以及未完成的 assistant 分片均会被排除。因此,已压缩源只会提供最新检查点及其后保留的会话内容,不会还原已遮蔽的文本。 +目标消息到达 `agent/pre-step` 时,准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`。因此,queued 消息在进入模型步骤时捕获源状态,此后生成的上下文保持不变。它仅投影折叠后当前表层中的用户直接发出的 `user/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compaction` 源标记。带独立来源的 session-reference 消息属于注入上下文,会被排除以防止快照递归传播。已遮蔽的压缩(compaction)前事件、工具、推理(reasoning)、除已标记 compact 检查点外的其他插件生成 user 消息,以及未完成的 assistant 分片也都会被排除。因此,已压缩源只会提供最新检查点及其后保留的会话内容,不会还原已遮蔽的文本。 -上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。agent 空闲时,标准 TUI 会安装一次性的 `agent/pre-step` 包装层,只把快照添加到包含已领取直接提示词的 `enter` 决策。agent 运行时,它会紧接着调用 `inject()` 和 `steer()`,把两条消息放入 next-step inbox,等待后续同一次领取。目标日志因此会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message`。后续源变更、压缩或删除都无法改变目标回放。 +上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。该服务的外层 `agent/pre-step` 监听器会处理已接受的直接用户消息,保留其消息 id,并把每份快照插入到引用它的消息紧前。解析发生在最终领取收件箱消息之后,因此队列编辑和从 queue 移动到 steer 不需要引用专用处理。无效 mention、读取失败、取消和预算失败会在消息进入面向模型的历史之前结束该轮次。目标日志会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message`;捕获后的源变更无法改变目标回放。 ## 配置 diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 5fc478314f..a0248ee0e3 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -22,17 +22,35 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" ], "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -42,6 +60,7 @@ "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { @@ -52,6 +71,7 @@ "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 43bed833ef..433ee1d0b1 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -5,10 +5,11 @@ * @module @deepseek-ai/dsh-session-reference */ -import { Context, Service } from '@deepseek-ai/cordis' +import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionSurfaceSnapshot, SessionTitleObservationResult } from '@deepseek-ai/dsh-session-query' @@ -21,7 +22,11 @@ import { } from './config.ts' import { retainReferencedSession, type ReferenceRetentionStats, type ReferencedSessionData } from './projection.ts' import { stringifyTagSafeJson } from './serialization.ts' -import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput, SessionReferenceSource } from './types.ts' +import type { + PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput, + SessionReferenceMentionCandidate, SessionReferenceSource, +} from './types.ts' +import { formatSessionReferenceMention, parseSessionReferenceText } from './uri.ts' export type * from './types.ts' export type { Config, SessionReferenceErrorCode } from './config.ts' @@ -67,7 +72,7 @@ interface RenderedSource { } /** Exact-read consumer that prepares immutable cross-session message context. */ -export class SessionReferenceResolver extends Service { +export class SessionReferenceResolver extends TypertRemoteService { static inject = ['sessionQuery'] static Config: z = z.object({ maxReferences: z.number().step(1).min(1).max(MAX_REFERENCES).default(MAX_REFERENCES), @@ -98,6 +103,48 @@ export class SessionReferenceResolver extends Service { 'SESSION_REFERENCE_INVALID_CONFIG', ) } + ctx.on('agent/pre-step', async ({ agent, signal }, next): Promise => { + const decision = await next() + if (decision.kind === 'reject') return decision + return { + kind: 'enter', + messages: await this.prepareDirectMessages(agent, decision.messages, signal), + } + }, { prepend: true }) + } + + /** + * Replace canonical mentions in direct user messages and place each prepared + * snapshot immediately before the message that cited it. + * @param agent - agent entering the model step. + * @param messages - messages accepted by downstream pre-step listeners. + * @param signal - active turn cancellation. + * @returns messages with session-reference context inserted in citation order. + */ + private async prepareDirectMessages( + agent: Agent, + messages: readonly UserMessage[], + signal: AbortSignal, + ): Promise { + const prepared = await Promise.all(messages.map(async (message): Promise => { + if (message.source.kind !== 'user') return [message] + const references: SessionReferenceInput[] = [] + const content = message.content.map((block): ContentBlock => { + if (block.type !== 'text') return block + const parsed = parseSessionReferenceText(block.text) + references.push(...parsed.references) + return { type: 'text', text: parsed.text } + }) + if (references.length === 0) return [message] + const resolved = await this.prepare(agent, content, references, signal) + const direct = freezeMessage({ ...message, content: resolved.content }) + /* v8 ignore if -- a parsed canonical mention always leaves one normalized reference */ + if (resolved.additionalContext === undefined) { + throw new Error('session-reference preparation omitted context for a canonical mention') + } + return [resolved.additionalContext, direct] + })) + return prepared.flat() } /** @@ -159,11 +206,33 @@ export class SessionReferenceResolver extends Service { } /** - * Snapshot all references before enqueue and return one aggregated durable context. + * Remote face of {@link listCandidates}: the configured candidate limit + * applies, and every candidate carries the canonical mention a host inserts + * into the prompt draft. + * @param agent - target agent; self is excluded and its cwd drives ranking. + * @param query - optional case-insensitive session-id/cwd/title substring. + * @param signal - caller cancellation. + * @returns mention-carrying candidates in rank order. + */ + @Remote('candidates') + async remoteExportCandidates( + agent: Agent, + query: string, + signal: AbortSignal, + ): Promise { + const candidates = await this.listCandidates(agent, query, this.config.candidateLimit, signal) + return candidates.map(candidate => ({ + ...candidate, + mention: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: candidate.label }), + })) + } + + /** + * Snapshot all references for one accepted direct message and return one aggregated durable context. * @param agent - target agent; references to it are rejected. * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. - * @param signal - optional cancellation boundary for host request teardown. + * @param signal - optional cancellation boundary for the active turn. * @returns detached content and optional referenced-session context. */ async prepare( diff --git a/packages/context/session-reference/src/types.ts b/packages/context/session-reference/src/types.ts index e1eff84244..6d908f34b9 100644 --- a/packages/context/session-reference/src/types.ts +++ b/packages/context/session-reference/src/types.ts @@ -1,7 +1,13 @@ -/** Public session-reference request, candidate, and preparation records. */ +/** + * Public session-reference request, candidate, and preparation records. + * Imports stay on type-only subpaths so generated Remote clients can consume + * this module without Host runtime code. + * @module @deepseek-ai/dsh-session-reference/types + */ -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import type { UserMessage } from '@deepseek-ai/dsh-llm/message' +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' /** Durable source session, cited event seqs, and snapshot facts for prepared cross-session context. */ export interface SessionReferenceSource { @@ -49,6 +55,12 @@ export interface SessionReferenceCandidate { createdAt: number } +/** One discovery candidate carrying its canonical prompt mention. */ +export interface SessionReferenceMentionCandidate extends SessionReferenceCandidate { + /** Canonical `@[label](dsh-session:…)` mention serialized into the prompt draft. */ + mention: string +} + /** Direct message content and optional referenced-session context. */ export interface PreparedReferencedMessage { /** Readable message content after host mention tokens are removed. */ diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index 75619868ad..e1eac75658 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction' import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -283,6 +283,100 @@ describe('session reference discovery and preparation', () => { listSessions.mockRestore() }) + it('serves the Remote face with the configured limit and canonical mentions', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same', createdAt: 10 } }) + ctx.sessions.create(SessionId('source]'), { meta: { cwd: '/same', createdAt: 20 } }) + const candidates = await ctx.sessionReferenceResolver.remoteExportCandidates( + fakeAgent(target), + '', + new AbortController().signal, + ) + expect(candidates).toEqual([{ + sessionId: SessionId('source]'), + label: 'source]', + cwd: '/same', + createdAt: 20, + mention: formatSessionReferenceMention({ sessionId: SessionId('source]'), label: 'source]' }), + }]) + }) + + it('prepares direct mentions at pre-step and keeps ordinary and plugin messages unchanged', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target')) + const source = ctx.sessions.create(SessionId('source')) + source.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'source fact' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + const agent = fakeAgent(target) + const direct = createUserMessage({ + content: [{ + type: 'text', + text: `compare ${formatSessionReferenceMention({ sessionId: source.id, label: 'Research' })} now`, + }, { type: 'reasoning', text: 'preserve this non-text block' }], + source: { kind: 'user' }, + }) + const ordinary = createUserMessage({ + content: [{ type: 'text', text: 'ordinary prompt' }], + source: { kind: 'user' }, + }) + const plugin = createUserMessage({ + content: [{ type: 'text', text: formatSessionReferenceMention({ sessionId: source.id, label: 'Ignored' }) }], + source: { kind: 'plugin', plugin: 'test' }, + }) + const signal = new AbortController().signal + + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: [direct, ordinary, plugin], turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [direct, ordinary, plugin] }), + ) + + expect(decision.kind).toBe('enter') + if (decision.kind !== 'enter') throw new Error('expected entered pre-step') + expect(decision.messages).toHaveLength(4) + expect(decision.messages[0]?.source).toMatchObject({ + kind: 'session-reference', + references: [{ sessionId: source.id, label: 'Research' }], + }) + expect(decision.messages[1]).toMatchObject({ + id: direct.id, + content: [ + { type: 'text', text: 'compare @Research now' }, + { type: 'reasoning', text: 'preserve this non-text block' }, + ], + }) + expect(decision.messages[1]).not.toBe(direct) + expect(decision.messages[2]).toBe(ordinary) + expect(decision.messages[3]).toBe(plugin) + }) + + it('does not prepare a rejected pre-step and rejects malformed direct mentions', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target')) + const agent = fakeAgent(target) + const malformed = createUserMessage({ + content: [{ type: 'text', text: '@[bad](dsh-session:not-canonical)' }], + source: { kind: 'user' }, + }) + const readSurface = vi.spyOn(ctx.sessionQuery, 'readSurface') + const signal = new AbortController().signal + + await expect(agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: [malformed], turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'reject' as const }), + )).resolves.toEqual({ kind: 'reject' }) + expect(readSurface).not.toHaveBeenCalled() + + await expect(agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: [malformed], turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [malformed] }), + )).rejects.toThrow(/invalid session reference URI/) + }) + it('keeps metadata matches when one title observation fails and cancels a stalled title batch', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target')) @@ -373,7 +467,12 @@ describe('session reference discovery and preparation', () => { const source = ctx.sessions.create(SessionId('source')) source.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'nested referenced snapshot must not propagate' }], - source: { kind: 'plugin', plugin: 'session-reference' }, + source: { + kind: 'session-reference', + form: 'recall', + version: 1, + references: [], + }, }), { surfaceOp: 'append' }) source.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'direct source question' }], diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 3778277a88..a7ad572221 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.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 packages/core/agent-loop/README.md -README.md: 683799d1840c857981d4ff30dd3e8e078be03098 -README.zh.md: 8009f72a387f5c65ed258eed9ca6d1f14835c41f +README.md: 907bbe75fd687388be044916f4c4509e1c552177 +README.zh.md: 29b06c907bb1a72c5056d29130fe85f479f2f19a diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 683799d184..907bbe75fd 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -63,7 +63,7 @@ Every inbox mutation publishes one normalized `agent/inbox/spliced` event before The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Package-private orchestration entry points recover the exact Agent, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or per-operation `Session` through shallow interfaces. A helper keeps an explicit `Session` when that is its actual interface, while creation, persistence load, unpublished setup, services, workers, processes, persistence, and wire protocols retain their explicit identities. The [agent service](../agent/README.md#initiating-agent-scope) owns propagation, teardown, and detached-work rules. -Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, lists the exact chunk seqs in `sourceEventSeqs` (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history. +Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, lists the exact chunk seqs in `sourceEventSeqs` (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history. A turn cancellation that interrupts streaming also appends an `interrupted: true` anchor when non-empty text or reasoning has reached the user. The anchor cites those chunk seqs and places the rendered prefix in derived message history, so the next request contains what the user saw. Undispatched tool calls are omitted, and an empty or tool-only stream produces no anchor; provider failures still commit no assistant content ([decision](../../../.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md)). After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance follows the same adapter-default marker rule when resuming. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 8009f72a38..29b06c907b 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -63,7 +63,7 @@ interface Config { 驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。包私有的编排入口点会恢复确切的 Agent,一次性派生 `agent.session`,并让操作局部的辅助函数捕获它,而不是通过浅层接口继续传递具体驱动器或每次操作的 `Session`。如果显式 `Session` 正是辅助函数的实际接口,该辅助函数会保留它;创建、持久化加载、未发布 setup、服务、worker、进程、持久化和 wire 协议则继续保留各自的显式身份。[agent 服务](../agent/README.md#initiating-agent-scope)规定传播、teardown 和分离工作规则。 -每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,在 `sourceEventSeqs` 中列出确切的分片 seq(流没有分片时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。 +每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,在 `sourceEventSeqs` 中列出确切的分片 seq(流没有分片时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。轮次取消打断流式输出时,如果非空文本或推理内容已送达用户,循环也会追加一个带 `interrupted: true` 的锚点。该锚点引用对应的分片 seq,并把已渲染的前缀放入派生消息历史,使下一次请求包含用户看到的内容。未分派的工具调用会被省略,空流或只包含工具调用的流不会生成锚点;提供方故障也不提交 assistant 内容([决策](../../../.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md))。 在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器负责的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall(瀑布式事件)前,循环会从提议中移除这些带标记字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会遵循同一套适配器默认值标记规则。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index ca98c94dab..1950195663 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -339,14 +339,33 @@ export class ReactLoopAgent implements Agent { ) const assembler = new BlockAssembler() const chunkSeqs: number[] = [] - const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) - signal.throwIfAborted() - for await (const chunk of stream) { + try { + const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) signal.throwIfAborted() - chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq) - assembler.push(chunk) + for await (const chunk of stream) { + signal.throwIfAborted() + chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq) + assembler.push(chunk) + } + signal.throwIfAborted() + } catch (error: unknown) { + if (signal.aborted) { + const content = assembler.interruptedBlocks() + if (content.length > 0) { + this.session.append('assistant/message', { + turn, + step, + message: createAssistantMessage({ + content, + source: { provider: request.provider, model: request.model }, + }), + interrupted: true, + ...assembler.usage === undefined ? {} : { usage: assembler.usage }, + }, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }) + } + } + throw error } - signal.throwIfAborted() const finish = assembler.finish if (finish.kind === 'error' || finish.kind === 'aborted') { const action = await this.dispatch.waterfall( diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 647bf81175..de44998536 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -1,6 +1,6 @@ import InboxService from '@deepseek-ai/dsh-agent/inbox' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' /** * Tests for the queue-aware `Agent.cancel()` primitive. The default clears * queued and steering work, while `keepInbox` preserves pending input for a @@ -483,6 +483,161 @@ describe('Agent.cancel()', () => { expect(reasons.length).toBe(2) }) + it('cancel mid-stream finalizes the streamed prefix onto the surface', async () => { + const adapter = new MockAdapter(['hang', textResponse('after')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('partial-finalize'), { provider: 'mock', model: 'mock' }) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + agent.cancel({ kind: 'user' }) + await waitForIdle(ctx, agent) + + // The prefix the user watched stream is committed as the step's message, + // carrying the truncation marker and citing exactly the chunk events that + // delivered it. + const message = agent.session.events.find(e => e.type === 'assistant/message') + expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) + .toEqual([{ type: 'text', text: 'partial' }]) + expect(message?.type === 'assistant/message' ? message.data.interrupted : undefined).toBe(true) + const chunkSeqs = agent.session.events.filter(e => e.type === 'assistant/chunk').map(e => e.seq) + expect(message?.sourceEventSeqs).toEqual(chunkSeqs) + const types = agent.session.events.map(e => e.type) + expect(types.indexOf('assistant/message')).toBeLessThan(types.indexOf('step/end')) + expect(types.indexOf('step/end')).toBeLessThan(types.indexOf('turn/end')) + + // The next request derives the finalized prefix: the model sees what the user saw. + send(agent, 'continue') + await waitForIdle(ctx, agent) + const replayed = adapter.requests[1]!.messages + .filter(m => m.role === 'assistant') + .flatMap(m => m.content) + .flatMap(b => b.type === 'text' ? [b.text] : []) + expect(replayed).toContain('partial') + }) + + it('cancel during reasoning-only streaming finalizes the reasoning prefix', async () => { + const adapter = new MockAdapter([{ + hangAfter: [ + { type: 'block-start', index: 0, blockType: 'reasoning' }, + { type: 'reasoning-delta', index: 0, text: 'thinking about it' }, + { type: 'usage', usage: { inputTokens: 7, outputTokens: 4 } }, + ], + }]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('reasoning-finalize'), { provider: 'mock', model: 'mock' }) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + agent.cancel({ kind: 'user' }) + await waitForIdle(ctx, agent) + + const message = agent.session.events.find(e => e.type === 'assistant/message') + expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) + .toEqual([{ type: 'reasoning', text: 'thinking about it' }]) + // A usage chunk delivered before the cancel travels with the finalized prefix. + expect(message?.type === 'assistant/message' ? message.data.usage : undefined) + .toEqual({ inputTokens: 7, outputTokens: 4 }) + }) + + it('cancel drops a half-streamed tool call and keeps the completed text before it', async () => { + const adapter = new MockAdapter([{ + hangAfter: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'reading the file' }, + { type: 'block-end', index: 0, block: { type: 'text', text: 'reading the file' } }, + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 1, id: CallId('c1'), name: 'read', argumentsDelta: '{"pa' }, + ], + }]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('tool-call-drop'), { provider: 'mock', model: 'mock' }) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + agent.cancel({ kind: 'user' }) + await waitForIdle(ctx, agent) + + // The undispatched call is dropped whole — no dangling tool_use to pair. + const message = agent.session.events.find(e => e.type === 'assistant/message') + expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) + .toEqual([{ type: 'text', text: 'reading the file' }]) + expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) + }) + + it('cancel during error recovery does not finalize the failed stream', async () => { + const adapter = new MockAdapter([[ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'doomed partial' }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'SERVER_ERROR' } } }, + ]]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('recovery-cancel'), { provider: 'mock', model: 'mock' }) + // Cancellation lands while agent/request-error is in flight — the window + // dsh-llm-retry opens when its backoff waits after appending llm/retry. + ctx.on('agent/request-error', async ({ agent: subject }) => { + if (subject === agent) subject.cancel({ kind: 'user' }) + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + // The failed stream's prefix stays off the surface: clients reset it on + // retry, and provider failures commit nothing. + expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) + const end = agent.session.events.find(e => e.type === 'turn/end') + expect(end?.type === 'turn/end' ? end.data.reason.kind : undefined).toBe('aborted') + }) + + it('retry discards the failed attempt; the final message cites only its own chunks', async () => { + const adapter = new MockAdapter([ + [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'doomed partial' }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'SERVER_ERROR' } } }, + ], + textResponse('recovered'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('retry-discards-content'), { provider: 'mock', model: 'mock' }) + ctx.on('agent/request-error', async () => ({ kind: 'retry' as const })) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + const messages = agent.session.events.filter(e => e.type === 'assistant/message') + expect(messages).toHaveLength(1) + const message = messages[0]! + expect(message.type === 'assistant/message' ? message.data.message.content : undefined) + .toEqual([{ type: 'text', text: 'recovered' }]) + expect(message.type === 'assistant/message' ? message.data.interrupted : undefined).toBeUndefined() + // The abandoned attempt's chunks stay out of the completion's source set. + const doomedSeqs = agent.session.events + .filter(e => e.type === 'assistant/chunk' + && e.data.chunk.type === 'text-delta' && e.data.chunk.text === 'doomed partial') + .map(e => e.seq) + expect(doomedSeqs).toHaveLength(1) + expect(message.sourceEventSeqs).not.toContain(doomedSeqs[0]) + }) + + it('cancel before any visible content finalizes nothing', async () => { + const adapter = new MockAdapter([{ + hangAfter: [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'read', argumentsDelta: '{"pa' }, + ], + }]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('nothing-to-finalize'), { provider: 'mock', model: 'mock' }) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + agent.cancel({ kind: 'user' }) + await waitForIdle(ctx, agent) + + expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) + }) + it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/mock-adapter.ts b/packages/core/agent-loop/tests/mock-adapter.ts index 69dc78990c..c5341fb7ed 100644 --- a/packages/core/agent-loop/tests/mock-adapter.ts +++ b/packages/core/agent-loop/tests/mock-adapter.ts @@ -55,19 +55,25 @@ export function toolCallResponse(rawCallId: string, name: string, args: object, return chunks } +/** Script entry that streams the given chunks, then hangs until aborted. */ +export interface HangAfter { + hangAfter: StreamChunk[] +} + /** * Mock adapter driven by a script: each model call consumes the next entry. * Records every request it receives for assertions. An entry may be a * function to compute chunks from the request, a 'hang' marker that - * streams one chunk then waits until aborted, or 'hang-slow' which takes + * streams one chunk then waits until aborted, 'hang-slow' which takes * 50ms to notice the abort — a stand-in for slow real-world teardown - * (LLM stream cancellation, tool unwinding). + * (LLM stream cancellation, tool unwinding) — or a {@link HangAfter} + * scripting the exact chunks delivered before the hang. */ export class MockAdapter extends LlmAdapter { requests: GenerateOptions[] = [] constructor( - private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow')[], + private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow' | HangAfter)[], private readonly reasoning?: LlmModelReasoningInfo, private readonly defaultMaxTokens?: number, ) { @@ -100,6 +106,14 @@ export class MockAdapter extends LlmAdapter { }) return } + if (typeof entry === 'object' && !Array.isArray(entry) && 'hangAfter' in entry) { + for (const chunk of entry.hangAfter) yield chunk + await new Promise((_resolve, reject) => { + if (options.signal?.aborted) { reject(new Error('aborted')); return } + options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }) + return + } if (entry === 'hang-slow') { yield { type: 'block-start', index: 0, blockType: 'text' } yield { type: 'text-delta', index: 0, text: 'partial' } diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index b84b2b282b..f2d95d52ea 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.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 packages/core/session/README.md -README.md: 02aae21d71101e626bf2d2ead0bfd5d155723165 -README.zh.md: 2da8c5db8fe25d55faafdd6d44ea9090dd232576 +README.md: c62668167f4e0dfda822c7b82add3b4d8eb3d635 +README.zh.md: 67de260e1ecb59f61a2e7b2eb1f6e665f06190a4 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 02aae21d71..c62668167f 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -98,7 +98,7 @@ Every `SessionEvent` carries three optional top-level fields (structural metadat #### What the model sees -The model receives the complete messages from `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. Their identities, roles, sources, and content blocks are the same values established at creation; projections do not mint identities. 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. +The model receives the complete messages from `user/message`, `assistant/message`, and `tool/result` surface entries verbatim. Their identities, roles, sources, and content blocks are the same values established at creation; projections do not mint identities. Direct prompts and injected context remain separate `user/message` events whose sources preserve their provenance. 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 diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 2da8c5db8f..67de260e1e 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -98,7 +98,7 @@ #### 模型看到的内容 -模型会原样接收 `user/message`、`assistant/message` 和 `tool/result` surface 条目中的完整消息。其标识、角色、来源和内容块都与创建时确定的值相同;投影不会生成标识。提示词封装只改变面向人的呈现;其前缀上下文和请求分隔符已经位于事件内容中。工具调用包含在 assistant 消息内。分片、边界、用量、钩子记录、todo 记录以及其他仅日志事件不会添加消息。 +模型会原样接收 `user/message`、`assistant/message` 和 `tool/result` surface 条目中的完整消息。其标识、角色、来源和内容块都与创建时确定的值相同;投影不会生成标识。直接提示词与注入上下文仍是彼此独立的 `user/message` 事件,各事件的来源会保留其出处。提示词封装只改变面向人的呈现;其前缀上下文和请求分隔符已经位于事件内容中。工具调用包含在 assistant 消息内。分片、边界、用量、钩子记录、todo 记录以及其他仅日志事件不会添加消息。 #### Token 影响 diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 17aacd1dfc..31ce28a01b 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -268,9 +268,13 @@ export interface SessionEventMap { * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event with `interrupted: true`; undispatched tool calls are absent. The + * marker distinguishes that prefix without re-deriving interruption from turn + * boundaries. An aborted turn with no such event streamed no visible content. */ - 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } + 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } /** * The model requested one tool invocation: `name` with the raw `arguments` * JSON string exactly as the model produced it (unparsed). `callId` pairs the diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 4eba7c202b..6fb951da48 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -536,7 +536,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace.directoryFlow\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:56', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:57', }, { key: 'conversation.input.attachments', @@ -1678,7 +1678,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces.directoryFlow\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:58', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:59', }, { key: 'tool.call.toolview', @@ -1695,7 +1695,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n /** Tool call identity, stable across running and settled forms. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Frozen running call or settled result node. */\n block: ToolCallBlock\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Open a Tool argument path through the Host. */\n openFile: (path: string) => void\n /** Inspect this call in the trajectory view when available. */\n inspect?: (() => void) | undefined\n}', + '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n /** Tool call identity, stable across running and settled forms. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Frozen running call or settled result node. */\n block: ToolCallBlock\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Host account home; POSIX home-rooted summaries display as `~`. */\n home?: string | undefined\n /** Open a Tool argument path through the Host. */\n openFile: (path: string) => void\n /** Inspect this call in the trajectory view when available. */\n inspect?: (() => void) | undefined\n}', ], ownerPropsReferences: [ 'Wire', @@ -1732,7 +1732,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.toolview\', () => ctx.slots.register(\n { name: \'tool.call.toolview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-tool/src/client/contract/slots.ts:23', + source: 'packages/client/ui-tool/src/client/contract/slots.ts:24', }, { key: 'tool.view.cordis', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 19d6299f74..03f90389b1 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -568,6 +568,25 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'fileReferences', + summary: 'Host capability for cancellable file-reference discovery.', + description: 'Host capability for cancellable file-reference discovery.', + methods: [ + { + signature: 'abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise', + description: 'List file and directory candidates for one agent\'s working directory.', + parameters: [{ name: 'agent', description: 'target agent whose session cwd bounds discovery.' }, { name: 'query', description: 'path text following `@` or `@"`.' }, { name: 'signal', description: 'caller cancellation.' }], + returns: 'deterministic path-only candidates.', + }, + { + signature: '@Remote(\'list\') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise', + description: 'Remote face of list; the decorator cannot mark the abstract member, so this concrete adapter carries the identical contract.', + parameters: [{ name: 'agent', description: 'target agent whose session cwd bounds discovery.' }, { name: 'query', description: 'path text following `@` or `@"`.' }, { name: 'signal', description: 'caller cancellation.' }], + returns: 'deterministic path-only candidates.', + }, + ], + }, { key: 'fs', summary: 'Abstract filesystem provider.', @@ -1275,10 +1294,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title substring.' }, { name: 'limit', description: 'optional positive result cap.' }, { name: 'signal', description: 'optional cancellation boundary for host autocomplete teardown.' }], returns: 'candidates labeled by latest title or, when absent, session id.', }, + { + signature: '@Remote(\'candidates\') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise', + description: 'Remote face of listCandidates: the configured candidate limit applies, and every candidate carries the canonical mention a host inserts into the prompt draft.', + parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title substring.' }, { name: 'signal', description: 'caller cancellation.' }], + returns: 'mention-carrying candidates in rank order.', + }, { signature: 'async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise', - description: 'Snapshot all references before enqueue and return one aggregated durable context.', - parameters: [{ name: 'agent', description: 'target agent; references to it are rejected.' }, { name: 'content', description: 'already host-normalized readable message content.' }, { name: 'references', description: 'structured source sessions in mention order.' }, { name: 'signal', description: 'optional cancellation boundary for host request teardown.' }], + description: 'Snapshot all references for one accepted direct message and return one aggregated durable context.', + parameters: [{ name: 'agent', description: 'target agent; references to it are rejected.' }, { name: 'content', description: 'already host-normalized readable message content.' }, { name: 'references', description: 'structured source sessions in mention order.' }, { name: 'signal', description: 'optional cancellation boundary for the active turn.' }], returns: 'detached content and optional referenced-session context.', }, ], @@ -3144,6 +3169,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'FileLocation', declaration: 'export interface FileLocation {\n path: string;\n line?: number;\n}', }, + { + name: 'FileReferenceCandidate', + declaration: 'export interface FileReferenceCandidate {\n path: string;\n kind: \'file\' | \'directory\';\n}', + }, { name: 'FinishReason', declaration: 'export type FinishReason = FinishReasonMap[keyof FinishReasonMap];', @@ -3846,7 +3875,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\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\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\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 message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': Record;\n}', + declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\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\': UserMessage;\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n interrupted?: true;\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 message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n \'request/context\': RequestContext;\n \'session/end-seed\': Record;\n}', }, { name: 'SessionEventMetadataFilter', @@ -3972,6 +4001,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionReferenceInput', declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}', }, + { + name: 'SessionReferenceMentionCandidate', + declaration: 'export interface SessionReferenceMentionCandidate extends SessionReferenceCandidate {\n mention: string;\n}', + }, { name: 'SessionResultFilter', declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | ({\n kind: \'created-at\';\n} & SessionResultRange) | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly SessionAvailability[];\n};', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 5826f75f75..b658b72b5b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.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 packages/host/apiproxy/README.md -README.md: 607cd4e4176631b64daf4a298b5d86a75ccdce68 -README.zh.md: cdfb5aa65b3ad5b00596487aae6c99e2f9d4e433 +README.md: 27efe5a75eb6947d71f95c0a60e590c15a35887c +README.zh.md: 71f4e63a013170c6822a5c8dae28bc5f899b271a diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 607cd4e417..27efe5a75e 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -50,7 +50,7 @@ A stale continuation discards every partial result, deduplication entry, and cur Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. -`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. `host.describe.canOpenPath` advertises whether that handoff can reach a user-visible desktop: explicit gateway `nativeOpen` wins, an injected opener is usable by definition, and platform detection otherwise accepts macOS, Windows, WSL, or Linux with a display while rejecting headless/container Linux. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`; clients combine both facts before presenting a native action. +`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. `host.describe.home` is the host account home directory. The Web client uses it to display POSIX home-rooted paths as `~`; Windows values are still reported and are not abbreviated. `host.describe.canOpenPath` advertises whether that handoff can reach a user-visible desktop: explicit gateway `nativeOpen` wins, an injected opener is usable by definition, and platform detection otherwise accepts macOS, Windows, WSL, or Linux with a display while rejecting headless/container Linux. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`; clients combine both facts before presenting a native action. The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names), whether it is the current default, and — when the preset cannot compose a session — a `broken` reason, because a damaged directory still occupies its id and a surface must be able to show and delete it rather than offer it and fail the session start. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cdfb5aa65b..71f4e63a01 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -50,7 +50,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr 目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 -`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。`host.describe.canOpenPath` 会宣告这次交接能否抵达用户可见的桌面:网关显式配置的 `nativeOpen` 优先,注入的 opener 按定义可用,否则平台检测接受 macOS、Windows、WSL 或带 display 的 Linux,并拒绝 headless/容器 Linux。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制;客户端会组合这两个事实后再呈现原生操作。 +`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。`host.describe.home` 是宿主账户的家目录。Web 客户端用它把 POSIX 家目录路径显示为 `~`;Windows 值仍会上报,但不会缩写。`host.describe.canOpenPath` 会宣告这次交接能否抵达用户可见的桌面:网关显式配置的 `nativeOpen` 优先,注入的 opener 按定义可用,否则平台检测接受 macOS、Windows、WSL 或带 display 的 Linux,并拒绝 headless/容器 Linux。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制;客户端会组合这两个事实后再呈现原生操作。 `agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)、它是否为当前默认值,以及——当该 preset 无法组装会话时——一条 `broken` 原因:损坏的目录仍占着它的 id,界面必须能展示并删除它,而不是把它端出来然后在会话启动时失败。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录的工具调用,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 11d633fa28..e87248974b 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' +import { homedir } from 'node:os' import { dirname } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' @@ -2843,6 +2844,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro provider: selection.provider, model: selection.model, attachedSessions: ctx.agents.list().length, + home: homedir(), canOpenPath: canOpenPaths(), })) }, diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index 7dd560d3a4..1a520f1e30 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -17,6 +17,7 @@ export const hostDescribeValueSchema = z.object({ provider: z.string().optional(), model: z.string().optional(), attachedSessions: z.number().int().nonnegative(), + home: z.string(), canOpenPath: z.boolean(), }) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 33c55106f5..5e45fcf3a5 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -41,6 +41,7 @@ export interface HostApi { * applied when a new agent doesn't specify them explicitly, absent when the host configures * no explicit default (the adapter falls back internally); * attachedSessions = count of currently attached sessions (those with a live agent); + * home = the host account home directory (Web display abbreviation on POSIX); * canOpenPath = whether this deployment can hand a path to a user-visible native desktop. */ describe(request: RpcRequest<{}>): Promise> diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 0bb9eb55b9..cf286e5174 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -236,6 +236,7 @@ describe('host.openPath', () => { const headless = await harness(undefined, undefined, { canOpenPath: () => false }) expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true) expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false) + expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir()) }) it('opens through the injected native boundary', async () => { diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 4130d8f210..de9d4ddab0 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -72,7 +72,7 @@ function scriptedApi(overrides: { }, host: { describe: r => ok(r, { - version: '0-test', cwd: '/t', attachedSessions: 0, canOpenPath: true, + version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true, }), pickDirectory: r => ok(r, { path: null }), listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 2000f708ba..77432c55af 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -143,7 +143,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra rpcId: request.rpcId, result: { ok: true, - value: { version: 'v', cwd: '/w', attachedSessions: 0, canOpenPath: true }, + value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true }, }, } }, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 66ae52f783..62d1a371da 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -312,15 +312,18 @@ describe('host domain schemas', () => { it('validates describe request/value', () => { expect(hostDescribeRequestSchema.parse({})).toEqual({}) const value = hostDescribeValueSchema.parse({ - version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, canOpenPath: true, + version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, home: '/h', canOpenPath: true, }) expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2, canOpenPath: true }) expect(hostDescribeValueSchema.parse({ - version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: false, + version: '1', cwd: '/x', attachedSessions: 0, home: '/h', canOpenPath: false, }).provider).toBeUndefined() expect(() => hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, })).toThrow() + expect(() => hostDescribeValueSchema.parse({ + version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: true, + })).toThrow() }) it('validates the browse listing/creation payloads', () => { diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index cf54a96ec7..a049b4dd87 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.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 packages/llm/llm-deepseek/README.md -README.md: 9bb28e6876b82c521341769123a8b2d0e5d98e09 -README.zh.md: 21c55cfa32bf68e0cac4c0bd72c94c86d955fc00 +README.md: 599dc1f530884df58fcc64d8cdf6c62fac80bc1f +README.zh.md: b1a45a1f398fdbb11cddebe34b646edf37f579be diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 9bb28e6876..599dc1f530 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -20,6 +20,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default + maxRequestImageBytes: 20971520 # optional positive integer; 20 MiB base64-payload default retryPolicy: # optional; omission uses normal mode with five retries mode: always # normal | always backoff: @@ -30,12 +31,19 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash name: DeepSeek-V4-Flash + - id: private-vision + name: Private Vision + inputModalities: [text, image] - id: private-reasoner description: Company-hosted reasoning model contextWindow: 512000 ``` -The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. +The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Vision models are not advertised by default until their endpoint rollout is complete, but a deployment can add one with `inputModalities: [text, image]`. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. + +An image-capable catalog entry may declare `inputModalities: [text, image]`. The adapter resolves user and tool-result `ImageBlock` references through `ctx.attachments`, verifies the stored bytes, and sends transient `data:;base64,...` `image_url` parts without changing the durable session message. Text-only and unlisted models reject image input before credential, attachment, or network I/O. System and assistant history remain image-free; tool-result images follow their string-only `tool` messages in a separate `user` message. + +`maxRequestImageBytes` bounds accumulated base64 image payload and defaults to 20 MiB, leaving headroom below the official 30 MiB request-body limit for text, tools, and JSON framing. When history exceeds the bound, the oldest images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]` until the request fits; omitted attachments are not read. Attachment admission continues to own per-image and per-message raw-byte, media, dimension, and pixel limits. `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`. @@ -49,10 +57,11 @@ The same exact-model result exposes ordered `off`, `low`, `high`, and `max` effo ## Dynamic configuration (settings + credentials) -Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: +Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, image bound, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Three optional seams feed that thunk: - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. - **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint. Configuration carries only `apiKeyEnv`, never a literal key: the reference resolves through the credential seam, and without a mounted seam through the trusted environment layers. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every resolved key is format-checked before use, so a value no HTTP header can carry is refused with `LlmError('INVALID_CREDENTIAL')` naming the failing entry point — never any part of the key — instead of surfacing as an opaque `fetch` `TypeError`. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. +- **`ctx.attachments`** — image requests resolve this service at request time, so Cordis load order does not freeze optional image availability. Absence rejects image input with `UNSUPPORTED_CONTENT`; text-only calls do not require the service. The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy. @@ -70,11 +79,12 @@ DeepSeek request identity is separate from app attribution. After credential res - The adapter-owned `off` effort maps to `thinking: {type: 'disabled'}` and never crosses the wire as `reasoning_effort: 'off'`. - The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block). - **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens). +- Image-capable user messages preserve text/image order. Tool-role content remains a string; consecutive tool-result images are grouped into the following user message with `Attached image(s) from tool result:`. - Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric. ## Errors -Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy). +Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s and 413), `SERVER` (5xx), `HTTP_` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. Attachment reads retain their stable attachment failure code rather than becoming transport failures. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy). ## Model Experience @@ -82,15 +92,15 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. The vision model also receives retained user and tool-result images as base64 data URLs; an over-budget older image is represented by the documented placeholder. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted. #### Token effect -Provider tokenization governs exact input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning avoids paying those tokens again; cache-read usage is reported when available. +Provider tokenization governs exact text and image-token input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning and over-budget images avoids paying those tokens again; cache-read usage is reported when available. #### KV Cache effect -An unchanged assembled prefix is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, or history change may prevent reuse from the first changed token; reasoning passback appends during tool round trips. +An unchanged assembled prefix, including deterministically encoded retained images and placeholders, is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, history, or image-budget change may prevent reuse from the first changed token; reasoning passback appends during tool round trips. ### DeepSeek response @@ -111,4 +121,5 @@ Loop-retained response blocks append to the next request and preserve its earlie - **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). -- **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`. +- **Plugin-added content block types are skipped** — core text and supported image blocks are serialized, and empty tool output crosses the wire as the literal `(no output)`. +- **Images are input-only durable attachments** — direct external URLs, the Files API, and assistant image output are not supported. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 21c55cfa32..b1a45a1f39 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -20,6 +20,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default + maxRequestImageBytes: 20971520 # optional positive integer; 20 MiB base64-payload default retryPolicy: # optional; omission uses normal mode with five retries mode: always # normal | always backoff: @@ -30,12 +31,19 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash name: DeepSeek-V4-Flash + - id: private-vision + name: Private Vision + inputModalities: [text, image] - id: private-reasoner description: Company-hosted reasoning model contextWindow: 512000 ``` -该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 +该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash` 和 `deepseek-v4-pro`,两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。在视觉模型端点完成发布前,默认目录不会公布视觉模型,但部署方可以通过 `inputModalities: [text, image]` 主动添加。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 + +支持图片的 catalog 配置项可以声明 `inputModalities: [text, image]`。适配器通过 `ctx.attachments` 解析 user 和工具结果中的 `ImageBlock` 引用,校验已存储字节,再发送瞬态 `data:;base64,...` `image_url` 部分,不改变持久会话消息。纯文本模型与未列出模型会在凭据、附件或网络 I/O 前拒绝图片输入。System 和 assistant 历史仍不能包含图片;工具结果图片会在仅含字符串的 `tool` 消息后,通过单独的 `user` 消息发送。 + +`maxRequestImageBytes` 限制累计 base64 图片 payload,默认值为 20 MiB,为官方 30 MiB 请求正文限制中的文本、工具和 JSON 分帧保留余量。历史超过上限时,适配器会从最旧图片开始替换为固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`,直至请求可容纳;被省略的附件不会被读取。附件准入仍负责单图和单消息原始字节数、媒体类型、尺寸与像素限制。 `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 @@ -49,10 +57,11 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 动态配置(settings + credentials) -连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: +连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值、图片上限与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。三个可选 seam 供给该 thunk: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 - **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照。配置只携带 `apiKeyEnv`,从不携带字面密钥:该引用经凭据 seam 解析,未挂载 seam 时则经受信环境层解析。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个解析出的密钥在使用前都会被校验格式,因此 HTTP 标头无法承载的值会以 `LlmError('INVALID_CREDENTIAL')` 被拒绝,点名失败的入口,但绝不透露密钥的任何部分,而不是以语义不明的 `fetch` `TypeError` 形式浮现。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 +- **`ctx.attachments`**——图片请求会在请求时解析该服务,因此 Cordis 加载顺序不会冻结可选图片能力。服务缺失时,图片输入以 `UNSUPPORTED_CONTENT` 失败;纯文本调用不依赖该服务。 唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。 @@ -70,11 +79,12 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 - 适配器持有的 `off` 推理强度映射为 `thinking: {type: 'disabled'}`,绝不会以 `reasoning_effort: 'off'` 通过协议发送。 - 第一个思考模式分片携带 `reasoning_content: ""`,系统会处理它(不会产生多余 reasoning 块)。 - **推理回传规则**:对携带工具调用的 assistant 轮次,会将 `reasoning_content` 序列化回历史(思考模式 API 必需);对不含工具调用的轮次,它会被丢弃(不会使用,可节省 token)。 +- 支持图片的 user 消息会保留文本/图片顺序。Tool role 内容仍为字符串;连续工具结果中的图片会用 `Attached image(s) from tool result:` 汇总到随后一条 user 消息。 - Cache 计量:`cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`;DeepSeek 不报告 cache-write 指标。 ## 错误 -非 2xx 响应会抛出稳定 code 的 `LlmError`:`AUTH`(401/403)、`QUOTA`(提供方详细信息标识配额、余额或点数耗尽的响应)、`RATE_LIMIT`(其他 429)、`CONTEXT_WINDOW_EXCEEDED`(提供方 code、type 或 message 标识上下文溢出的 400)、`INVALID_REQUEST`(其他 400)、`SERVER`(5xx),其他情况为 `HTTP_`。其可序列化 `failure` 保留 HTTP 状态,以及有效的正 `Retry-After` 秒数/日期延迟和存在时的 `x-request-id` / `x-deepseek-request-id`。响应前传输失败(DNS、连接被拒绝、TLS、proxy)会抛出命名已配置端点的 `TRANSPORT`,并将原始拒绝作为 `cause`;调用方 abort 抛出 `ABORTED`,仍以 loop 的取消信号为准。协议违例抛出 `STREAM_CLOSED`(没有 `[DONE]`)或 `MALFORMED_RESPONSE`(JSON payload 格式错误)。未知协议 `finish_reason`(例如 `content_filter`、`insufficient_system_resource`)会变为 `finish {kind: 'error', failure}` 分片;已完成流如果使用 `stop`(或缺失)finish 但没有开启内容块,就会变为 `finish {kind: 'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试)。 +非 2xx 响应会抛出稳定 code 的 `LlmError`:`AUTH`(401/403)、`QUOTA`(提供方详细信息标识配额、余额或点数耗尽的响应)、`RATE_LIMIT`(其他 429)、`CONTEXT_WINDOW_EXCEEDED`(提供方 code、type 或 message 标识上下文溢出的 400)、`INVALID_REQUEST`(其他 400 和 413)、`SERVER`(5xx),其他情况为 `HTTP_`。其可序列化 `failure` 保留 HTTP 状态,以及有效的正 `Retry-After` 秒数/日期延迟和存在时的 `x-request-id` / `x-deepseek-request-id`。附件读取会保留稳定的附件失败 code,不会变成传输失败。响应前传输失败(DNS、连接被拒绝、TLS、proxy)会抛出命名已配置端点的 `TRANSPORT`,并将原始拒绝作为 `cause`;调用方 abort 抛出 `ABORTED`,仍以 loop 的取消信号为准。协议违例抛出 `STREAM_CLOSED`(没有 `[DONE]`)或 `MALFORMED_RESPONSE`(JSON payload 格式错误)。未知协议 `finish_reason`(例如 `content_filter`、`insufficient_system_resource`)会变为 `finish {kind: 'error', failure}` 分片;已完成流如果使用 `stop`(或缺失)finish 但没有开启内容块,就会变为 `finish {kind: 'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试)。 ## 模型体验 @@ -82,15 +92,15 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### 模型看到的内容 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。当之前的 assistant 轮次包含工具调用时,会按要求回传其推理内容;不含工具调用的轮次会省略推理。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。视觉模型还会通过 base64 data URL 收到保留的 user 与工具结果图片;超出上限的较旧图片由已记录的占位文本表示。当之前的 assistant 轮次包含工具调用时,会按要求回传其推理内容;不含工具调用的轮次会省略推理。 #### Token 影响 -精确输入取决于提供方 tokenization。有条件推理回传会增加工具往返上下文,丢弃其他推理则避免再次支付这些 token;可用时会报告 cache-read 用量。 +精确文本与图片 token 输入取决于提供方 tokenization。有条件推理回传会增加工具往返上下文,丢弃其他推理和超出上限的图片则避免再次支付这些 token;可用时会报告 cache-read 用量。 #### KV Cache 影响 -未更改的已组装前缀可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀或历史变更,都可能使从首个发生变化的 token 起的复用失效;推理回传会在工具往返期间追加。 +未更改的已组装前缀,包括确定性编码的保留图片与占位文本,可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀、历史或图片上限变更,都可能使从首个发生变化的 token 起的复用失效;推理回传会在工具往返期间追加。 ### DeepSeek 响应 @@ -111,4 +121,5 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 - **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 -- **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。 +- **会跳过插件添加的内容块类型**:核心文本与支持的图片块会被序列化,空工具输出会以字面 `(no output)` 通过协议发送。 +- **图片是仅输入的持久附件**:不支持直接外部 URL、Files API 和 assistant 图片输出。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 36ccad7994..52d022806a 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -32,6 +32,7 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -46,6 +47,7 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index bc30d46c3b..638d555b1e 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -8,19 +8,21 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, + ModelModality, ResolvedRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' -import { serializeRequest } from './serialize.ts' +import { serializeRequest, serializeRequestWithImages } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' import { parseSse } from './sse.ts' import { translate } from './translate.ts' @@ -38,6 +40,8 @@ export interface DeepSeekCatalogModel { contextWindow?: number /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ maxTokens?: number + /** Accepted request modalities; omission is text-only. */ + inputModalities?: ModelModality[] } /** @@ -66,6 +70,8 @@ export interface DeepSeekConnectionOptions { models: readonly DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs: number + /** Maximum accumulated base64 image payload in one request. */ + maxRequestImageBytes: number /** Provider-owned model-request retry policy, already resolved. */ retryPolicy: ResolvedRetryPolicy } @@ -83,6 +89,8 @@ export interface DeepSeekAdapterOptions { resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise /** Resolve the harness-home anonymous id shared with telemetry and feedback. */ resolveUserId: () => AnonymousUserId + /** Resolve the current durable attachment service; absence rejects image input. */ + resolveAttachments?: () => AttachmentStore | undefined } /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -91,6 +99,8 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 export const DEFAULT_CONTEXT_WINDOW = 1_000_000 /** Default per-request output-token cap. */ export const DEFAULT_MAX_TOKENS = 256_000 +/** Default bound on accumulated base64 image payload per request. */ +export const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024 const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT' const OFF_REASONING_EFFORT = ReasoningEffortId('off') const LOW_REASONING_EFFORT = ReasoningEffortId('low') @@ -112,7 +122,7 @@ function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo id: model.id, name: model.name ?? model.id, ...model.description === undefined ? {} : { description: model.description }, - inputModalities: ['text'], + inputModalities: model.inputModalities ?? ['text'], } } @@ -139,6 +149,7 @@ function requestId(headers: Headers): ReturnType | und */ export function httpErrorCode(status: number, error?: WireError['error']): string { if (status === 401 || status === 403) return 'AUTH' + if (status === 413) return 'INVALID_REQUEST' const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ') if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE if (status === 429) return 'RATE_LIMIT' @@ -184,10 +195,9 @@ export class DeepSeekAdapter extends LlmAdapter { const contextWindow = configured?.contextWindow ?? connection.defaultContextWindow return Promise.resolve({ - // The chat-completions wire route is text-only regardless of catalog - // membership, so the uncatalogued fallback declares the same negative - // capability — "unknown" here would let the host accept and persist - // images the serializer must then reject. + // An uncatalogued endpoint is safely treated as text-only. Declaring an + // unverified image capability would let the host persist input that the + // endpoint may reject on every later turn. ...configured === undefined ? { provider, id: model, name: model, inputModalities: ['text' as const] } : modelInfo(provider, configured), @@ -222,6 +232,24 @@ export class DeepSeekAdapter extends LlmAdapter { // The key resolves *from this snapshot*, so an endpoint and the secret // sent to it can never come from different configuration generations. const connection = this.config.options() + const hasImages = options.messages.some(message => contentHasImage(message.content)) + let attachments: AttachmentStore | undefined + if (hasImages) { + const model = connection.models.find(entry => entry.id === options.model) + if (model?.inputModalities?.includes('image') !== true) { + throw new LlmError( + `DeepSeek model "${options.model}" does not accept image input.`, + 'UNSUPPORTED_CONTENT', + ) + } + attachments = this.config.resolveAttachments?.() + if (attachments === undefined) { + throw new LlmError( + 'DeepSeek image conversion requires the durable attachment service.', + 'UNSUPPORTED_CONTENT', + ) + } + } const apiKey = await this.config.resolveApiKey(connection) const userId = this.config.resolveUserId() const consumer = new AbortController() @@ -235,6 +263,7 @@ export class DeepSeekAdapter extends LlmAdapter { connection, apiKey, userId, + attachments, () => { watchdog.pulse() }, )[Symbol.asyncIterator]() let exhausted = false @@ -278,9 +307,16 @@ export class DeepSeekAdapter extends LlmAdapter { connection: DeepSeekConnectionOptions, apiKey: string, userId: AnonymousUserId, + attachments: AttachmentStore | undefined, onComment: () => void, ): AsyncIterable { - const body = serializeRequest(options, connection.defaults) + const body = attachments === undefined + ? serializeRequest(options, connection.defaults) + : await serializeRequestWithImages(options, { + attachments, + maxRequestImageBytes: connection.maxRequestImageBytes, + signal, + }, connection.defaults) // Prepared outside the try so the TRANSPORT label below covers exactly the // transport boundary, never a serialization failure. const payload = JSON.stringify(body) diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index cbaa0c13b6..51382bb128 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -14,7 +14,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { assertUsableApiKey, LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' -import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' +import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { launchEnvironmentOf, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -22,6 +22,7 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { DEFAULT_CONTEXT_WINDOW, + DEFAULT_MAX_REQUEST_IMAGE_BYTES, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter, @@ -30,6 +31,7 @@ import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter. export { DEFAULT_CONTEXT_WINDOW, + DEFAULT_MAX_REQUEST_IMAGE_BYTES, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter, @@ -51,6 +53,8 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: DEFAULT_CONTEXT_WINDOW }, ] +const MODEL_MODALITIES = ['text', 'image'] as const satisfies readonly ModelModality[] + /** * Plugin config, validated by the same-named schemastery schema and doubling * as the `llm-deepseek` settings-section shape. Every field is optional in @@ -76,6 +80,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Maximum accumulated base64 image payload per request (default 20 MiB). */ + maxRequestImageBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -86,6 +92,7 @@ const catalogModel: z = z.object({ description: z.string(), contextWindow: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1), + inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(['text']), }) export const Config: z = z.object({ @@ -97,6 +104,7 @@ export const Config: z = z.object({ defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), models: z.array(catalogModel).default(DEFAULT_MODELS), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), + maxRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_IMAGE_BYTES), retryPolicy: RetryPolicySchema, }) @@ -134,6 +142,18 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee `llm-deepseek: catalog model "${model.id}" maxTokens must be a positive integer`, ) } + const inputModalities = model.inputModalities ?? ['text'] + if (inputModalities.length === 0) { + throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not be empty`) + } + if (inputModalities.some(modality => !MODEL_MODALITIES.includes(modality))) { + throw new Error( + `llm-deepseek: catalog model "${model.id}" inputModalities must contain only "text" and "image"`, + ) + } + if (new Set(inputModalities).size !== inputModalities.length) { + throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not contain duplicates`) + } if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`) seen.add(model.id) return { @@ -142,6 +162,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee ...model.description === undefined ? {} : { description: model.description }, ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }, ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens }, + inputModalities: [...inputModalities], } }) } @@ -180,6 +201,10 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } + const maxRequestImageBytes = config.maxRequestImageBytes ?? DEFAULT_MAX_REQUEST_IMAGE_BYTES + if (!Number.isSafeInteger(maxRequestImageBytes) || maxRequestImageBytes <= 0) { + throw new Error('llm-deepseek: maxRequestImageBytes must be a positive safe integer') + } return { apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL: config.baseURL @@ -193,6 +218,7 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, models: resolveModels(config.models), streamIdleTimeoutMs, + maxRequestImageBytes, retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'), } } @@ -247,7 +273,12 @@ export function apply(ctx: Context, config: Config): void { let userId: AnonymousUserId | undefined const resolveUserId = (): AnonymousUserId => userId ??= getOrCreateAnonymousUserId() - const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId }) + const adapter = new DeepSeekAdapter({ + options, + resolveApiKey, + resolveUserId, + resolveAttachments: () => ctx.get('attachments'), + }) ctx.llm.registerConfigurableProviders([ { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] }, ]) diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index f51ef23031..da643aa599 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -1,15 +1,22 @@ /** - * Serialize harness messages into DeepSeek chat completions. User text is joined; assistant text - * becomes `content`, tool calls become `tool_calls`, and tool results become separate tool messages. - * Assistant reasoning is replayed as `reasoning_content` only on tool-call turns, as required by - * thinking-mode passback. Core image blocks are rejected explicitly because this wire route is text-only; - * unknown declaration-merged block types retain the adapter's documented extension fallback. + * Serialize harness messages into DeepSeek chat completions. Text-only + * requests retain string user content; the image path resolves durable + * attachments into ordered data-URL parts. Tool-result images follow their + * string-only tool messages in a separate user message. * @module dsh-llm-deepseek/serialize */ -import { contentHasImage, LlmError } from '@deepseek-ai/dsh-llm' +import { contentHasImage, LlmError, offloadRequestImages } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import type { WireMessage, WireRequest, WireTool } from './types.ts' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { + WireImageContentPart, + WireMessage, + WireRequest, + WireTool, + WireUserContentPart, +} from './types.ts' /** Adapter-level request defaults (from plugin config). */ export interface RequestDefaults { @@ -22,6 +29,18 @@ interface ResolvedThinking { reasoningEffort?: 'low' | 'high' | 'max' } +/** Dependencies required only when the request contains image input. */ +export interface ImageSerializationOptions { + /** Durable resolver for canonical image references. */ + attachments: AttachmentStore + /** Positive bound on accumulated base64 image payload. */ + maxRequestImageBytes: number + /** Cancellation shared with the provider request. */ + signal: AbortSignal +} + +const TOOL_RESULT_IMAGE_TEXT = 'Attached image(s) from tool result:' + /** Validate the adapter-owned effort before resolving its DeepSeek wire fields. */ function reasoningEffort(effort: NonNullable): 'off' | 'low' | 'high' | 'max' { if (effort === 'off' || effort === 'low' || effort === 'high' || effort === 'max') { @@ -67,6 +86,76 @@ function assertTextOnly(blocks: readonly ContentBlock[]): void { } } +/** Reject roles whose DeepSeek history format cannot carry image input. */ +function assertSupportedImageRoles(messages: readonly Message[]): void { + for (const message of messages) { + if (message.role !== 'user' && contentHasImage(message.content)) { + throw new LlmError( + `The DeepSeek chat-completions adapter cannot represent image content in a ${message.role} message.`, + 'UNSUPPORTED_CONTENT', + ) + } + } +} + +/** Resolve one durable image into its transient DeepSeek data-URL part. */ +async function imagePart( + block: Extract, + attachments: AttachmentStore, + signal: AbortSignal, +): Promise { + try { + const stored = await attachments.readImage(block.attachment, signal) + return { + type: 'image_url', + image_url: { + url: `data:${stored.ref.mediaType};base64,${Buffer.from(stored.data).toString('base64')}`, + }, + } + } catch (error: unknown) { + if (error instanceof AttachmentError) { + throw new LlmError(error.message, error.code, { cause: error }) + } + throw error + } +} + +/** Convert user or nested tool-result blocks into ordered wire parts. */ +async function contentParts( + blocks: readonly ContentBlock[], + attachments: AttachmentStore, + signal: AbortSignal, +): Promise { + const parts: WireUserContentPart[] = [] + for (const block of blocks) { + switch (block.type) { + case 'text': + if (block.text.length > 0) parts.push({ type: 'text', text: block.text }) + break + case 'image': + parts.push(await imagePart(block, attachments, signal)) + break + case 'tool-result': + parts.push(...await contentParts(block.content, attachments, signal)) + break + default: + // Other merge-extensible blocks are not DeepSeek user-input vocabulary. + break + } + } + return parts +} + +/** Keep text-only user messages on the compact string wire form. */ +function userContent(parts: readonly WireUserContentPart[]): string | WireUserContentPart[] { + const text: string[] = [] + for (const part of parts) { + if (part.type === 'image_url') return [...parts] + text.push(part.text) + } + return text.join('') +} + /** Serialize one assistant message (text + reasoning + tool calls). */ function serializeAssistant(message: Message): WireMessage { const text = flattenText(message.content) @@ -140,6 +229,103 @@ export function serializeMessages(messages: Message[]): WireMessage[] { return wire } +/** + * Serialize image-capable history after resolving durable attachments. + * Consecutive tool results keep string `tool` messages and share one following + * user message containing their images. + * @param messages - transient request history after request-size offloading. + * @param attachments - durable image resolver. + * @param signal - cancellation for attachment reads. + * @returns ordered DeepSeek wire messages. + */ +export async function serializeMessagesWithImages( + messages: readonly Message[], + attachments: AttachmentStore, + signal: AbortSignal, +): Promise { + assertSupportedImageRoles(messages) + const wire: WireMessage[] = [] + let pendingToolImages: WireImageContentPart[] = [] + const flushToolImages = (): void => { + if (pendingToolImages.length === 0) return + wire.push({ + role: 'user', + content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages], + }) + pendingToolImages = [] + } + + for (const message of messages) { + if (message.role === 'system') { + flushToolImages() + wire.push({ role: 'system', content: flattenText(message.content) }) + continue + } + if (message.role === 'assistant') { + flushToolImages() + wire.push(serializeAssistant(message)) + continue + } + + const regular = message.content.filter(block => block.type !== 'tool-result') + const toolResults = message.content.filter((block): block is Extract => ( + block.type === 'tool-result' + )) + const content = userContent(await contentParts(regular, attachments, signal)) + if (content.length > 0 || toolResults.length === 0) { + flushToolImages() + wire.push({ + role: 'user', + content, + }) + } + for (const result of toolResults) { + const parts = await contentParts(result.content, attachments, signal) + const images = parts.filter((part): part is WireImageContentPart => part.type === 'image_url') + const text = parts.filter(part => part.type === 'text').map(part => part.text).join('') + wire.push({ + role: 'tool', + tool_call_id: result.toolCallId, + content: text || (images.length > 0 ? '(see attached image)' : '(no output)'), + }) + pendingToolImages.push(...images) + } + } + flushToolImages() + return wire +} + +/** Assemble request fields shared by text-only and image-capable conversion. */ +function requestWithMessages( + options: GenerateOptions, + messages: WireMessage[], + defaults: RequestDefaults, +): WireRequest { + const tools: WireTool[] | undefined = options.tools?.map(tool => ({ + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }, + })) + const resolvedThinking = resolveThinking(options, defaults) + return { + model: options.model, + messages, + stream: true, + stream_options: { include_usage: true }, + ...resolvedThinking.thinking !== undefined ? { thinking: { type: resolvedThinking.thinking } } : {}, + ...resolvedThinking.reasoningEffort !== undefined + ? { reasoning_effort: resolvedThinking.reasoningEffort } + : {}, + ...tools !== undefined && tools.length > 0 ? { tools } : {}, + ...options.temperature !== undefined ? { temperature: options.temperature } : {}, + ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }, + ...options.stop !== undefined ? { stop: options.stop } : {}, + } +} + /** * Build the full wire request. Always streaming (`stream: true`, usage * reporting on); optional fields are omitted rather than sent as null, so @@ -158,30 +344,29 @@ export function serializeRequest( } messages.push(...serializeMessages(options.messages)) - const tools: WireTool[] | undefined = options.tools?.map(tool => ({ - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - }, - })) - // A short title budget must produce visible text; conversation and - // compaction calls continue to inherit the adapter's thinking defaults. - const resolvedThinking = resolveThinking(options, defaults) + return requestWithMessages(options, messages, defaults) +} - return { - model: options.model, - messages, - stream: true, - stream_options: { include_usage: true }, - ...resolvedThinking.thinking !== undefined ? { thinking: { type: resolvedThinking.thinking } } : {}, - ...resolvedThinking.reasoningEffort !== undefined - ? { reasoning_effort: resolvedThinking.reasoningEffort } - : {}, - ...tools !== undefined && tools.length > 0 ? { tools } : {}, - ...options.temperature !== undefined ? { temperature: options.temperature } : {}, - ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }, - ...options.stop !== undefined ? { stop: options.stop } : {}, +/** + * Build one image-capable request while keeping durable bytes out of session + * messages. Oversized oldest images become deterministic text before any + * attachment read. + * @param options - harness request containing image-capable user content. + * @param images - attachment resolver, request bound, and cancellation. + * @param defaults - adapter-level thinking defaults. + * @returns the fully materialized DeepSeek request body. + */ +export async function serializeRequestWithImages( + options: GenerateOptions, + images: ImageSerializationOptions, + defaults: RequestDefaults = {}, +): Promise { + assertSupportedImageRoles(options.messages) + const requestMessages = offloadRequestImages(options.messages, images.maxRequestImageBytes) + const messages: WireMessage[] = [] + if (options.system !== undefined) { + messages.push({ role: 'system', content: options.system }) } + messages.push(...await serializeMessagesWithImages(requestMessages, images.attachments, images.signal)) + return requestWithMessages(options, messages, defaults) } diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index ec3f28cbf4..1781ed8646 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -35,10 +35,25 @@ export interface WireSystemMessage { content: string } -/** User-role message: a single string of user input. */ +/** Text part inside a multimodal user message. */ +export interface WireTextContentPart { + type: 'text' + text: string +} + +/** Base64 data URL part inside a multimodal user message. */ +export interface WireImageContentPart { + type: 'image_url' + image_url: { url: string } +} + +/** Ordered input part accepted by a multimodal user message. */ +export type WireUserContentPart = WireTextContentPart | WireImageContentPart + +/** User-role message: text-only string or ordered multimodal input. */ export interface WireUserMessage { role: 'user' - content: string + content: string | WireUserContentPart[] } /** Tool-role message: the result of one tool call, keyed by its call id. */ diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ce56635ee7..6d86691e5c 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -3,6 +3,8 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import LlmRuntime, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, @@ -47,15 +49,31 @@ async function harness(baseURL: string, config: object = {}) { } /** Direct adapter over the plugin's real resolve step, with a static key. */ -function adapterOf(config: Partial & { apiKey?: string } = {}): DeepSeekAdapter { +function adapterOf( + config: Partial & { apiKey?: string } = {}, + attachments?: AttachmentStore, +): DeepSeekAdapter { const { apiKey, ...rest } = config return new DeepSeekAdapter({ options: () => resolveAdapterOptions(rest), resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), resolveUserId: () => TEST_USER_ID, + resolveAttachments: () => attachments, }) } +async function drain(stream: AsyncIterable): Promise { + for await (const _chunk of stream) { /* drain */ } +} + +const imageRef: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, +} + describe('DeepSeekAdapter against a mock server', () => { it('streams a text generation end to end through the assembler', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) @@ -90,6 +108,96 @@ describe('DeepSeekAdapter against a mock server', () => { expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-compact') }) + it('sends a durable image as a base64 data URL for the vision model', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const signalSeen: (AbortSignal | undefined)[] = [] + const attachments = { + readImage: vi.fn((ref: ImageAttachmentRef, signal?: AbortSignal) => { + signalSeen.push(signal) + return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) }) + }), + } as unknown as AttachmentStore + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'text', text: 'describe ' }, + { type: 'image', attachment: imageRef }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + + expect(server.requests[0]).toMatchObject({ + model: 'deepseek-v4-flash-vision-exp', + messages: [{ + role: 'user', + content: [ + { type: 'text', text: 'describe ' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } }, + ], + }], + }) + expect(signalSeen[0]).toBeInstanceOf(AbortSignal) + }) + + it.each(['deepseek-v4-flash', 'unlisted-pass-through'])( + 'rejects image input for text-only model %s before credentials, attachments, or fetch', + async (model) => { + const server = await mockServer([]) + const resolveApiKey = vi.fn(() => Promise.resolve('k')) + const resolveAttachments = vi.fn(() => ({}) as AttachmentStore) + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey, + resolveUserId: () => TEST_USER_ID, + resolveAttachments, + }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model, + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(resolveApiKey).not.toHaveBeenCalled() + expect(resolveAttachments).not.toHaveBeenCalled() + expect(server.requests).toHaveLength(0) + }, + ) + + it('rejects vision input without an attachment provider before credentials or fetch', async () => { + const server = await mockServer([]) + const resolveApiKey = vi.fn(() => Promise.resolve('k')) + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }), + resolveApiKey, + resolveUserId: () => TEST_USER_ID, + }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(resolveApiKey).not.toHaveBeenCalled() + expect(server.requests).toHaveLength(0) + }) + it('streams raw chunks through ctx.llm.stream', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 2 }]) const ctx = await harness(server.url) @@ -386,7 +494,7 @@ describe('DeepSeekAdapter against a mock server', () => { .toBe(CONTEXT_WINDOW_EXCEEDED_CODE) expect(httpErrorCode(400, { message: 'invalid input: temperature exceeds maximum allowed value' })) .toBe('INVALID_REQUEST') - expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('HTTP_413') + expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('INVALID_REQUEST') }) it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => { @@ -760,6 +868,21 @@ describe('plugin registration and config', () => { ]) }) + it('defaults an adapter-supplied catalog entry to text input', async () => { + const connection = resolveAdapterOptions({ models: [] }) + const adapter = new DeepSeekAdapter({ + options: () => ({ ...connection, models: [{ id: 'adapter-model' }] }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + }) + await expect(adapter.listModels('deepseek-official')).resolves.toEqual([{ + provider: 'deepseek-official', + id: 'adapter-model', + name: 'adapter-model', + inputModalities: ['text'], + }]) + }) + it('advertises configured models without restricting arbitrary request ids', async () => { const ctx = new Context() await ctx.plugin(LlmRuntime) @@ -772,12 +895,13 @@ describe('plugin registration and config', () => { name: 'Private Reasoner', description: 'Higher reasoning budget', contextWindow: 64_000, + inputModalities: ['text', 'image'], }, ], }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] }, - { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'] }, + { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text', 'image'] }, ]) await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-fast')) .resolves.toMatchObject({ context: { contextWindow: 32_000 } }) @@ -785,6 +909,7 @@ describe('plugin registration and config', () => { .resolves.toMatchObject({ name: 'Private Reasoner', description: 'Higher reasoning budget', + inputModalities: ['text', 'image'], }) await expect(ctx.llm.resolveModelInfo('deepseek-official', 'arbitrary-unlisted')) .resolves.toMatchObject({ @@ -823,13 +948,21 @@ describe('plugin registration and config', () => { await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([]) }) - it.each([ + const invalidModels: Array<[LlmDeepSeek.DeepSeekCatalogModel[], RegExp]> = [ [[{ id: '' }], /ids must be non-empty/], [[{ id: 'm', name: '' }], /empty name/], [[{ id: 'm', contextWindow: 0 }], /contextWindow/], [[{ id: 'm', contextWindow: 1.5 }], /contextWindow/], + [[{ id: 'm', inputModalities: [] }], /inputModalities/], + [[{ id: 'm', inputModalities: ['text', 'text'] }], /inputModalities must not contain duplicates/], + [[{ + id: 'm', + inputModalities: ['audio'] as unknown as NonNullable, + }], /expected "text" \| "image"/], [[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/], - ] as const)('rejects invalid advisory model config', async (models, message) => { + ] + + it.each(invalidModels)('rejects invalid advisory model config', async (models, message) => { const ctx = new Context() await ctx.plugin(LlmRuntime) await expect(ctx.plugin(LlmDeepSeek, { @@ -839,6 +972,18 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([]) }) + const invalidProgrammaticModalities: Array<[LlmDeepSeek.DeepSeekCatalogModel[], RegExp]> = [ + [[{ id: 'm', inputModalities: [] }], /inputModalities must not be empty/], + [[{ + id: 'm', + inputModalities: ['audio'] as unknown as NonNullable, + }], /inputModalities must contain only "text" and "image"/], + ] + + it.each(invalidProgrammaticModalities)('rejects programmatic modality config that bypasses the schema', (models, message) => { + expect(() => resolveAdapterOptions({ models: [...models] })).toThrow(message) + }) + it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => { expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] })) .toThrow(/maxTokens must be a positive integer/) @@ -903,6 +1048,22 @@ describe('plugin registration and config', () => { }, ) + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid request image bound %s', + async (maxRequestImageBytes) => { + expect(() => resolveAdapterOptions({ maxRequestImageBytes })) + .toThrow(/maxRequestImageBytes must be a positive safe integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + maxRequestImageBytes, + })).rejects.toThrow(/maxRequestImageBytes/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1') diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 84e78819b0..be7d4e3688 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -3,7 +3,14 @@ import { Context } from '@deepseek-ai/cordis' import { access, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import LlmRuntime, { INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { createUserMessage, INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm' +import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -14,6 +21,36 @@ import { closeMockServers, mockServer, textEvents } from './mock-server.ts' const NS = settingsNamespace('llm-deepseek') const KEY_REF = credentialRef('DEEPSEEK_API_KEY') +const IMAGE_REF: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, +} + +class StaticAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: 16, + maxImagesPerMessage: 4, + maxMessageImageBytes: 64, + maxImagePixels: 4, + maxImageDimension: 4, + mediaTypes: ['image/png'], + } + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.resolve() + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.resolve(IMAGE_REF) + } + + readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { + return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) }) + } +} const cleanups: Array<() => Promise> = [] @@ -47,6 +84,7 @@ async function boot(dir: string, config: object): Promise { await ctx.fiber.dispose() }) await ctx.plugin(LlmRuntime) + await ctx.plugin(StaticAttachmentStore) const settingsFiber = ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false }) await settingsFiber await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) @@ -118,12 +156,44 @@ describe('request-level dynamic configuration', () => { const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) - await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] }) + await ctx.settings.update(NS, { + models: [{ id: 'settings-model', name: 'From Settings', inputModalities: ['text', 'image'] }], + }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'settings-model', name: 'From Settings', inputModalities: ['text'] }, + { provider: 'deepseek-official', id: 'settings-model', name: 'From Settings', inputModalities: ['text', 'image'] }, ]) }) + it('applies a changed request image bound to the next request', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + const dir = await home() + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const { ctx } = await boot(dir, { + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }) + const messages = [createUserMessage({ + content: [ + { type: 'image', attachment: IMAGE_REF }, + { type: 'image', attachment: IMAGE_REF }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })] + + await assemble(ctx, { model: 'deepseek-v4-flash-vision-exp', messages }) + await ctx.settings.update(NS, { maxRequestImageBytes: 4 }) + await assemble(ctx, { model: 'deepseek-v4-flash-vision-exp', messages }) + + const first = (server.requests[0] as { messages: Array<{ content: unknown }> }).messages[0]?.content + const second = (server.requests[1] as { messages: Array<{ content: unknown }> }).messages[0]?.content + expect(JSON.stringify(first).match(/"type":"image_url"/g)).toHaveLength(2) + expect(JSON.stringify(second)).toContain('[image omitted to keep the request within its image limit') + expect(JSON.stringify(second).match(/"type":"image_url"/g)).toHaveLength(1) + }) + it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => { const dir = await home() const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 714cfc5d27..d503a8b5c7 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,13 +1,38 @@ -import { describe, expect, it } from 'vitest' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { describe, expect, it, vi } from 'vitest' +import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import { createUserMessage, CallId, ReasoningEffortId, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import { serializeMessages, serializeRequest } from '../src/serialize.ts' +import { + serializeMessages, + serializeMessagesWithImages, + serializeRequest, + serializeRequestWithImages, +} from '../src/serialize.ts' function request(overrides: Partial = {}): GenerateOptions { return { provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [], ...overrides } } +function imageRef(mediaType: ImageMediaType = 'image/png', bytes = 3): ImageAttachmentRef { + return { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType, + bytes, + width: 1, + height: 1, + } +} + +function attachmentStore( + readImage = vi.fn((ref: ImageAttachmentRef, _signal?: AbortSignal) => Promise.resolve({ + ref, + data: Uint8Array.of(1, 2, 3), + })), +): AttachmentStore { + return { readImage } as unknown as AttachmentStore +} + describe('serializeMessages', () => { it('maps user text to string content', () => { const wire = serializeMessages([ @@ -270,6 +295,288 @@ describe('serializeRequest', () => { }) }) +describe('image serialization', () => { + it.each([ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', + ] as const)('preserves ordered text and %s image parts', async (mediaType) => { + const signal = new AbortController().signal + const readImage = vi.fn((ref: ImageAttachmentRef, received?: AbortSignal) => { + expect(received).toBe(signal) + return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) }) + }) + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'text', text: 'before' }, + { type: 'image', attachment: imageRef(mediaType) }, + { type: 'text', text: 'after' }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), { + attachments: attachmentStore(readImage), + maxRequestImageBytes: 20 * 1024 * 1024, + signal, + }) + + expect(wire.messages).toEqual([{ + role: 'user', + content: [ + { type: 'text', text: 'before' }, + { type: 'image_url', image_url: { url: `data:${mediaType};base64,AQID` } }, + { type: 'text', text: 'after' }, + ], + }]) + }) + + it('serializes image-only user content without synthetic text', async () => { + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef() }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), { + attachments: attachmentStore(), + maxRequestImageBytes: 20 * 1024 * 1024, + signal: new AbortController().signal, + }) + + expect(wire.messages).toEqual([{ + role: 'user', + content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } }], + }]) + }) + + it('keeps tool content textual and groups consecutive tool-result images afterward', async () => { + const messages = [ + createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId('first'), + content: [{ type: 'image', attachment: imageRef() }], + }], + source: { kind: 'plugin', plugin: 'test' }, + }), + createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId('second'), + content: [ + { type: 'text', text: 'caption' }, + { type: 'image', attachment: imageRef('image/jpeg') }, + ], + }], + source: { kind: 'plugin', plugin: 'test' }, + }), + ] + + await expect(serializeMessagesWithImages( + messages, + attachmentStore(), + new AbortController().signal, + )).resolves.toEqual([ + { role: 'tool', tool_call_id: 'first', content: '(see attached image)' }, + { role: 'tool', tool_call_id: 'second', content: 'caption' }, + { + role: 'user', + content: [ + { type: 'text', text: 'Attached image(s) from tool result:' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } }, + { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,AQID' } }, + ], + }, + ]) + }) + + it('does not emit an empty user message for ignored content beside a tool result', async () => { + const messages = [createUserMessage({ + content: [ + { type: 'text', text: '' }, + { type: 'chart', data: 'ignored' } as unknown as ContentBlock, + { + type: 'tool-result', + toolCallId: CallId('result'), + content: [{ type: 'text', text: 'ok' }], + }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })] + + await expect(serializeMessagesWithImages( + messages, + attachmentStore(), + new AbortController().signal, + )).resolves.toEqual([ + { role: 'tool', tool_call_id: 'result', content: 'ok' }, + ]) + }) + + it('recursively converts nested tool-result content and preserves the empty fallback', async () => { + const messages = [createUserMessage({ + content: [ + { + type: 'tool-result', + toolCallId: CallId('nested'), + content: [{ + type: 'tool-result', + toolCallId: CallId('inner'), + content: [{ type: 'text', text: 'inside' }], + }], + }, + { type: 'tool-result', toolCallId: CallId('empty'), content: [] }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })] + + await expect(serializeMessagesWithImages( + messages, + attachmentStore(), + new AbortController().signal, + )).resolves.toEqual([ + { role: 'tool', tool_call_id: 'nested', content: 'inside' }, + { role: 'tool', tool_call_id: 'empty', content: '(no output)' }, + ]) + }) + + it('flushes tool-result images before system and assistant history', async () => { + const imageResult = (id: string) => createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId(id), + content: [{ type: 'image', attachment: imageRef() }], + }], + source: { kind: 'plugin' as const, plugin: 'test' }, + }) + const messages = [ + imageResult('before-system'), + createMessage({ + role: 'system', + content: [{ type: 'text', text: 'system history' }], + source: { kind: 'plugin', plugin: 'test' }, + }), + imageResult('before-assistant'), + createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'assistant history' }], + source: { kind: 'plugin', plugin: 'test' }, + }), + ] + + const wire = await serializeMessagesWithImages( + messages, + attachmentStore(), + new AbortController().signal, + ) + expect(wire).toEqual([ + { role: 'tool', tool_call_id: 'before-system', content: '(see attached image)' }, + expect.objectContaining({ role: 'user' }), + { role: 'system', content: 'system history' }, + { role: 'tool', tool_call_id: 'before-assistant', content: '(see attached image)' }, + expect.objectContaining({ role: 'user' }), + { role: 'assistant', content: 'assistant history' }, + ]) + }) + + it('offloads oldest images before reads and keeps the newest image', async () => { + const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ + ref, + data: Uint8Array.of(1, 2, 3), + })) + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef('image/png', 3) }, + { type: 'image', attachment: imageRef('image/jpeg', 3) }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), { + attachments: attachmentStore(readImage), + maxRequestImageBytes: 4, + signal: new AbortController().signal, + }) + + expect(wire.messages[0]).toMatchObject({ + role: 'user', + content: [ + { type: 'text', text: expect.stringContaining('older images are omitted first') as string }, + { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,AQID' } }, + ], + }) + expect(readImage).toHaveBeenCalledTimes(1) + expect(readImage.mock.calls[0]?.[0]).toMatchObject({ mediaType: 'image/jpeg' }) + }) + + it.each(['system', 'assistant'] as const)('rejects an image in %s history before reading attachments', async (role) => { + const readImage = vi.fn() + await expect(serializeMessagesWithImages([createMessage({ + role, + content: [{ type: 'image', attachment: imageRef() }], + source: { kind: 'plugin', plugin: 'test' }, + })], attachmentStore(readImage), new AbortController().signal)) + .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(readImage).not.toHaveBeenCalled() + }) + + it('rejects unsupported image history before request offloading can replace it', async () => { + const readImage = vi.fn() + await expect(serializeRequestWithImages(request({ + messages: [createMessage({ + role: 'system', + content: [{ type: 'image', attachment: imageRef('image/png', 300) }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), { + attachments: attachmentStore(readImage), + maxRequestImageBytes: 1, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(readImage).not.toHaveBeenCalled() + }) + + it('prepends the request system prompt on the image path', async () => { + const wire = await serializeRequestWithImages(request({ + system: 'system prompt', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef() }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), { + attachments: attachmentStore(), + maxRequestImageBytes: 20 * 1024 * 1024, + signal: new AbortController().signal, + }) + expect(wire.messages[0]).toEqual({ role: 'system', content: 'system prompt' }) + }) + + it('preserves stable attachment failure codes', async () => { + const readImage = vi.fn(() => Promise.reject(new AttachmentError( + 'Stored attachment bytes are corrupt.', + 'ATTACHMENT_CORRUPT', + ))) + await expect(serializeMessagesWithImages([createUserMessage({ + content: [{ type: 'image', attachment: imageRef() }], + source: { kind: 'plugin', plugin: 'test' }, + })], attachmentStore(readImage), new AbortController().signal)) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) + + it('preserves non-attachment resolver failures', async () => { + const failure = new Error('resolver failed') + const readImage = vi.fn(() => Promise.reject(failure)) + await expect(serializeMessagesWithImages([createUserMessage({ + content: [{ type: 'image', attachment: imageRef() }], + source: { kind: 'plugin', plugin: 'test' }, + })], attachmentStore(readImage), new AbortController().signal)).rejects.toBe(failure) + }) +}) + describe('review fixes: assistant content shapes', () => { it('serializes a content-less, tool-call-less assistant message as "" content, never null', () => { // Aborted/empty assistant turns: no text, no calls → "". The earlier diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 694da1fb3c..6ea69058b9 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.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 packages/llm/llm-pi-ai/README.md -README.md: f696b6bee50b844bfbc6bab7f9def0e785d450c9 -README.zh.md: cace9d1fdfd85a559674b121b79b3dbb86b4337b +README.md: 6d62aabf954a80120bacc9049a7498af269dd067 +README.zh.md: e7e832fa356c48bf14d7aebac7351b259b924214 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index f696b6bee5..6d62aabf95 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -51,9 +51,12 @@ Configure credentials, the model catalog, and deployment-specific transport sett apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 - # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + # Request shape for an endpoint whose URL pi-ai cannot recognize; it + # would otherwise be addressed as though it were OpenAI itself. compat: thinkingFormat: deepseek + supportsDeveloperRole: false + maxTokensField: max_tokens models: - id: acme-large name: Acme Large @@ -85,9 +88,13 @@ A profile's `models` list *replaces* the route's installed catalog rather than e The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with every level decided explicitly — undeclared levels are pinned unsupported rather than left to pi-ai's own defaulting, which is asymmetric (an absent key means "supported" for the five base levels but "unsupported" for `xhigh`/`max`) and which a profile author should not need to know. `off` is the one three-state key: left out, selectors offer no Off and an explicit Off request is refused — a request naming no effort still goes out without the parameter, so what the provider then does is its own default; declared with no value (`off:`), Off is offered and selecting it sends nothing — for the `deepseek` dialect an explicit `thinking: {type: "disabled"}` — which also covers a request naming no effort at all; declared with a value (`off: none`), that value goes on the wire as the effort parameter. There is no spelling for restoring a catalog map key to "unset": the declaration is the whole offer, so restate the catalog levels you keep. -### Reasoning-dispatch compat switches +### Wire-compatibility switches -How a thinking level travels — `reasoning_effort` alone, DeepSeek's `thinking: {type}` plus effort, z.ai's `thinking` object, and so on — is pi-ai's `compat.thinkingFormat`, which pi-ai guesses from the endpoint URL; a private gateway's URL says nothing, so a DeepSeek-dialect gateway would be spoken to in the OpenAI dialect with no way to correct it. `compat.thinkingFormat` and `compat.supportsReasoningEffort` are therefore configurable on the route (its models' default) and per model (winning per field), resolving model → route → installed catalog entry → pi-ai's URL-derived guess; setting a route-level switch shadows the catalog entry's value for every model on the route, and there is no spelling for handing a field back to the catalog short of restating its value. `thinkingFormat` accepts pi-ai's dispatchable formats except the two `chat-template` variants, which need `chatTemplateKwargs` this configuration does not expose. Both switches exist only on `openai-completions` — the other protocols carry their reasoning shape in the protocol itself — so a model-level switch elsewhere fails resolution, a route-level one skips models of other protocols, and a route with no `openai-completions` model at all is refused. The rest of pi-ai's compat surface (`supportsStore`, `maxTokensField`, …) stays auto-detected and is deliberately not configurable here. +pi-ai shapes each request from the provider id and baseURL: which role carries the system prompt, which field caps output, how a thinking level travels. A private gateway's URL says nothing, and for an endpoint pi-ai does not recognize the detection answers as though it were OpenAI itself — a reasoning model's system prompt goes out as `developer`, the output cap as `max_completion_tokens`, the thinking level as a bare `reasoning_effort` — and most OpenAI-compatible gateways reject at least one of those. `compat` is therefore configurable on the route (its models' default) and per model (winning per field), resolving model → route → installed catalog entry → pi-ai's own detection; a route-level switch shadows the catalog entry's value for every model that reads it, and there is no spelling for handing a field back to the catalog short of restating its value. + +Each switch belongs to the protocols whose pi-ai compat type declares it, and grouping follows the compat *type* rather than the protocol name: the three Responses protocols (`openai-responses`, `azure-openai-responses`, `openai-codex-responses`) share one compat type, so a switch settable on one is settable on all three. `supportsDeveloperRole` is settable on `openai-completions` and on those three; `thinkingFormat` only on `openai-completions`; `supportsTemperature` only on `anthropic-messages`; `supportsStrictMode` also reaches `bedrock-converse-stream`. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it at all. + +Three kinds of key are refused rather than dropped: one no protocol declares (a misspelling), one pi-ai's installed catalog owns for a named vendor (`openRouterRouting`, `zaiToolStream`, `deferredToolsMode`, `sessionAffinityFormat`, `supportsOpenAIGrammarTools`, `supportsToolSearch`, `supportsExplicitPromptCacheMode`, `supportsToolReferences`, `vercelGatewayRouting`, `sendSessionAffinityHeaders`) — a route needing a vendor's own switch is a catalog route that should be named as such — and one written with no value at all (`supportsDeveloperRole:`), which schemastery passes through as null and which would otherwise replace the installed catalog's value with nothing. The offered set is pinned to pi-ai's four compat types by drift gates, the protocols carrying them are derived from `Model.compat` itself, and each field's type is derived from upstream rather than restated, so an upgrade that adds a field, gives a further protocol a compat type, or widens a value union fails the build until someone classifies it. A model neither the entry nor the installed catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768), so a listing that discloses nothing but ids still yields a serviceable route. Both fallbacks are guesses by construction, which is why they are route fields a deployment whose gateway serves smaller models corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index cace9d1fdf..e7e832fa35 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -51,9 +51,12 @@ apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 - # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + # Request shape for an endpoint whose URL pi-ai cannot recognize; it + # would otherwise be addressed as though it were OpenAI itself. compat: thinkingFormat: deepseek + supportsDeveloperRole: false + maxTokensField: max_tokens models: - id: acme-large name: Acme Large @@ -85,9 +88,13 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,其中每个档位都被显式决定——未声明的档位一律固定为不支持,而不是留给 pi-ai 自己的默认规则:那套规则并不对称(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」),也本不该要求 profile 作者了解。`off` 是唯一的三态键:不写它,选择器不提供 Off,显式请求 Off 会被拒绝——不点名任何档位的请求仍会在不带该参数的情况下发出,提供方随后做什么是它自己的默认行为;声明而不给值(`off:`),则会提供 Off,选中它时什么也不发送——对 `deepseek` 方言则是一个显式的 `thinking: {type: "disabled"}`——这同时覆盖完全不点名任何档位的请求;声明并给值(`off: none`),该值就会作为档位参数在协议中发送。没有任何写法能把 catalog 映射中的键恢复为「未设置」:这份声明就是对外提供的全部,因此把你要保留的 catalog 档位重述出来。 -### 推理分派的 compat 开关 +### 协议兼容开关 -思考级别如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加上档位、z.ai 的 `thinking` 对象,诸如此类——就是 pi-ai 的 `compat.thinkingFormat`,pi-ai 会从端点 URL 猜测它;私有网关的 URL 什么也说明不了,于是说 DeepSeek 方言的网关只会收到 OpenAI 方言的请求,且无从更正。因此 `compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 既可配置在路由上(作为其模型的默认值),也可按模型配置(逐字段胜出),解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测;设置路由级开关会为路由上的每个模型遮蔽 catalog 条目的值,而且除了重述其值,没有任何写法能把某个字段交还给 catalog。`thinkingFormat` 接受 pi-ai 可分派的各种格式,但不含两个 `chat-template` 变体:它们需要的 `chatTemplateKwargs` 本配置并不暴露。两个开关都只存在于 `openai-completions` 上——其余协议的推理形状由协议本身承载——因此在其他协议的模型上设置模型级开关会使解析失败,路由级开关会跳过其他协议的模型,而完全没有 `openai-completions` 模型的路由则会被拒绝。pi-ai compat 面的其余部分(`supportsStore`、`maxTokensField`……)保持自动检测,特意不在此处开放配置。 +pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示词由哪个角色承载、输出上限写在哪个字段、思考级别如何传输。私有网关的 URL 什么也说明不了,而对于 pi-ai 无法识别的端点,其检测会当作 OpenAI 本身来回答——推理模型的系统提示词以 `developer` 发出、输出上限写作 `max_completion_tokens`、思考级别只发一个裸的 `reasoning_effort`——而多数 OpenAI 兼容网关至少会拒绝其中之一。因此 `compat` 既可配置在路由上(作为其模型的默认值),也可按模型配置(逐字段胜出),解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 自身的检测;路由级开关会为每个读取它的模型遮蔽 catalog 条目的值,而且除了重述其值,没有任何写法能把某个字段交还给 catalog。 + +每个开关归属于其 pi-ai compat 类型声明了它的那些协议,且归组依据是 compat **类型**而非协议名:三个 Responses 协议(`openai-responses`、`azure-openai-responses`、`openai-codex-responses`)共用同一个 compat 类型,因此可设在其中之一的开关,三者皆可设。`supportsDeveloperRole` 可设在 `openai-completions` 与这三者上;`thinkingFormat` 只能设在 `openai-completions`;`supportsTemperature` 只能设在 `anthropic-messages`;`supportsStrictMode` 还可达 `bedrock-converse-stream`。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。 + +三类键会被拒绝而非丢弃:没有任何协议声明的键(笔误);pi-ai 已安装 catalog 为具名厂商掌管的键(`openRouterRouting`、`zaiToolStream`、`deferredToolsMode`、`sessionAffinityFormat`、`supportsOpenAIGrammarTools`、`supportsToolSearch`、`supportsExplicitPromptCacheMode`、`supportsToolReferences`、`vercelGatewayRouting`、`sendSessionAffinityHeaders`)——需要某厂商专属开关的路由,本就是一条应当以该厂商命名的 catalog 路由;以及完全没有写值的键(`supportsDeveloperRole:`),schemastery 会把它放行为 null,若照单收下就会用空值替换已安装 catalog 的值。开放集由漂移门禁钉在 pi-ai 的四个 compat 类型上,承载它们的协议集派生自 `Model.compat` 本身,每个字段的类型也派生自上游而非重述,因此上游新增字段、给别的协议加上 compat 类型、或拓宽某个值并集,都会使构建失败,直到有人为它做出分类。 条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`(262,144)与 `defaultMaxTokens`(32,768),因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成单次请求上限。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 7a4ff7e8a2..229e881c98 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -15,11 +15,16 @@ import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all' import type { + AnthropicMessagesCompat, Api, + BedrockCompat, + ChatTemplateKwargValue, + KnownApi, Model, ModelCost, ModelThinkingLevel, OpenAICompletionsCompat, + OpenAIResponsesCompat, Provider, ThinkingLevelMap, } from '@earendil-works/pi-ai' @@ -79,23 +84,16 @@ const THINKING_LEVEL_GATE: Record = { /** Every pi-ai thinking level a profile may declare, in escalation order. */ export const THINKING_LEVELS = Object.keys(THINKING_LEVEL_GATE) as readonly ModelThinkingLevel[] -/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ -type PiThinkingFormat = NonNullable - -/** - * pi-ai thinking formats a profile cannot name: both drive the request through - * `chatTemplateKwargs`, which this configuration does not expose. - */ -type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' - /** One reasoning-dispatch wire format a profile may name. */ -export type PiAiThinkingFormat = Exclude +export type PiAiThinkingFormat = NonNullable /** * The nameable reasoning-dispatch formats, most-reached first. The `Record` * key type is a drift gate: a pi-ai upgrade that adds a format (0.84 added - * `baseten`) fails compilation here until the format is classified as offered - * here or withheld above, so the offer never silently lags the upstream set. + * `baseten`) fails compilation here until the new format is named, so the + * offer never silently lags the upstream set. The two `chat-template` variants + * are nameable because {@link PiAiCompatProfile.chatTemplateKwargs} carries + * the kwargs they dispatch through. */ const THINKING_FORMAT_GATE: Record = { 'openai': true, @@ -104,6 +102,8 @@ const THINKING_FORMAT_GATE: Record = { 'together': true, 'zai': true, 'qwen': true, + 'chat-template': true, + 'qwen-chat-template': true, 'string-thinking': true, 'ant-ling': true, } @@ -111,6 +111,41 @@ const THINKING_FORMAT_GATE: Record = { /** Reasoning-dispatch wire formats a profile may name, most-reached first. */ export const SUPPORTED_THINKING_FORMATS = Object.keys(THINKING_FORMAT_GATE) as readonly PiAiThinkingFormat[] +/** The output-cap field spellings pi-ai accepts. */ +export type PiAiMaxTokensField = NonNullable + +/** Drift gate over {@link PiAiMaxTokensField}; an upstream spelling added here fails compilation until named. */ +const MAX_TOKENS_FIELD_GATE: Record = { + max_completion_tokens: true, + max_tokens: true, +} + +/** The output-cap field spellings a profile may name. */ +export const MAX_TOKENS_FIELDS = Object.keys(MAX_TOKENS_FIELD_GATE) as readonly PiAiMaxTokensField[] + +/** The prompt-cache marker conventions pi-ai accepts. */ +export type PiAiCacheControlFormat = NonNullable + +/** Drift gate over {@link PiAiCacheControlFormat}; a new upstream convention fails compilation until named. */ +const CACHE_CONTROL_FORMAT_GATE: Record = { + anthropic: true, +} + +/** The prompt-cache marker conventions a profile may name. */ +export const CACHE_CONTROL_FORMATS = Object.keys(CACHE_CONTROL_FORMAT_GATE) as readonly PiAiCacheControlFormat[] + +/** The request-state placeholders a `chat_template_kwargs` value may name. */ +export type PiAiChatTemplateVar = Extract['$var'] + +/** Drift gate over {@link PiAiChatTemplateVar}; a new upstream placeholder fails compilation until named. */ +const CHAT_TEMPLATE_VAR_GATE: Record = { + 'thinking.enabled': true, + 'thinking.effort': true, +} + +/** The request-state placeholders a profile may name. */ +export const CHAT_TEMPLATE_VARS = Object.keys(CHAT_TEMPLATE_VAR_GATE) as readonly PiAiChatTemplateVar[] + let providerIndex: Map | undefined /** @@ -183,19 +218,336 @@ export function catalogModels(provider: string): Map> { export type PiAiReasoningEfforts = Partial> /** - * Reasoning-dispatch compatibility switches, set on the route (its models' - * default) or per model (winning over the route). Only the switches pi-ai's - * reasoning dispatch reads are offered; the rest of pi-ai's compat surface - * keeps its baseURL-derived auto-detection. pi-ai types both fields only on - * `OpenAICompletionsCompat` — the other wire protocols define their reasoning - * fields in the protocol itself — so resolution rejects a model-level switch - * anywhere else, while a route-level default skips past models it cannot fit. + * Whether one pi-ai compat field is configurable on a profile. + * + * `withhold` is the disposition for a field pi-ai's installed catalog already + * sets for a named vendor. Reaching for one of those on a hand-declared route + * means configuring a provider that should have been named as a catalog route + * instead, where the installed entry carries the right value already. + */ +type CompatDisposition = 'offer' | 'withhold' + +/** + * Disposition of every `OpenAICompletionsCompat` field. The `Record` key type + * is a drift gate: a pi-ai upgrade that adds a field fails compilation here + * until it is classified, so the offer never silently lags the upstream set. + */ +const COMPLETIONS_COMPAT_GATE = { + supportsStore: 'offer', + supportsDeveloperRole: 'offer', + supportsReasoningEffort: 'offer', + supportsUsageInStreaming: 'offer', + maxTokensField: 'offer', + requiresToolResultName: 'offer', + requiresAssistantAfterToolResult: 'offer', + requiresThinkingAsText: 'offer', + requiresReasoningContentOnAssistantMessages: 'offer', + thinkingFormat: 'offer', + chatTemplateKwargs: 'offer', + supportsStrictMode: 'offer', + cacheControlFormat: 'offer', + supportsLongCacheRetention: 'offer', + openRouterRouting: 'withhold', + vercelGatewayRouting: 'withhold', + zaiToolStream: 'withhold', + supportsOpenAIGrammarTools: 'withhold', + sendSessionAffinityHeaders: 'withhold', + deferredToolsMode: 'withhold', + sessionAffinityFormat: 'withhold', +} as const satisfies Record + +/** Disposition of every `OpenAIResponsesCompat` field; a drift gate like the one above. */ +const RESPONSES_COMPAT_GATE = { + supportsDeveloperRole: 'offer', + supportsStrictMode: 'offer', + supportsLongCacheRetention: 'offer', + sessionAffinityFormat: 'withhold', + supportsOpenAIGrammarTools: 'withhold', + supportsToolSearch: 'withhold', + supportsExplicitPromptCacheMode: 'withhold', +} as const satisfies Record + +/** Disposition of every `AnthropicMessagesCompat` field; a drift gate like the one above. */ +const ANTHROPIC_COMPAT_GATE = { + supportsEagerToolInputStreaming: 'offer', + supportsLongCacheRetention: 'offer', + supportsCacheControlOnTools: 'offer', + supportsTemperature: 'offer', + forceAdaptiveThinking: 'offer', + allowEmptySignature: 'offer', + supportsStrictTools: 'offer', + sendSessionAffinityHeaders: 'withhold', + supportsToolReferences: 'withhold', +} as const satisfies Record + +/** Disposition of every `BedrockCompat` field; a drift gate like the one above. */ +const BEDROCK_COMPAT_GATE = { + supportsStrictMode: 'offer', +} as const satisfies Record + +/** + * Every wire protocol pi-ai gives a compat type. Derived from `Model.compat`'s + * own conditional rather than listed by hand, so a pi-ai release that gives a + * further protocol a compat type fails the {@link COMPAT_GATES} entry list + * until someone classifies its fields. A protocol pi-ai gives no compat type + * resolves away here and takes no configured compat at all. + */ +type ApiWithCompat = { [K in KnownApi]: NonNullable['compat']> extends never ? never : K }[KnownApi] + +/** + * The compat gate of every wire protocol a profile may configure. + * + * Keyed by protocol, but grouped by pi-ai's compat *type*: the three Responses + * protocols share `OpenAIResponsesCompat`, so a switch settable on one is + * settable on all three. Keying by protocol alone would refuse + * `azure-openai-responses` and `openai-codex-responses` the fields their own + * models declare. + */ +const COMPAT_GATES: Readonly>>> = { + 'openai-completions': COMPLETIONS_COMPAT_GATE, + 'openai-responses': RESPONSES_COMPAT_GATE, + 'azure-openai-responses': RESPONSES_COMPAT_GATE, + 'openai-codex-responses': RESPONSES_COMPAT_GATE, + 'anthropic-messages': ANTHROPIC_COMPAT_GATE, + 'bedrock-converse-stream': BEDROCK_COMPAT_GATE, +} + +/** + * The compat gate of one resolved protocol. A `string` lookup rather than a + * keyed read: a route's `api` is configuration, so it may name a protocol + * pi-ai gives no compat type — or none at all. + * @param api - resolved wire protocol. + * @returns that protocol's field gate, or `undefined` when it takes no compat. + */ +function compatGate(api: string): Readonly> | undefined { + return (COMPAT_GATES as Readonly>>>)[api] +} + +/** The field names one gate offers. */ +type OfferedIn = { [K in keyof G]: G[K] extends 'offer' ? K : never }[keyof G] + +/** Every compat field name a profile may set, on whichever protocol takes it. */ +type OfferedCompatField = + | OfferedIn + | OfferedIn + | OfferedIn + | OfferedIn + +/** + * pi-ai wire-compatibility switches, set on the route (its models' default) or + * per model (winning over the route, field by field). + * + * pi-ai decides each of these from the provider id and baseURL when no layer + * sets it, and a private gateway's URL says nothing: for an endpoint it does + * not recognize the detection answers as though it were OpenAI itself, which + * is wrong for most OpenAI-compatible gateways. So every field here is one a + * deployment must be able to state because nothing can infer it, while the + * fields pi-ai's catalog sets for a named vendor stay withheld. + * + * A field belongs to the protocols whose upstream compat type declares it: a + * model-level switch its protocol does not take fails resolution, and a + * route-level one skips past models it cannot fit. "The three Responses + * protocols" below means `openai-responses`, `azure-openai-responses`, and + * `openai-codex-responses`, which pi-ai gives one shared compat type, so a + * switch settable on one is settable on all three. */ export interface PiAiCompatProfile { - /** Reasoning parameter format the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ - thinkingFormat?: PiAiThinkingFormat - /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + /** Whether the endpoint accepts `store`; `openai-completions`. */ + supportsStore?: boolean + /** + * Whether the endpoint accepts the `developer` role for the system prompt, + * which pi-ai sends only to a reasoning model; `false` keeps `system`. + * `openai-completions` and the three Responses protocols. + */ + supportsDeveloperRole?: boolean + /** Whether the endpoint accepts `reasoning_effort`; `openai-completions`. */ supportsReasoningEffort?: boolean + /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ + supportsUsageInStreaming?: boolean + /** Which output-cap field the endpoint reads; `openai-completions`. */ + maxTokensField?: NonNullable + /** Whether tool results must carry `name`; `openai-completions`. */ + requiresToolResultName?: boolean + /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */ + requiresAssistantAfterToolResult?: boolean + /** Whether thinking blocks must travel as text in `` delimiters; `openai-completions`. */ + requiresThinkingAsText?: boolean + /** Whether replayed assistant messages need an empty `reasoning_content` while reasoning is on; `openai-completions`. */ + requiresReasoningContentOnAssistantMessages?: boolean + /** Reasoning parameter format the endpoint expects; `openai-completions`. */ + thinkingFormat?: PiAiThinkingFormat + /** + * Kwargs sent as `chat_template_kwargs`, which pi-ai reads only under the + * two `chat-template` thinking formats; `openai-completions`. Nothing checks + * that pairing: the format in force may come from the installed catalog + * entry or from pi-ai's own baseURL detection, neither of which resolution + * can read, so kwargs set beside another format are sent nowhere. + */ + chatTemplateKwargs?: NonNullable + /** + * Whether the endpoint accepts `strict` in tool definitions; + * `openai-completions`, the three Responses protocols, `bedrock-converse-stream`. + */ + supportsStrictMode?: boolean + /** Prompt-cache marker convention; `openai-completions`. */ + cacheControlFormat?: NonNullable + /** + * Whether the endpoint accepts long prompt-cache retention; + * `openai-completions`, the three Responses protocols, `anthropic-messages`. + */ + supportsLongCacheRetention?: boolean + /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */ + supportsEagerToolInputStreaming?: boolean + /** Whether the endpoint accepts `cache_control` on tool definitions; `anthropic-messages`. */ + supportsCacheControlOnTools?: boolean + /** Whether the endpoint accepts the `temperature` request field; `anthropic-messages`. */ + supportsTemperature?: boolean + /** Whether to force adaptive thinking regardless of model id; `anthropic-messages`. */ + forceAdaptiveThinking?: boolean + /** Whether to replay an empty thinking signature instead of converting thinking to text; `anthropic-messages`. */ + allowEmptySignature?: boolean + /** Whether the endpoint accepts Anthropic strict tool schemas; `anthropic-messages`. */ + supportsStrictTools?: boolean +} + +/** Compile-time constraint that `T` is `never`. */ +type AssertNever = T + +/** + * Proof that every documented field is one a gate offers. A field the profile + * declares past the gates fails compilation with its own name in the error. + */ +export type EveryProfileFieldIsOffered = AssertNever> + +/** + * Proof that every offered field is documented. A gate entry flipped to + * `offer` without a profile field fails compilation with its own name in the + * error, which is the half a schema alone cannot catch. + */ +export type EveryOfferedFieldIsDocumented = AssertNever> + +/** Compile-time constraint that `T` is `true`. */ +type AssertTrue = T + +/** Every compat type a gate classifies, merged so one `Pick` reaches all offered fields. */ +type UpstreamCompat = OpenAICompletionsCompat & OpenAIResponsesCompat & AnthropicMessagesCompat & BedrockCompat + +/** + * Proof that each documented field carries its upstream type, not a hand-copied + * restatement of it. The name gates above pin *which* fields exist; this pins + * their types, in both directions because each catches a different drift. A + * profile field wider than upstream accepts a value the provider rejects, and + * `resolveModelCompat`'s cast to `ModelCompat` would hide it; a narrower one + * refuses a value the provider accepts, which is how an upgrade that widens a + * union would otherwise leave configuration silently behind. + */ +export type EveryProfileFieldMatchesUpstream = AssertTrue< + PiAiCompatProfile extends Partial> + ? Partial> extends PiAiCompatProfile ? true : false + : false +> + +/** + * The compat entries a profile actually set. + * + * schemastery materializes an absent dict as `{}` — the behavior + * `reasoningEfforts` works around with a union — so every parsed profile + * carries a `chatTemplateKwargs` key whether or not anyone wrote one. An empty + * one states nothing here: it would send no kwargs, which is exactly what + * leaving the field out does, so absent and empty are the same request and + * neither may make a route look like it configured a switch. A valueless + * scalar is the other thing schemastery lets through, and it is refused by + * {@link assertOfferedCompatFields} before this runs rather than filtered. + * @param compat - the configured switches, when any. + * @returns the entries carrying a value, in declaration order. + */ +function configuredCompatEntries(compat: PiAiCompatProfile | undefined): readonly (readonly [string, unknown])[] { + return Object.entries(compat ?? {}).flatMap(([field, value]) => { + const empty = typeof value === 'object' && value !== null && !Array.isArray(value) + && Object.keys(value as object).length === 0 + return empty ? [] : [[field, value] as const] + }) +} + +/** + * The protocols offering one compat field, in {@link COMPAT_GATES} order. + * @param field - configured compat field name. + * @returns the protocols whose compat takes it; empty when none does, which + * is either a withheld field or a name no upstream compat type declares. + */ +function compatProtocols(field: string): readonly string[] { + return Object.entries(COMPAT_GATES).flatMap(([api, gate]) => gate[field] === 'offer' ? [api] : []) +} + +/** + * The compat fields one protocol offers, for a diagnostic that has to show + * what was available instead of the name that missed. + * @param api - wire protocol. + * @returns the offered field names, or an empty list for a protocol taking no compat. + */ +function offeredCompatFields(api: string): readonly string[] { + return Object.entries(compatGate(api) ?? {}).flatMap(([field, disposition]) => disposition === 'offer' ? [field] : []) +} + +/** + * Every offered field name, deduplicated, for the one diagnostic that cannot + * narrow by protocol: the vocabulary check runs before any protocol resolves, + * which is what lets it refuse a misspelling on a route whose models would + * never have reached the protocol that declares the intended field. + * @returns the offered field names across every protocol, in gate order. + */ +function allOfferedCompatFields(): readonly string[] { + const fields = new Set() + for (const api of Object.keys(COMPAT_GATES)) { + for (const field of offeredCompatFields(api)) fields.add(field) + } + return [...fields] +} + +/** + * Reject a compat key no protocol offers. Runs before any protocol is + * resolved, so a withheld field or a misspelling fails even on a route whose + * models never reach the protocol that would have taken it — the alternative + * being the silent drop that let an unreadable switch look applied. + * @param provider - provider route key, for diagnostics. + * @param site - the configuration site, for diagnostics. + * @param compat - the configured switches, when any. + * @throws Error naming the offending key. + */ +function assertOfferedCompatFields( + provider: string, + site: string, + compat: PiAiCompatProfile | undefined, +): void { + // Every key, not only the ones carrying a value: a withheld or undeclared + // name is never in the schema, so schemastery cannot have materialized it — + // whatever its value, a person wrote it and expects it to do something. + for (const [field, value] of Object.entries(compat ?? {})) { + // The name is judged before the value, so a withheld or misspelled key + // written bare is refused for being that name rather than for being empty: + // the other order sends someone to supply a value the key would be refused + // with anyway. + if (compatProtocols(field).length === 0) { + const declared = Object.values(COMPAT_GATES).some(gate => gate[field] !== undefined) + if (declared) { + invalid(provider, `${site} sets compat "${field}", which is not configurable here: pi-ai's installed` + + ' catalog sets it for the vendors that need it, so name that provider as the route instead') + } + invalid(provider, `${site} sets compat "${field}", which no wire protocol declares; the configurable` + + ` switches are ${allOfferedCompatFields().join(', ')}`) + } + // A valueless key (`supportsDeveloperRole:`) survives schemastery, which + // passes nullable data through before any member schema runs — the same + // behavior `reasoningEfforts` documents — and a `cordis.yml` entry may + // reach the same state through `!!js undefined`. Either way the key is + // kept, so carrying it forward writes nothing over whatever the next layer + // resolved, leaving pi-ai's `??` at its baseURL detection: the "written but + // not applied" outcome this surface exists to refuse. + if (value == null) { + invalid(provider, `${site} sets compat "${field}" with no value; give it one, or remove the key to` + + ' leave the field to the next layer — the installed catalog entry, then pi-ai\'s own detection') + } + } } /** One configured model entry: an id plus the catalog fields it overrides. */ @@ -233,7 +585,7 @@ export interface PiAiModelProfile { * declares the offered levels and their wire spellings. */ reasoningEfforts?: false | PiAiReasoningEfforts - /** Reasoning-dispatch switches for this model, winning over the route's. */ + /** pi-ai wire-compatibility switches for this model, winning over the route's per field; one its protocol does not declare is refused. */ compat?: PiAiCompatProfile } @@ -258,7 +610,7 @@ export interface RouteCatalogRequest { models?: readonly PiAiModelProfile[] /** Installed-catalog customizations by model id; only meaningful while `models` is absent. */ modelOverrides?: Readonly> - /** Reasoning-dispatch switches for every `openai-completions` model on the route; entries override per field. */ + /** Route-level wire-compatibility switches, landing on each model whose protocol declares them; entries override per field. */ compat?: PiAiCompatProfile /** Context capacity for a model neither the entry nor the catalog sizes. */ defaultContextWindow: number @@ -368,16 +720,20 @@ function resolveModelReasoning( return { reasoning: true, thinkingLevelMap: map } } +/** The compat block a materialized model carries, whichever protocol it speaks. */ +type ModelCompat = OpenAICompletionsCompat | OpenAIResponsesCompat | AnthropicMessagesCompat | BedrockCompat + /** - * Resolve one model's compat block from the profile's reasoning switches. + * Resolve one model's compat block from the profile's switches. * - * A model switch wins over the route switch; whatever neither sets keeps the - * installed entry's value, and a field no layer decides falls through to - * pi-ai's baseURL-derived detection. Only an `openai-completions` model takes - * the switches at all: a model-level switch on any other protocol fails - * resolution, while a route-level default skips past such models — the same - * posture as the route-level `reasoning` default, which also must not fail - * models it does not fit. + * A model switch wins over the route switch field by field; whatever neither + * sets keeps the installed entry's value, and a field no layer decides falls + * through to pi-ai's own detection. A model-level switch its protocol does not + * take fails resolution — about one named model it can only be a mistake — + * while a route-level one skips past such models, since a route default must + * stay settable on a route whose models do not all speak one protocol. Every + * field reaching here is offered by some protocol; {@link + * assertOfferedCompatFields} has already refused the rest. * @param provider - provider route key, for diagnostics. * @param entry - the configured model entry. * @param route - the route-level switches, when any. @@ -391,31 +747,31 @@ function resolveModelCompat( route: PiAiCompatProfile | undefined, base: Model | undefined, api: string, -): { compat: OpenAICompletionsCompat } | Record { - const thinkingFormat = entry.compat?.thinkingFormat ?? route?.thinkingFormat - const supportsReasoningEffort = entry.compat?.supportsReasoningEffort ?? route?.supportsReasoningEffort - if (thinkingFormat === undefined && supportsReasoningEffort === undefined) return {} - if (api !== 'openai-completions') { - if (entry.compat?.thinkingFormat !== undefined || entry.compat?.supportsReasoningEffort !== undefined) { - invalid(provider, `model "${entry.id}" sets compat reasoning switches, but its api is "${api}";` - + ' thinkingFormat and supportsReasoningEffort exist only on openai-completions') - } - return {} +): { compat: ModelCompat } | Record { + const gate = compatGate(api) + const configured: Record = {} + for (const [field, value] of configuredCompatEntries(route)) { + if (gate?.[field] !== 'offer') continue + configured[field] = value } + for (const [field, value] of configuredCompatEntries(entry.compat)) { + if (gate?.[field] !== 'offer') { + const offered = offeredCompatFields(api) + invalid(provider, `model "${entry.id}" sets compat "${field}", but its api is "${api}", which does not` + + ` take it; that switch exists on ${compatProtocols(field).join(', ')}, and "${api}" offers` + + ` ${offered.length === 0 ? 'no configurable compat' : offered.join(', ')}`) + } + configured[field] = value + } + if (Object.keys(configured).length === 0) return {} // The installed entry's compat matches the entry's OWN api — a route-level // `api` repoint (an anthropic catalog served through an OpenAI-compatible // gateway) leaves `base.compat` in the other protocol's shape, so it is // inherited only while the resolved api still is the entry's. A repointed // model starts from pi-ai's baseURL-derived detection instead, which is // what a protocol change means for every other compat field too. - const inherited: OpenAICompletionsCompat | undefined = base?.api === api ? base.compat : undefined - return { - compat: { - ...inherited, - ...thinkingFormat === undefined ? {} : { thinkingFormat }, - ...supportsReasoningEffort === undefined ? {} : { supportsReasoningEffort }, - }, - } + const inherited = base?.api === api ? base.compat : undefined + return { compat: { ...inherited, ...configured } as ModelCompat } } /** One route's materialized catalog, plus the request caps its profile chose. */ @@ -485,8 +841,13 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { + ' must be listed in configuration') } const routeApi = sharedCatalogApi(defaults) - const routeCompatDefined = request.compat?.thinkingFormat !== undefined - || request.compat?.supportsReasoningEffort !== undefined + // Vocabulary before protocols: a withheld or undeclared switch is refused + // wherever it is written, so it cannot look applied on a route whose models + // never reach the protocol that would have taken it. + assertOfferedCompatFields(provider, 'route', request.compat) + for (const entry of entries) { + assertOfferedCompatFields(provider, `model "${entry.id}"`, entry.compat) + } const seen = new Set() const configuredMaxTokens = new Map() const models = entries.map((entry) => { @@ -538,9 +899,15 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { ...resolveModelCompat(provider, entry, request.compat, base, api), } }) - if (routeCompatDefined && !models.some(model => model.api === 'openai-completions')) { - invalid(provider, 'sets compat reasoning switches, but no model on the route speaks openai-completions;' - + ' thinkingFormat and supportsReasoningEffort exist only on that protocol') + // Per field, not per block: a route may default a switch its completions + // models take beside one only its anthropic models do, and neither should + // fail for the other's sake. What is refused is a route default no model on + // the route could ever read, which is a route that will not behave as written. + for (const [field] of configuredCompatEntries(request.compat)) { + const takers = compatProtocols(field) + if (models.some(model => takers.includes(model.api))) continue + invalid(provider, `sets compat "${field}", but no model on the route speaks a protocol that takes it;` + + ` it exists on ${takers.join(', ')}`) } return { models, configuredMaxTokens } } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index d1e1f697a9..62d49a58ee 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -14,14 +14,22 @@ * @module dsh-llm-pi-ai/config */ -import type { CacheRetention, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' +import type { CacheRetention, ChatTemplateKwargValue, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' import z from '@deepseek-ai/schemastery' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' -import { MODALITIES, resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts' +import { + CACHE_CONTROL_FORMATS, + CHAT_TEMPLATE_VARS, + MAX_TOKENS_FIELDS, + MODALITIES, + resolveRouteModels, + SUPPORTED_THINKING_FORMATS, + THINKING_LEVELS, +} from './catalog.ts' import type { PiAiCompatProfile, PiAiModality, @@ -102,10 +110,11 @@ export interface PiAiProviderProfile { */ modelOverrides?: Record /** - * Reasoning-dispatch switches for every `openai-completions` model on this - * route; each model's own `compat` overrides per field. What neither sets - * keeps the installed catalog entry's value, then pi-ai's baseURL-derived - * detection. + * pi-ai wire-compatibility switches defaulting every model on this route + * whose protocol declares them; each model's own `compat` overrides per + * field. What neither sets keeps the installed catalog entry's value, then + * pi-ai's own detection. A switch no model on the route could read is + * refused rather than left looking applied. */ compat?: PiAiCompatProfile /** @@ -205,9 +214,43 @@ const thinkingBudgets = z.object({ high: z.number(), }) +/** + * One `chat_template_kwargs` value. The `$var` member is pi-ai's placeholder + * for a value dispatch fills from the request's thinking state, which is what + * makes a chat-template gateway configurable without restating its template. + */ +const chatTemplateKwarg: z = z.union([ + z.string(), + z.number(), + z.boolean(), + z.const(null), + z.object({ + $var: z.union(CHAT_TEMPLATE_VARS).required(), + omitWhenOff: z.boolean(), + }), +]) + const compatProfile: z = z.object({ - thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS), + supportsStore: z.boolean(), + supportsDeveloperRole: z.boolean(), supportsReasoningEffort: z.boolean(), + supportsUsageInStreaming: z.boolean(), + maxTokensField: z.union(MAX_TOKENS_FIELDS), + requiresToolResultName: z.boolean(), + requiresAssistantAfterToolResult: z.boolean(), + requiresThinkingAsText: z.boolean(), + requiresReasoningContentOnAssistantMessages: z.boolean(), + thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS), + chatTemplateKwargs: z.dict(chatTemplateKwarg), + supportsStrictMode: z.boolean(), + cacheControlFormat: z.union(CACHE_CONTROL_FORMATS), + supportsLongCacheRetention: z.boolean(), + supportsEagerToolInputStreaming: z.boolean(), + supportsCacheControlOnTools: z.boolean(), + supportsTemperature: z.boolean(), + forceAdaptiveThinking: z.boolean(), + allowEmptySignature: z.boolean(), + supportsStrictTools: z.boolean(), }) /** diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 5a2d330b7d..d66a48115d 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -4,7 +4,7 @@ * @module dsh-llm-pi-ai/context */ -import { CallId, contentHasImage, LlmError } from '@deepseek-ai/dsh-llm' +import { CallId, contentHasImage, LlmError, offloadRequestImages } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai' @@ -26,82 +26,29 @@ function toolResultText(blocks: readonly ContentBlock[]): string { : block.type === 'tool-result' ? toolResultText(block.content) : '').join('') } -/** Model-facing stand-in for an image dropped to fit the request bound. */ -export const OFFLOADED_IMAGE_TEXT - = '[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]' - -/** Base64 length of `bytes` raw bytes (4 output characters per 3 input bytes, padded). */ -function base64Length(bytes: number): number { - return Math.ceil(bytes / 3) * 4 -} - -/** - * Select the images a request must drop to fit the per-request payload bound. - * History order is oldest-first, so the most recent images are omitted last. - * A single image larger than the bound is itself omitted. Locations use - * message and nested block indexes so JSON replay cannot change the result by - * splitting or preserving shared object identities. - * @param messages - complete request history, oldest first. - * @param maxRequestImageBytes - bound on total base64-encoded image payload; undefined leaves every image in place. - * @returns the image locations the conversion replaces with {@link OFFLOADED_IMAGE_TEXT}. - */ -function offloadedImages( - messages: readonly Message[], - maxRequestImageBytes: number | undefined, -): ReadonlySet { - const offloaded = new Set() - if (maxRequestImageBytes === undefined) return offloaded - const images: { location: string; base64Bytes: number }[] = [] - const collect = (messageIndex: number, blocks: readonly ContentBlock[], prefix: readonly number[] = []): void => { - for (const [blockIndex, block] of blocks.entries()) { - const path = [...prefix, blockIndex] - if (block.type === 'image') { - images.push({ - location: `${messageIndex}:${path.join('.')}`, - base64Bytes: base64Length(block.attachment.bytes), - }) - } else if (block.type === 'tool-result') { - collect(messageIndex, block.content, path) - } +/** Reject image roles that pi-ai cannot replay before request-size offloading can replace them. */ +function assertSupportedImageRoles(messages: readonly Message[]): void { + for (const message of messages) { + if (message.role !== 'user' && contentHasImage(message.content)) { + throw new LlmError( + `pi-ai cannot represent an image in an in-history ${message.role} message`, + 'UNSUPPORTED_CONTENT', + ) } } - for (const [messageIndex, message] of messages.entries()) collect(messageIndex, message.content) - let total = images.reduce((sum, image) => sum + image.base64Bytes, 0) - for (const image of images) { - if (total <= maxRequestImageBytes) break - offloaded.add(image.location) - total -= image.base64Bytes - } - return offloaded -} - -interface LocatedContentBlock { - readonly block: ContentBlock - readonly path: readonly number[] -} - -/** Attach stable nested indexes to blocks from one message. */ -function locatedBlocks(blocks: readonly ContentBlock[], prefix: readonly number[] = []): LocatedContentBlock[] { - return blocks.map((block, index) => ({ block, path: [...prefix, index] })) } async function userContent( - blocks: readonly LocatedContentBlock[], + blocks: readonly ContentBlock[], attachments: AttachmentStore, - offloaded: ReadonlySet, - messageIndex: number, ): Promise { const content: (TextContent | ImageContent)[] = [] - for (const { block, path } of blocks) { + for (const block of blocks) { switch (block.type) { case 'text': if (block.text.length > 0) content.push({ type: 'text', text: block.text }) break case 'image': { - if (offloaded.has(`${messageIndex}:${path.join('.')}`)) { - content.push({ type: 'text', text: OFFLOADED_IMAGE_TEXT }) - break - } const stored = await attachments.readImage(block.attachment) content.push({ type: 'image', @@ -112,7 +59,7 @@ async function userContent( } case 'tool-result': { - const nested = await userContent(locatedBlocks(block.content, path), attachments, offloaded, messageIndex) + const nested = await userContent(block.content, attachments) if (typeof nested === 'string') { if (nested.length > 0) content.push({ type: 'text', text: nested }) } else { @@ -234,15 +181,13 @@ async function toPiContextWithImages( onReplayDegrade?: (reason: string) => void, maxRequestImageBytes?: number, ): Promise { - const offloaded = offloadedImages(options.messages, maxRequestImageBytes) + assertSupportedImageRoles(options.messages) + const requestMessages = offloadRequestImages(options.messages, maxRequestImageBytes) const toolNames = new Map() const messages: PiMessage[] = [] - for (const [messageIndex, message] of options.messages.entries()) { + for (const message of requestMessages) { if (message.role === 'system') { - if (contentHasImage(message.content)) { - throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT') - } // pi-ai has a single systemPrompt slot; in-history system messages are // folded into user messages to preserve order (rare in practice — the // harness sends the system prompt via options.system). @@ -258,17 +203,16 @@ async function toPiContextWithImages( continue } // user role: text + tool results (each result becomes its own message). - const located = locatedBlocks(message.content) - const regular = located.filter(({ block }) => block.type !== 'tool-result') - const content = await userContent(regular, attachments, offloaded, messageIndex) - const results = located.filter((entry): entry is LocatedContentBlock & { block: Extract } => ( - entry.block.type === 'tool-result' + const regular = message.content.filter(block => block.type !== 'tool-result') + const content = await userContent(regular, attachments) + const results = message.content.filter((block): block is Extract => ( + block.type === 'tool-result' )) if (content.length > 0 || results.length === 0) { messages.push({ role: 'user', content, timestamp: 0 }) } - for (const { block: result, path } of results) { - const resultContent = await userContent(locatedBlocks(result.content, path), attachments, offloaded, messageIndex) + for (const result of results) { + const resultContent = await userContent(result.content, attachments) messages.push({ role: 'toolResult', toolCallId: result.toolCallId, diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index d45a2e3089..2446b10286 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -597,6 +597,46 @@ describe('provider profile lifecycle', () => { expect(server.requests[1]).not.toHaveProperty('reasoning_effort') }) + it('keeps the system role on a declared route whose gateway rejects the developer one', async () => { + vi.stubEnv('PI_TEST_KEY', 'test-key') + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKeyEnv: 'PI_TEST_KEY', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + models: [ + // pi-ai sends the system prompt as `developer` to a reasoning + // model whenever its URL detection says the endpoint is OpenAI — + // which is what an unrecognized private URL resolves to. Most + // OpenAI-compatible gateways reject that role. + { id: 'acme-think', reasoningEfforts: { off: null, high: 'high' }, compat: { supportsDeveloperRole: false } }, + { id: 'acme-guess', reasoningEfforts: { off: null, high: 'high' } }, + ], + }, + }, + }) + const roles = async (model: string): Promise => { + await assemble(ctx, { + provider: 'acme-gateway', + model, + reasoningEffort: ReasoningEffortId('high'), + system: 'you are a harness', + messages: [], + }) + const request = server.requests.at(-1) as { messages: { role: string }[] } + return request.messages.map(message => message.role) + } + + expect(await roles('acme-think')).toEqual(['system']) + // The switch is the only thing that changes it: the same route, same + // endpoint, same reasoning declaration still gets pi-ai's guess. + expect(await roles('acme-guess')).toEqual(['developer']) + }) + it('sends a declared off value as the effort parameter instead of omitting it', async () => { vi.stubEnv('PI_TEST_KEY', 'test-key') const server = await mockServer([{ events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index eb4ba511d4..0322b81edf 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import LlmRuntime, { createUserMessage } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import FileSettingsProvider from '@deepseek-ai/dsh-settings-file' import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -755,7 +755,7 @@ describe('modelOverrides', () => { }) }) -describe('reasoning-dispatch compat switches', () => { +describe('compat switches', () => { /** The materialized models of one route, keyed by id. */ function modelsOf(providers: Record, route: string): Map> { const models = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? [] @@ -813,18 +813,244 @@ describe('reasoning-dispatch compat switches', () => { expect(models.get(responses.id)?.compat).toEqual(responses.compat) }) - it('rejects a model-level switch on a protocol that has no such field', () => { + it('rejects a model-level switch on a protocol that has no such field, naming what it offers', () => { expect(() => resolveProfiles({ anthropic: { models: [{ id: 'claude-sonnet-4-5', compat: { thinkingFormat: 'openai' } }], }, - })).toThrow(/exist only on openai-completions/) + })).toThrow(/its api is "anthropic-messages", which does not take it.*exists on openai-completions/s) }) it('rejects route switches no model on the route can take', () => { expect(() => resolveProfiles({ anthropic: { compat: { thinkingFormat: 'openai' } }, - })).toThrow(/no model on the route speaks openai-completions/) + })).toThrow(/no model on the route speaks a protocol that takes it/) + }) + + it('carries the developer-role switch onto a hand-declared reasoning model', () => { + // pi-ai reads this switch only for a reasoning model, and detects it from + // the endpoint URL — which for a private gateway answers as though it were + // OpenAI itself, so the route must be able to say otherwise. + const models = modelsOf({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { supportsDeveloperRole: false, maxTokensField: 'max_tokens' }, + models: [{ id: 'acme-think', reasoningEfforts: { off: null, high: 'high' } }], + }, + }, 'acme-gateway') + + expect(models.get('acme-think')?.compat).toEqual({ + supportsDeveloperRole: false, + maxTokensField: 'max_tokens', + }) + }) + + it('carries a switch both OpenAI protocols declare onto an openai-responses route', () => { + const models = modelsOf({ + 'acme-responses': { + api: 'openai-responses', + baseURL: 'https://acme.test', + compat: { supportsDeveloperRole: false }, + models: [{ id: 'acme-r', reasoningEfforts: { off: null, high: 'high' } }], + }, + }, 'acme-responses') + + expect(models.get('acme-r')?.compat).toEqual({ supportsDeveloperRole: false }) + }) + + it('carries an anthropic-only switch onto an anthropic-messages route', () => { + const models = modelsOf({ + 'acme-claude': { + api: 'anthropic-messages', + baseURL: 'https://acme.test', + compat: { supportsTemperature: false, supportsCacheControlOnTools: false }, + models: [{ id: 'acme-opus' }], + }, + }, 'acme-claude') + + expect(models.get('acme-opus')?.compat).toEqual({ + supportsTemperature: false, + supportsCacheControlOnTools: false, + }) + }) + + it('lands each route switch only on the models whose protocol declares it', () => { + const catalog = getBuiltinModels('xai') as readonly Model[] + const completions = catalog.find(model => model.api === 'openai-completions') + const responses = catalog.find(model => model.api === 'openai-responses') + if (completions === undefined || responses === undefined) throw new Error('xai no longer ships a mixed catalog') + + const models = modelsOf({ + xai: { + // Both protocols take the first switch; only completions takes the second. + compat: { supportsDeveloperRole: false, thinkingFormat: 'openai' }, + models: [{ id: completions.id }, { id: responses.id }], + }, + }, 'xai') + + const onCompletions = models.get(completions.id)?.compat as OpenAICompletionsCompat + expect(onCompletions.supportsDeveloperRole).toBe(false) + expect(onCompletions.thinkingFormat).toBe('openai') + const onResponses = models.get(responses.id)?.compat as { supportsDeveloperRole?: boolean; thinkingFormat?: string } + expect(onResponses.supportsDeveloperRole).toBe(false) + expect(onResponses.thinkingFormat).toBeUndefined() + }) + + it('carries chat-template kwargs beside the thinking format that dispatches through them', () => { + const models = modelsOf({ + 'acme-qwen': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ + id: 'qwen-local', + reasoningEfforts: { off: null, medium: 'medium' }, + compat: { + thinkingFormat: 'qwen-chat-template', + chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' } }, + }, + }], + }, + }, 'acme-qwen') + + expect(models.get('qwen-local')?.compat).toEqual({ + thinkingFormat: 'qwen-chat-template', + chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' } }, + }) + }) + + it('rejects a model switch on an unrecognized protocol as having no configurable compat', () => { + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'acme-chat', + baseURL: 'https://acme.test', + models: [{ id: 'acme-a', compat: { supportsStore: false } }], + }, + })).toThrow(/its api is "acme-chat", which does not take it.*"acme-chat" offers no configurable compat/s) + }) + + it('refuses a valueless compat key written through the composed settings path', async () => { + // The write path an operator reaches: a section resolved by schemastery, + // judged by this adapter's section validator before it is stored. + // schemastery keeps the null, so nothing but that check stands between it + // and `Model.compat`. + const dir = await home() + const ctx = await bootWithSettings(dir, {}) + await expect(ctx.settings.update(settingsNamespace('llm-pi-ai'), { + providers: { + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test/v1', + compat: { supportsDeveloperRole: null }, + models: [{ id: 'acme-a' }], + }, + }, + })).rejects.toThrow(/compat "supportsDeveloperRole" with no value/) + }) + + it('carries a compat switch from a written settings section onto the wire', async () => { + // End to end for the reported gap: the switch enters as configuration and + // changes the request the provider receives, not merely the resolved model. + vi.stubEnv(KEY_ENV, 'test-key') + const server = await mockServer([{ events: textEvents }]) + const dir = await home() + const ctx = await bootWithSettings(dir, {}) + await ctx.settings.update(settingsNamespace('llm-pi-ai'), { + providers: { + 'acme-gateway': { + apiKeyEnv: KEY_ENV, + api: 'openai-completions', + baseURL: `${server.url}/v1`, + compat: { supportsDeveloperRole: false }, + models: [{ id: 'acme-think', reasoningEfforts: { off: null, high: 'high' } }], + }, + }, + }) + + await assemble(ctx, { + provider: 'acme-gateway', + model: 'acme-think', + reasoningEffort: ReasoningEffortId('high'), + system: 'you are a harness', + messages: [], + }) + + const request = server.requests[0] as { messages: { role: string }[] } + expect(request.messages.map(message => message.role)).toEqual(['system']) + }) + + it('refuses a valueless compat key rather than writing null over the catalog', () => { + // schemastery passes a YAML bare key through as null. Carried forward it + // would replace the installed entry's value, and pi-ai's `??` would then + // reach for its baseURL detection — the "written but not applied" outcome. + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { supportsDeveloperRole: null } as never, + models: [{ id: 'acme-a' }], + }, + })).toThrow(/compat "supportsDeveloperRole" with no value/) + }) + + it('refuses a compat key whose value is undefined, as a cordis.yml entry can write', () => { + // `!!js undefined` reaches the same state as a YAML bare key, and + // schemastery keeps the key either way, so both are refused together. + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { supportsDeveloperRole: undefined } as never, + models: [{ id: 'acme-a' }], + }, + })).toThrow(/compat "supportsDeveloperRole" with no value/) + }) + + it('refuses a valueless compat key on a model entry too', () => { + expect(() => resolveProfiles({ + deepseek: { + modelOverrides: { 'deepseek-v4-flash': { compat: { requiresReasoningContentOnAssistantMessages: null } } as never }, + }, + })).toThrow(/model "deepseek-v4-flash" sets compat "requiresReasoningContentOnAssistantMessages" with no value/) + }) + + it('serves the Responses compat type on every protocol pi-ai gives it to', () => { + // pi-ai types azure-openai-responses and openai-codex-responses with the + // same OpenAIResponsesCompat, so a switch settable on one is settable on all. + for (const route of ['azure-openai-responses', 'openai-codex']) { + const models = modelsOf({ [route]: { compat: { supportsDeveloperRole: false } } }, route) + const [first] = [...models.values()] + expect((first?.compat as { supportsDeveloperRole?: boolean }).supportsDeveloperRole).toBe(false) + } + }) + + it('serves the Bedrock compat type on its own protocol', () => { + const models = modelsOf({ 'amazon-bedrock': { compat: { supportsStrictMode: false } } }, 'amazon-bedrock') + const [first] = [...models.values()] + expect((first?.compat as { supportsStrictMode?: boolean }).supportsStrictMode).toBe(false) + }) + + it('refuses a compat key no wire protocol declares instead of dropping it', () => { + // The silent drop is what let an unreadable switch look applied: schemastery + // passes unknown keys through, and resolution used to read only two fields. + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { supportsDevelperRole: false } as never, + models: [{ id: 'acme-a' }], + }, + })).toThrow(/compat "supportsDevelperRole", which no wire protocol declares; the configurable switches are .*\bsupportsDeveloperRole\b/) + }) + + it('refuses a compat key pi-ai’s catalog owns, pointing at the catalog route', () => { + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'acme-a', compat: { openRouterRouting: {} } as never }], + }, + })).toThrow(/compat "openRouterRouting", which is not configurable here/) }) }) diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index be41a12b07..b82c6b63f5 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { CallId, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { CallId, createMessage, createUserMessage, OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import { OFFLOADED_IMAGE_TEXT, toPiContext } from '../src/context.ts' +import { toPiContext } from '../src/context.ts' import { toPiAssistant } from '../src/replay.ts' const ref: ImageAttachmentRef = { @@ -140,6 +140,59 @@ describe('pi-ai request context conversion', () => { ]) }) + it('recursively converts nested tool-result text and images', async () => { + const callId = CallId('nested-call') + const context = await toPiContext(request([user([{ + type: 'tool-result', + toolCallId: callId, + content: [ + { + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text: 'nested text' }], + }, + { + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'image', attachment: ref }], + }, + ], + }])]), attachments) + + expect(context.messages).toEqual([{ + role: 'toolResult', + toolCallId: 'nested-call', + toolName: 'unknown', + content: [ + { type: 'text', text: 'nested text' }, + { type: 'image', data: 'AQ==', mimeType: 'image/png' }, + ], + isError: false, + timestamp: 0, + }]) + }) + + it('flattens nested text-only tool results and ignores other block types without storage', () => { + const callId = CallId('nested-text') + expect(toPiContext(request([user([{ + type: 'tool-result', + toolCallId: callId, + content: [ + { type: 'chart', data: 'ignored' } as unknown as ContentBlock, + { + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text: 'nested' }], + }, + ], + }])]))).toMatchObject({ + messages: [{ + role: 'toolResult', + content: [{ type: 'text', text: 'nested' }], + }], + }) + }) + it('replaces the oldest images with placeholders once the request payload bound is exceeded', async () => { const readImage = vi.fn(() => Promise.resolve({ ref: { ...ref, bytes: 3 }, data: Uint8Array.of(1, 2, 3) })) const store = { readImage } as unknown as AttachmentStore @@ -249,9 +302,14 @@ describe('pi-ai request context conversion', () => { }) it('handles in-history system and assistant messages explicitly on the image path', async () => { - await expect(toPiContext(request([ - history('system', [{ type: 'image', attachment: ref }]), - ]), attachments)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + for (const role of ['system', 'assistant'] as const) { + const readImage = vi.fn() + const store = { readImage } as unknown as AttachmentStore + await expect(toPiContext(request([ + history(role, [{ type: 'image', attachment: ref }]), + ]), store, undefined, 1)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(readImage).not.toHaveBeenCalled() + } await expect(toPiContext(request([ history('system', [{ type: 'text', text: 'history system' }]), diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index 5eb3668915..7dc5221c9b 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -27,7 +27,8 @@ interface PartialBlock { * {@link ContentBlock}s and a final assistant {@link Message}. * * The agent loop feeds it while logging raw chunks for replay fidelity, then - * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends, + * or `interruptedBlocks()` when cancellation cut the stream short. * * Tolerant of delta-only protocols (no block-start/end); deltas arriving for * an index already closed by `block-end` are ignored (malformed stream) so a @@ -157,6 +158,25 @@ export class BlockAssembler { return this.assembled().blocks } + /** + * Assemble the prefix an interrupted stream can safely finalize: closed and + * open text/reasoning blocks with non-whitespace content, in stream order. + * Tool calls are omitted because interruption precedes dispatch; retaining + * one would require a fabricated result. Open unknown blocks are also omitted. + * @returns the kept blocks; empty when nothing streamed before the interruption. + */ + interruptedBlocks(): ContentBlock[] { + return this.order + .map((index) => { + const partial = this.mustGet(index) + const type = partial.block?.type ?? partial.blockType + if (type !== 'text' && type !== 'reasoning') return undefined + return this.assemble(partial, index) + }) + .filter((block): block is ContentBlock => + (block?.type === 'text' || block?.type === 'reasoning') && block.text.trim() !== '') + } + /** Usage from the `usage` chunk; undefined until one arrives. */ get usage(): TokenUsage | undefined { return this._usage diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 19b760a02a..55c0719fb9 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -1,6 +1,11 @@ /** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */ import type { ContentBlock } from './types.ts' +import type { Message } from './message.ts' + +/** Model-facing stand-in for an image removed to fit a provider request bound. */ +export const OFFLOADED_IMAGE_TEXT + = '[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]' /** * True when typed model content contains an image block, walking nested @@ -14,3 +19,76 @@ export function contentHasImage(content: readonly ContentBlock[]): boolean { return content.some(block => block.type === 'image' || (block.type === 'tool-result' && contentHasImage(block.content))) } + +/** Base64 length of raw image bytes, including padding. */ +function base64Length(bytes: number): number { + return Math.ceil(bytes / 3) * 4 +} + +/** Collect base64 payload lengths in request and nested-block order. */ +function collectImageLengths(blocks: readonly ContentBlock[], lengths: number[]): void { + for (const block of blocks) { + if (block.type === 'image') { + lengths.push(base64Length(block.attachment.bytes)) + } else if (block.type === 'tool-result') { + collectImageLengths(block.content, lengths) + } + } +} + +/** Replace the first `remaining.count` image occurrences without mutating durable messages. */ +function replaceOldestImages( + blocks: readonly ContentBlock[], + remaining: { count: number }, +): ContentBlock[] { + let next: ContentBlock[] | undefined + for (const [index, block] of blocks.entries()) { + if (block.type === 'image' && remaining.count > 0) { + remaining.count -= 1 + next ??= blocks.slice(0, index) + next.push({ type: 'text', text: OFFLOADED_IMAGE_TEXT }) + continue + } + if (block.type === 'tool-result') { + const content = replaceOldestImages(block.content, remaining) + if (content !== block.content) { + next ??= blocks.slice(0, index) + next.push({ ...block, content }) + continue + } + } + next?.push(block) + } + return next ?? blocks as ContentBlock[] +} + +/** + * Return transient request messages whose oldest images are replaced until + * their accumulated base64 payload fits the configured bound. The selection + * is deterministic from durable message order and attachment metadata; a + * provider can serialize the returned messages without reading omitted bytes. + * @param messages - complete request history, oldest first. + * @param maxRequestImageBytes - positive bound on total base64 image payload; undefined preserves every image. + * @returns the original messages when they already fit, otherwise shallow message copies with replaced content trees. + */ +export function offloadRequestImages( + messages: readonly Message[], + maxRequestImageBytes: number | undefined, +): readonly Message[] { + if (maxRequestImageBytes === undefined) return messages + const lengths: number[] = [] + for (const message of messages) collectImageLengths(message.content, lengths) + let total = lengths.reduce((sum, bytes) => sum + bytes, 0) + let count = 0 + for (const bytes of lengths) { + if (total <= maxRequestImageBytes) break + total -= bytes + count += 1 + } + if (count === 0) return messages + const remaining = { count } + return messages.map((message) => { + const content = replaceOldestImages(message.content, remaining) + return content === message.content ? message : { ...message, content } + }) +} diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index 9f73ee3f96..2ecfcfd860 100644 --- a/packages/llm/llm/tests/assembler.spec.ts +++ b/packages/llm/llm/tests/assembler.spec.ts @@ -224,3 +224,41 @@ describe('BlockAssembler duplicate-close contract', () => { expect(assembler.blocks()).toEqual([{ type: 'reasoning', text: 'first' }]) }) }) + +describe('BlockAssembler.interruptedBlocks', () => { + it('keeps closed and open text/reasoning blocks with streamed content, in order', () => { + const assembler = new BlockAssembler() + assembler.push({ type: 'block-start', index: 0, blockType: 'reasoning' }) + assembler.push({ type: 'reasoning-delta', index: 0, text: 'planning' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'planning' } }) + assembler.push({ type: 'block-start', index: 1, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 1, text: 'half an ans' }) + expect(assembler.interruptedBlocks()).toEqual([ + { type: 'reasoning', text: 'planning' }, + { type: 'text', text: 'half an ans' }, + ]) + }) + + it('drops tool calls whether open or closed — interruption precedes dispatch', () => { + const assembler = new BlockAssembler() + assembler.push({ type: 'block-start', index: 0, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 0, text: 'calling' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'calling' } }) + assembler.push({ type: 'block-start', index: 1, blockType: 'tool-call' }) + assembler.push({ type: 'tool-call-delta', index: 1, id: CallId('c1'), name: 'read', argumentsDelta: '{"a":1}' }) + assembler.push({ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c1'), name: 'read', arguments: '{"a":1}' } }) + assembler.push({ type: 'block-start', index: 2, blockType: 'tool-call' }) + assembler.push({ type: 'tool-call-delta', index: 2, id: CallId('c2'), name: 'read', argumentsDelta: '{"pa' }) + expect(assembler.interruptedBlocks()).toEqual([{ type: 'text', text: 'calling' }]) + }) + + it('drops empty and whitespace-only text/reasoning blocks and unknown open block types', () => { + const assembler = new BlockAssembler() + assembler.push({ type: 'block-start', index: 0, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 0, text: ' \n' }) + // A merge-extended block kind this build does not know how to assemble. + assembler.push({ type: 'block-start', index: 1, blockType: 'mystery' } as unknown as StreamChunk) + assembler.push({ type: 'block-start', index: 2, blockType: 'reasoning' }) + expect(assembler.interruptedBlocks()).toEqual([]) + }) +}) diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts new file mode 100644 index 0000000000..ffb5a586bf --- /dev/null +++ b/packages/llm/llm/tests/content.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { CallId, createUserMessage, OFFLOADED_IMAGE_TEXT, offloadRequestImages } from '../src/index.ts' +import type { ContentBlock } from '../src/index.ts' + +const source = { kind: 'plugin' as const, plugin: 'test' } + +function image(bytes: number): ContentBlock { + return { + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes, + width: 1, + height: 1, + }, + } +} + +describe('offloadRequestImages', () => { + it('preserves the original request when its base64 payload fits exactly', () => { + const messages = [createUserMessage({ content: [image(3), image(3)], source })] + expect(offloadRequestImages(messages, 8)).toBe(messages) + }) + + it('keeps five 3 MiB images at 20 MiB and offloads the oldest after one more raw byte', () => { + const rawImageBytes = 3 * 1024 * 1024 + const maxRequestImageBytes = 20 * 1024 * 1024 + const exact = [createUserMessage({ + content: Array.from({ length: 5 }, () => image(rawImageBytes)), + source, + })] + expect(offloadRequestImages(exact, maxRequestImageBytes)).toBe(exact) + + const over = [createUserMessage({ + content: [image(rawImageBytes + 1), ...Array.from({ length: 4 }, () => image(rawImageBytes))], + source, + })] + expect(offloadRequestImages(over, maxRequestImageBytes)[0]?.content).toEqual([ + { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + ...Array.from({ length: 4 }, () => image(rawImageBytes)), + ]) + }) + + it('replaces the oldest nested occurrences without mutating durable messages', () => { + const shared = image(3) + const messages = [ + createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId('shot'), + content: [shared], + }], + source, + }), + createUserMessage({ content: [shared, image(3)], source }), + ] + + const fitted = offloadRequestImages(messages, 8) + expect(fitted).not.toBe(messages) + expect(fitted[0]?.content).toEqual([{ + type: 'tool-result', + toolCallId: CallId('shot'), + content: [{ type: 'text', text: OFFLOADED_IMAGE_TEXT }], + }]) + expect(fitted[1]?.content).toEqual([shared, image(3)]) + expect(messages[0]?.content[0]).toMatchObject({ type: 'tool-result', content: [shared] }) + }) + + it('replaces a single image that cannot fit', () => { + const messages = [createUserMessage({ content: [image(300)], source })] + expect(offloadRequestImages(messages, 8)[0]?.content) + .toEqual([{ type: 'text', text: OFFLOADED_IMAGE_TEXT }]) + }) + + it('keeps unchanged nested content while replacing a later image', () => { + const nested = { + type: 'tool-result' as const, + toolCallId: CallId('text-only'), + content: [{ type: 'text' as const, text: 'kept' }], + } + const messages = [createUserMessage({ content: [nested, image(3)], source })] + expect(offloadRequestImages(messages, 1)[0]?.content).toEqual([ + nested, + { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + ]) + }) +}) diff --git a/packages/session/session-title/tests/session-title.spec.ts b/packages/session/session-title/tests/session-title.spec.ts index bb8ce4625d..2715db4fd4 100644 --- a/packages/session/session-title/tests/session-title.spec.ts +++ b/packages/session/session-title/tests/session-title.spec.ts @@ -72,11 +72,20 @@ describe('SessionTitleService', () => { expect(session.surface.nodes).toEqual([message.seq]) }) - it('derives a fallback title from the direct prompt instead of baked prefix context', async () => { + it('derives a fallback title from the direct prompt instead of injected 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('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Referenced session snapshot' }], + source: { + kind: 'session-reference', + form: 'recall', + version: 1, + references: [], + }, + }), { surfaceOp: 'append' }) session.append('turn/start', { turn: 1, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b729ed9ad..6f5d632801 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -863,6 +863,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal @@ -884,6 +887,9 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session/session-persistence + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../context/session-reference '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -1335,6 +1341,9 @@ importers: '@deepseek-ai/dsh-client-ui-plan': specifier: workspace:^ version: link:../../client/ui-plan + '@deepseek-ai/dsh-client-ui-reference': + specifier: workspace:^ + version: link:../../client/ui-reference '@deepseek-ai/dsh-client-ui-renderer': specifier: workspace:^ version: link:../../client/ui-renderer @@ -1392,6 +1401,12 @@ importers: '@deepseek-ai/dsh-cordis-host-runner': specifier: workspace:^ version: link:../../extensions/cordis-host-runner + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference + '@deepseek-ai/dsh-file-reference-local': + specifier: workspace:^ + version: link:../../context/file-reference-local '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy @@ -1422,6 +1437,9 @@ importers: '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../session/session-projection-cache + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../context/session-reference '@deepseek-ai/dsh-session-stats': specifier: workspace:^ version: link:../../session/session-stats @@ -2068,6 +2086,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -2404,6 +2425,39 @@ importers: specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) + packages/client/ui-reference: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-input-trigger': + specifier: workspace:^ + version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../context/session-reference + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + packages/client/ui-renderer: dependencies: use-sync-external-store: @@ -3049,6 +3103,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -3345,11 +3402,58 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/context/file-reference: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + + packages/context/file-reference-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../file-reference + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/context/session-reference: dependencies: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3375,6 +3479,9 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol packages/context/time-context: dependencies: @@ -5338,6 +5445,9 @@ importers: '@deepseek-ai/dsh-anonymous-user-id': specifier: workspace:^ version: link:../../identity/anonymous-user-id + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index fec6cbf6bc..e29a815ed9 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -69,6 +69,7 @@ export const SERVICE_PAGE: Record = { directoryPicker: 'workspace.md', dynamicCordisRunner: 'extensions.md', e2b: 'subprocess.md', + fileReferences: 'session-reference.md', fs: 'filesystem.md', goals: 'goal.md', webServer: 'web-server.md', @@ -272,7 +273,9 @@ export const LINK_MAP: Readonly> = { RequestErrorAction: 'core.md', RequestFailureContext: 'core.md', PreparedReferencedMessage: 'session-reference.md', + FileReferenceCandidate: 'session-reference.md', SessionReferenceCandidate: 'session-reference.md', + SessionReferenceMentionCandidate: 'session-reference.md', SessionReferenceInput: 'session-reference.md', SessionEvent: 'session.md', SessionId: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 4be1cd9d33..2763b76ddd 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -247,6 +247,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['session-reference', 'tool-session-query'], note: 'The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering.', }, + { + key: 'fileReferences', + pkg: 'file-reference', + title: 'File reference discovery', + mode: 'seam', + implementations: ['file-reference-local'], + note: 'The interface returns path-only completion candidates within the addressed Agent cwd through its unary Remote contract; providers own namespace access and ranking without reading file contents.', + }, { key: 'sessionReferenceResolver', pkg: 'session-reference', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 3c7038937c..8c51d4f813 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -685,11 +685,21 @@ "symbol": "SessionReferenceInput", "source": "packages/context/session-reference/src/types.ts" }, + { + "doc": "docs/subsystems/session-reference.md", + "symbol": "FileReferenceCandidate", + "source": "packages/context/file-reference/src/types.ts" + }, { "doc": "docs/subsystems/session-reference.md", "symbol": "SessionReferenceCandidate", "source": "packages/context/session-reference/src/types.ts" }, + { + "doc": "docs/subsystems/session-reference.md", + "symbol": "SessionReferenceMentionCandidate", + "source": "packages/context/session-reference/src/types.ts" + }, { "doc": "docs/subsystems/session-reference.md", "symbol": "PreparedReferencedMessage", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 707d979f23..2b9d4966c8 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -76,6 +76,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-jobs': { kind: 'none', reason: 'Browser-side read-only projection of ctx.jobs records; dsh-tool-jobs owns the model-facing behavior.' }, 'packages/client/ui-workflow-run': { kind: 'none', reason: 'Browser-side UI plugin layer; renders durable workflow records without changing model context.' }, 'packages/client/ui-input-trigger': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-reference': { kind: 'indirect', reason: 'Browser-side reference selection delegates file guidance and session snapshot preparation to Host-owned providers.' }, 'packages/client/ui-commands': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, 'packages/client/ui-model-selection': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' }, 'packages/client/ui-goal': { kind: 'indirect', reason: 'The strip verbs route goal.* mutations; the host GoalService owns the model-visible goal/change context message.' }, @@ -96,6 +97,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/locale': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/web': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' }, + 'packages/context/file-reference': { kind: 'indirect', reason: 'The discovery seam and grammar delegate model guidance to the composed provider.' }, 'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' }, 'packages/e2b/fs-e2b': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 3a74653569..ae405569bb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -88,6 +88,9 @@ "@deepseek-ai/dsh-api-remotes/invariant": ["./packages/api/remotes/src/invariant.ts"], "@deepseek-ai/dsh-compaction/checkpoint": ["./packages/compaction/compaction/src/checkpoint.ts"], "@deepseek-ai/dsh-compaction/types": ["./packages/compaction/compaction/src/types.ts"], + "@deepseek-ai/dsh-file-reference/grammar": ["./packages/context/file-reference/src/grammar.ts"], + "@deepseek-ai/dsh-file-reference/types": ["./packages/context/file-reference/src/types.ts"], + "@deepseek-ai/dsh-session-reference/types": ["./packages/context/session-reference/src/types.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-tools/types": ["./packages/core/tools/src/types.ts"], "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], @@ -203,6 +206,7 @@ "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"], "@deepseek-ai/dsh-client-ui-workflow-run": ["./packages/client/ui-workflow-run/src"], "@deepseek-ai/dsh-client-ui-input-trigger": ["./packages/client/ui-input-trigger/src"], + "@deepseek-ai/dsh-client-ui-reference": ["./packages/client/ui-reference/src"], "@deepseek-ai/dsh-client-ui-commands": ["./packages/client/ui-commands/src"], "@deepseek-ai/dsh-client-ui-model-selection": ["./packages/client/ui-model-selection/src"], "@deepseek-ai/dsh-client-ui-goal": ["./packages/client/ui-goal/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 8eb1983094..17dbaff070 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -70,6 +70,7 @@ { "path": "./packages/client/ui-input-trigger" }, { "path": "./packages/client/ui-commands" }, { "path": "./packages/client/ui-skill" }, + { "path": "./packages/client/ui-reference" }, { "path": "./packages/client/ui-subagent" }, { "path": "./packages/client/ui-jobs" }, { "path": "./packages/client/ui-directory-picker-browse" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 28d61a7054..a4d82de228 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -40,6 +40,7 @@ "apps/web/tests/cold-blank-session.e2e.ts", "apps/web/tests/stats-paged-history.e2e.ts", "apps/web/tests/sidebar-scrollbar.e2e.ts", + "apps/web/tests/rail-search-expand.e2e.ts", "apps/web/tests/conversation-column-overflow.e2e.ts", "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/composer-draft-scroll.e2e.ts", @@ -49,6 +50,7 @@ "apps/web/tests/message-feedback.e2e.ts", "apps/web/tests/message-feedback-layout.e2e.ts", "apps/web/tests/markdown-images.e2e.ts", + "apps/web/tests/reference-composer.e2e.ts", "apps/web/tests/math-rendering.e2e.ts", "apps/web/tests/markdown-cjk-strong.e2e.ts", "apps/web/tests/markdown-inline-code-links.e2e.ts", @@ -175,6 +177,8 @@ { "path": "./packages/feedback/command-feedback" }, { "path": "./packages/schedule/schedule" }, { "path": "./packages/context/time-context" }, + { "path": "./packages/context/file-reference" }, + { "path": "./packages/context/file-reference-local" }, { "path": "./packages/context/tmux-context" }, { "path": "./packages/context/session-reference" }, { "path": "./packages/interaction/user-questions" },