diff --git a/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml new file mode 100644 index 0000000000..a4882f82cb --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.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-09-03-session-search-result-reveal.md +2026-09-03-session-search-result-reveal.md: dc43d5e06febf61852d4b8dcdd0b5ca290f623e0 +2026-09-03-session-search-result-reveal.zh.md: 0982498f5602ee18b20368d2b0dc04ab1ffae7f0 diff --git a/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.md b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.md new file mode 100644 index 0000000000..dc43d5e06f --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.md @@ -0,0 +1,36 @@ +# Agent Note: Session search result reveal + +Status: implemented +Archived: 2026-09-03 + +English | [中文](2026-09-03-session-search-result-reveal.zh.md) + +## Problem + +Selecting a Session search result opened its conversation while leaving the sidebar in the filtered search view. The user could not see where the Session belonged in the normal Workspace hierarchy. Clearing search alone was insufficient because the owning Workspace could be closed, the Session could be hidden beyond the five-row fold, and either grouped or flat navigation could place the row outside the scrollport. + +## Decision + +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) treats result selection as a transition back to normal browsing. It records the target Session id, clears the query, collapses search, and opens the Session. In grouped browsing, `SessionTree` waits until the current Workspace stream has a complete Host baseline, derives and opens the owning Workspace or Ungrouped group, and transiently reveals the hidden remainder only when the target is behind the five-row fold. Flat browsing needs no fold override. + +The normal Session row owns completion of the one-shot reveal. A matching mounted row scrolls itself into the nearest visible position and acknowledges the target id, preventing later renders from repeating the scroll. Starting another non-empty search cancels an unacknowledged reveal. Metadata and content matches use the same transition because both result kinds resolve to a Session id. + +## Alternatives considered + +**Preserve the query after opening the Session.** This keeps the discovery context but leaves the user in the temporary result list and does not identify the Session's normal location. + +**Clear search without opening or unfolding the owning group.** The conversation would open while its selected row could remain hidden, reproducing the missing-location problem in a different sidebar state. + +**Persist an expanded-all preference for the group.** One navigation would permanently replace the bounded five-row presentation. The reveal instead expands the remainder only for the current tree mount. + +**Scroll from the browser parent.** The parent cannot complete the operation before a folded target row mounts. The row that owns the DOM element performs and acknowledges the scroll. + +## Consequences + +Selecting a result discards the current query and returns the sidebar to normal browsing. The owning group stays open, and its hidden remainder is visible for that tree mount when required, so the selected row supplies both hierarchy context and an on-screen location. Waiting for the current Workspace baseline prevents a reconnect's retained membership from acknowledging the reveal before replacement state arrives. A later search or ordinary render does not repeat the scroll after acknowledgment. + +The target row is the only completion signal. If another client archives or moves the Session between result selection and row mount, the reveal remains armed; the row will scroll if it mounts later, unless a new non-empty search cancels the reveal or the browser unmounts. + +## Testing + +UI tests cover a content-only hit in the sixth position of a closed Workspace, pending and reconnecting Workspace baselines, cancellation by a new search, transient group expansion and scroll acknowledgment, and the same one-shot scroll in flat mode. The assembled Web navigation test verifies that one click clears search and leaves exactly one selected Session row in the normal tree. The long-conversation browser test opens its seeded Session with that single-click transition. diff --git a/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.zh.md b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.zh.md new file mode 100644 index 0000000000..0982498f56 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-09-03-session-search-result-reveal.zh.md @@ -0,0 +1,36 @@ +# Agent Note: Session 搜索结果显露 + +Status: implemented +Archived: 2026-09-03 + +[English](2026-09-03-session-search-result-reveal.md) | 中文 + +## 问题 + +选择 Session 搜索结果会打开其对话,但侧边栏仍停留在筛选后的搜索视图。用户无法在常规 Workspace 层级中看到该 Session 的所属位置。仅清空搜索还不够,因为所属 Workspace 可能处于关闭状态,Session 可能隐藏在五行折叠之后,而分组或单列表导航都可能把该行放在滚动区域之外。 + +## 决策 + +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 将结果选择视为返回常规浏览的状态切换。它会记录目标 Session id、清空查询、收起搜索并打开 Session。在分组浏览中,`SessionTree` 会等待当前 Workspace stream 取得完整 Host 基线,再解析并打开所属 Workspace 或 Ungrouped 分组;仅当目标位于五行折叠之后时,才会临时显露其余隐藏条目。单列表浏览不需要覆盖折叠状态。 + +常规 Session 行负责完成一次性显露。匹配的行挂载后会将自身滚动到最近的可见位置,再确认目标 id 已处理,避免后续渲染重复滚动。开始另一项非空搜索会取消尚未确认的显露。元数据命中与内容命中都会解析为 Session id,因此共用同一套状态切换。 + +## 考虑过的替代方案 + +**打开 Session 后保留查询。** 这会保留发现上下文,却仍让用户停留在临时结果列表中,无法识别 Session 在常规层级中的位置。 + +**只清空搜索,不打开或展开所属分组。** 对话会打开,但选中行仍可能隐藏,只是把缺失位置的问题带到另一种侧边栏状态。 + +**为分组持久保存全部展开偏好。** 一次导航会永久取代有界的五行呈现。本次显露只在当前树挂载期间展开其余条目。 + +**由浏览器父组件执行滚动。** 折叠的目标行挂载前,父组件无法完成操作。持有 DOM 元素的行负责滚动并确认完成。 + +## 后果 + +选择结果会丢弃当前查询,并让侧边栏返回常规浏览。所属分组保持打开,并在需要时于该次树挂载期间显示隐藏条目,因此选中行同时提供层级上下文与屏幕内位置。等待当前 Workspace 基线可避免重连期间保留的旧归属信息在替换状态到达前确认显露。确认完成后,后续搜索或普通渲染不会重复滚动。 + +目标行是唯一的完成信号。如果另一客户端在结果选择与行挂载之间归档或移动了 Session,显露会保持待处理;除非新的非空搜索取消显露或浏览器卸载,否则该行以后挂载时仍会触发滚动。 + +## 测试 + +UI 测试覆盖关闭 Workspace 中位于第六位的纯内容命中、待定与重连中的 Workspace 基线、新搜索取消显露、临时分组展开与滚动确认,以及单列表模式下的同类一次性滚动。组装层 Web 导航测试验证一次点击会清空搜索,并在常规树中只留下一个选中的 Session 行。长对话浏览器测试通过这次单击状态切换打开预置 Session。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 1247fb5ed0..88606a0992 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -145,6 +145,9 @@ "bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml": "sha256:f7a20bddd4544738ec0dbbfc52ea931f42317defa1674beb9a3c0daebd52fc2d", "bug-fix/2026-08-24-system-prompt-section-order-ties.md": "sha256:108a97346eb7a62f1ab01f48dbb9fdd965e8991f53e382b0f501b916af0e9e23", "bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md": "sha256:3deaddfcf9736b3ff8d61b51093d7e46fdcc86103705033e4aa4c9d043794b16", + "bug-fix/2026-09-03-session-search-result-reveal.i18n.yaml": "sha256:ad9dcedeb25ab3eddbc51660935abaf6e6e92eefb66fd14f8852ba5f30e725b5", + "bug-fix/2026-09-03-session-search-result-reveal.md": "sha256:ce8983a9ffa3d1b59946aeecf0e10177c8316c3621d5d0718a03e8eedcd05fd9", + "bug-fix/2026-09-03-session-search-result-reveal.zh.md": "sha256:cd3ca0290f259a252e0b19fd0f15ac62232847be6f186e16f72652044a034bbe", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 28ab0da3dd..fbb3f653a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.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-07-25-web-input-machine-and-slash-pipeline.md -2026-07-25-web-input-machine-and-slash-pipeline.md: 200761cc9e648eea80bdae9d7b363246c816e5d1 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 673d0ee4bd0916b20ee74226f50240e3904fe06c +2026-07-25-web-input-machine-and-slash-pipeline.md: 1b9e9d95b5a30efbf297be5fc5f788f9a1ac77c4 +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: c9bee217da1dbffaeff69dfe5a2dcf8f0e8e3cb0 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index 200761cc9e..1b9e9d95b5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -67,8 +67,8 @@ skill/@subagent references skip the placeholder + occurrence identity chain — - PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes. - Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast. -- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text. -- Sending is the literal text (no more `` serialization); on the bubble side MessageItem decorates both shapes (the legacy `` tag + plain-text tokens). +- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits; a `/name` token also ends at whitespace or the draft end — the whitespace-bounded shape of the host skill gesture, so `/nfs-hg/xxx` is a path and `/plan。` is prose; the sent-text projection `projectUserText` in ui-primitives applies the same shape) against the roster; a hit becomes a `TextRefNode` entity in the Lexical tree (the claim decoration has precedence on the leading-token seat — [the Lexical composer note](2026-08-20-web-composer-lexical-editor.md)); an edit breaking the match shape reverts the entity to plain text. +- Sending is the literal text (no more `` serialization); on the bubble side `projectUserText` decorates a plain-text `/name` token only when the same step logged a `skill-invocation` injection for that name — ui-chat's `SkillNameProjector` attaches the step's injected names to the direct message Node, the way the recall projector attaches session labels — so `/123` or a stray `/word` stays plain; a command-input bubble (ui-goal) names its executed command the same way and renders the token as a `command` chip; `@name` tokens still decorate by shape. - Decoration reactivity: the shell subscribes to the controller's lexicon store and re-scans the document on each roll change, so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render. ### Per-session provide contributions and the private keyboard surface diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index 673d0ee4bd..c9bee217da 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -67,8 +67,8 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——纯文本引 - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同约定:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。 - source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` 快照 store(每次 source 通知重拉);scope 出生后才注册的 source 由服务广播给活 controller,补 warm 并并入名录。 -- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。 -- 发送即原文(不再 `` 序列化);气泡侧 MessageItem 双形状装饰(legacy `` 标签 + 纯文本 token)。 +- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中;`/name` token 还必须止于空白或 draft 末尾——与宿主 skill gesture 同样以空白为界,因此 `/nfs-hg/xxx` 是路径、`/plan。` 是普通文本;ui-primitives 中已发送文本的投影 `projectUserText` 采用同一形状)对照名录,命中即成为 Lexical 树中的 `TextRefNode` 实体(claim 装饰对行首 token 席位有优先权——见 [Lexical composer note](2026-08-20-web-composer-lexical-editor.zh.md));编辑破坏匹配形状时实体还原为普通文本。 +- 发送即原文(不再 `` 序列化);气泡侧 `projectUserText` 只在同一步骤记录了该名字的 `skill-invocation` 注入时才装饰纯文本 `/name` token——ui-chat 的 `SkillNameProjector` 把该步骤注入的 skill 名挂到直接消息节点上,与 recall 投影挂会话标签的方式相同——因此 `/123` 或随手敲的 `/词` 保持普通文本;指令输入气泡(ui-goal)以同样方式指明其已执行的指令,把 token 渲染为 `command` chip;`@name` token 仍按形状装饰。 - 装饰响应性:shell 订阅 controller 的 lexicon store,每次名录变化重扫全文档,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。 ### 每会话供数贡献与键盘私面 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index 8c8de93c31..eee0bed2ef 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 425975944f7b23575c4f716dee8754dc6b3ab54c -2026-08-09-client-conversation-node-assembly.zh.md: 96439fa951cdc1d6abe397ee37661291c0f954a7 +2026-08-09-client-conversation-node-assembly.md: 50e11b04d6461e99cdddb8e5e2d94bf864228841 +2026-08-09-client-conversation-node-assembly.zh.md: 7be1cd1b7f356caf51b8003821369771b0a6e269 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 425975944f..50e11b04d6 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -278,7 +278,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec |---|---|---|---| | Inbox | `none` | No Node | Recompute next-step ID state along the Reader chain when prepend supplies earlier splices; next-turn creates no Chat Context | | Message | Immediate by default | `user`, `steering`, or `context` | Window-gap repair can reclassify the same message key | -| Request Prompt | Immediate by default | One `system-prompt` for every header carrying a non-empty system field | A step's first header anchors before its request messages; a later same-step series anchors after its surface rewrite; prepend of the preceding header can correct a partial-window anchor | +| Request Prompt | Immediate by default | One non-empty `system-prompt` for the initial request, each explicit series, or a real system change | A step's first header anchors before its request messages; prepend can hide a conservatively rendered resume after its preceding header proves the system unchanged | | Assistant | RAF for scalar chunks and packed runs, immediate for final, none for pure usage/finish | Same-key `assistant-step` with running/settled/interrupted status | Scalar and packed reducers are equivalent; Matches support fallback without `step/start`; Location close produces interruption presentation | | Tool | Immediate by default | One recursive `tool-call` root containing all `subCalls` | A result-only history window supports fallback; running→settled retains its key | | Command | Immediate by default | Ordinary `command` or integrated `manual-compaction` | Checkpoint arrival may change the anchor without changing the Context key | @@ -291,7 +291,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each next-step state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox. The state itself shares immutable pending splice nodes and one current claimed-batch Set, while unconsumed next-turn input remains outside Conversation because no Chat or Trajectory classification reads it. -Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes every header carrying a non-empty system field, including `series` snapshots that repeat an unchanged header for an explicitly declared series or a post-replacement request, while Trajectory retains the complete request fact and its change classification. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event until prepend supplies that predecessor. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history. +Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes a non-empty initial system field, a real system change, and each `series` snapshot that explicitly begins a message series or follows a surface replacement. An unchanged `resume` remains in Trajectory and reconstruction state but does not repeat the visible Chat row once its predecessor is loaded. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event and renders conservatively. Prepending an identical predecessor hides an unchanged resume without withdrawing its stable Node key; a real change remains visible. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history ([resume presentation decision](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md)). Retry, Assistant, and Turn Tail demonstrate independent claims on one Event. Each Definition updates only its own State and produces its own atomic Chat Node. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 96439fa951..7be1cd1b7f 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -278,7 +278,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新 |---|---|---|---| | Inbox | `none` | 不生成 Node | prepend 补前序 splice 时沿 Reader 链重算 next-step ID state;next-turn 不创建 Chat Context | | Message | 默认 immediate | `user`、`steering` 或 `context` | window gap 修复可让同一 message key 重新分类 | -| Request Prompt | 默认 immediate | 每条带非空 system 字段的 header 都生成一个 `system-prompt` | Step 首条 header 锚定在请求消息之前;同 step 后续序列锚定在表层改写之后;prepend 补入前序 header 后可纠正部分窗口的锚点 | +| Request Prompt | 默认 immediate | 初始请求、每个显式序列或真实 system 变化各生成一个非空 `system-prompt` | Step 首条 header 锚定在请求消息之前;prepend 补入前序 header 并证明 system 未变后,可隐藏此前保守渲染的 resume | | Assistant | scalar chunk 与 packed run 为 RAF,final immediate,纯 usage/finish 为 none | 同 key `assistant-step`,状态为 running/settled/interrupted | scalar 与 packed reducer 等价;缺 `step/start` 可先用 Matches fallback;Location close 生成中断表现 | | Tool | 默认 immediate | 一个递归 `tool-call` root,包含全部 `subCalls` | result-only 历史窗口可 fallback;running→settled 保持 key | | Command | 默认 immediate | 普通 `command` 或集成 `manual-compaction` | checkpoint 到达可改变 anchor,但不改变 Context key | @@ -291,7 +291,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新 Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。每个 next-step state 通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。state 自身共享不可变 pending splice 节点和一个当前 claimed-batch Set;未消费的 next-turn input 不进入 Conversation,因为 Chat 与 Trajectory 都不读取它来分类。 -Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化每条带非空 system 字段的 header,包括为显式声明的序列或表层替换后的请求重复未变 header 的 `series` 快照;Trajectory 则保留完整请求事实及其变化分类。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event,直到 prepend 补入该前序 header。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段。 +Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化非空的初始 system 字段、真实 system 变化,以及每个显式开启消息序列或紧随表层替换的 `series` 快照。未变化的 `resume` 会留在 Trajectory 和重建状态中,但前序 header 已加载时不会重复可见的 Chat 行。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event 并保守渲染。prepend 补入相同的前序 header 后,内容未变的 resume 会隐藏但不撤回其稳定 Node key;真实变化仍然可见。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段([resume 展示决策](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md))。 Retry、Assistant 和 Turn Tail 展示了同一 Event 被多个 Definition 独立认领。每个 Definition 只更新自己的 State,最终分别生成原子 Chat Node。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml index 1fd7278446..76462e861d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.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-07-31-same-basename-workspace-adoption.md -2026-07-31-same-basename-workspace-adoption.md: a634972448d8a3fea3d08c5713602cec0584a44e -2026-07-31-same-basename-workspace-adoption.zh.md: 82be4aee3b3da2e9fb02783f1cd9bf470802cae0 +2026-07-31-same-basename-workspace-adoption.md: d7c4f7a9d67d760dccbdc536b4894292a171ee3e +2026-07-31-same-basename-workspace-adoption.zh.md: 968863a24c6e7c6237d814f20e9055fd029178a4 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md index a634972448..d7c4f7a9d6 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md @@ -6,11 +6,11 @@ English | [中文](2026-07-31-same-basename-workspace-adoption.zh.md) ## Problem -A Workspace is identified by its stable id and canonical directory path, while its title is mutable display metadata. The registry nevertheless rejected a new canonical path when its basename-derived title matched another Workspace. Common directory layouts such as `/a/xx` and `/b/xx` therefore could not coexist in the Web UI, even though the [domain design](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) already permits duplicate titles and every client operation addresses a Workspace by id. +A Workspace is identified by its stable id and canonical directory path, while its title is mutable display metadata. The registry nevertheless rejected a new canonical path when its directory-derived title matched another Workspace. Common directory layouts such as `/a/xx` and `/b/xx` therefore could not coexist in the Web UI, even though the [domain design](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) already permits duplicate titles and every client operation addresses a Workspace by id. ## Decision -`ctx.workspaceRegistry.create(path, title?)` treats canonical path as the only uniqueness key. Repeating the same path remains idempotent and preserves the registered title. Different canonical paths create different Workspace records and may share a title; when no title is supplied, each record still derives its title from `basename(path)` without suffixing or rewriting it. +`ctx.workspaceRegistry.create(path, title?)` treats canonical path as the only uniqueness key. Repeating the same path remains idempotent and preserves the registered title. Different canonical paths create different Workspace records and may share a title; when no title is supplied, each record derives its title from the final path segment, falling back to the root spelling when that segment is empty. The [fully qualified Workspace path decision](2026-09-03-fully-qualified-workspace-paths.md) owns path admission and the title fallback. The Host's `workspace.create({ path })` adoption route inherits that rule. The Workspace manager, picker, grouping tree, selection, rename, deletion, and Session creation continue to use `WorkspaceId`, so equal labels neither merge records nor redirect an operation. The sidebar hover card exposes each canonical path when the labels need disambiguation. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md index 82be4aee3b..968863a24c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -Workspace 的身份由其稳定 id 和规范目录路径确定,标题则是可变的显示元数据。然而,只要新规范路径按 basename 派生出的标题与另一个 Workspace 相同,注册表就会拒绝该路径。因此,`/a/xx` 和 `/b/xx` 等常见目录布局无法同时出现在 Web UI 中,尽管[领域设计](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)早已允许标题重复,而且每项客户端操作都通过 id 定位 Workspace。 +Workspace 的身份由其稳定 id 和规范目录路径确定,标题则是可变的显示元数据。然而,只要新规范路径按目录名称派生出的标题与另一个 Workspace 相同,注册表就会拒绝该路径。因此,`/a/xx` 和 `/b/xx` 等常见目录布局无法同时出现在 Web UI 中,尽管[领域设计](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)早已允许标题重复,而且每项客户端操作都通过 id 定位 Workspace。 ## 决策 -`ctx.workspaceRegistry.create(path, title?)` 仅以规范路径作为唯一性键。重复传入同一路径仍保持幂等,并保留已注册的标题。不同的规范路径会创建不同的 Workspace 记录,且可以共用标题;未提供标题时,每条记录仍从 `basename(path)` 派生标题,不添加后缀,也不改写标题。 +`ctx.workspaceRegistry.create(path, title?)` 仅以规范路径作为唯一性键。重复传入同一路径仍保持幂等,并保留已注册的标题。不同的规范路径会创建不同的 Workspace 记录,且可以共用标题;未提供标题时,每条记录从最终路径段派生标题,该路径段为空时回退到根路径拼写。[Workspace 完全限定路径决策](2026-09-03-fully-qualified-workspace-paths.zh.md)负责规定路径准入和标题回退。 Host 的 `workspace.create({ path })` 接纳入口沿用该规则。Workspace 管理器、选择器、分组树、选择、重命名、删除和 Session 创建仍使用 `WorkspaceId`,因此相同标签既不会合并记录,也不会把操作指向其他记录。需要区分相同标签时,侧边栏悬停详情卡会显示各自的规范路径。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.i18n.yaml new file mode 100644 index 0000000000..70f8f76907 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.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-09-03-fully-qualified-workspace-paths.md +2026-09-03-fully-qualified-workspace-paths.md: 0bc6215ec5f6485856153b5a5c59d0ed68f485bb +2026-09-03-fully-qualified-workspace-paths.zh.md: b8e67f9d34fc8ccb7759c95017dd3ab4fde56113 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.md b/.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.md new file mode 100644 index 0000000000..0bc6215ec5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.md @@ -0,0 +1,35 @@ +# Agent Note: Fully qualified Workspace paths + +Status: implemented + +English | [中文](2026-09-03-fully-qualified-workspace-paths.zh.md) + +## Problem + +Workspace path identity must name one directory independently of process state. POSIX relative paths, Windows drive-relative paths such as `C:work`, and Windows root-relative paths such as `\\work` can resolve against the Host cwd or the current directory retained for a drive. Passing those spellings to `realpath` can therefore register a different directory when host state changes. Filesystem roots also have an empty basename, which can create an empty default Workspace title. + +Windows drive roots need separate handling in browser-safe relative-path joins. Removing the trailing separator from `C:\\` produces `C:`, which changes an absolute path into a drive-relative path. + +## Decision + +`WorkspaceRegistry.create()` and `resolveByPath()` reject paths that are not fully qualified before calling `realpath`. POSIX requires an absolute path. Windows requires `win32.isAbsolute(path)` plus a parsed root that is neither `\\` nor `/`; this accepts drive-qualified and UNC paths while rejecting current-drive-root and drive-relative spellings without maintaining a second path grammar. + +Canonical paths remain the registry identity. A default title uses the final path segment, or `node:path`'s parsed root when that segment is empty. This refines the display rule owned by [same-basename Workspace adoption](2026-07-31-same-basename-workspace-adoption.md) without making titles unique. + +The browser-safe `resolveWorkspacePath()` removes trailing separators only after choosing the separator from the Workspace spelling. Backslash drive and UNC paths keep `\\`; forward-slash drive paths keep `/`; joining a drive root always retains the separator after the colon. + +## Alternatives considered + +**Resolve relative paths against the Host cwd.** Rejected because Workspace identity would depend on process state that callers do not supply and remote clients cannot observe. + +**Resolve relative paths against another stored Workspace.** Rejected because create and lookup requests do not identify such an anchor, and guessing one would make the same path spelling address different records. + +**Maintain a regular expression for drive and UNC syntax.** Rejected because `node:path.win32` already parses roots and absolute paths; a second grammar can diverge on separator variants and UNC roots. + +**Use an empty title for filesystem roots.** Rejected because the title is the primary Workspace label. The root spelling is short, stable, and already distinguishes drive and UNC roots. + +## Consequences + +Callers must submit fully qualified Workspace paths. Invalid path spellings fail before filesystem access, while nonexistent fully qualified paths still return the original filesystem error. Windows drive and UNC roots remain valid identities and have non-empty default titles; an UNC share root uses the share name as its final segment. + +Workspace-relative joins preserve the separator style already present in the Workspace root. Unit tests cover POSIX roots, drive roots, UNC roots, rejected drive-relative and current-drive-root paths, and both Windows separator styles. diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.zh.md b/.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.zh.md new file mode 100644 index 0000000000..b8e67f9d34 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-fully-qualified-workspace-paths.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Workspace 完全限定路径 + +Status: implemented + +[English](2026-09-03-fully-qualified-workspace-paths.md) | 中文 + +## 问题 + +Workspace 路径身份必须在不依赖进程状态的情况下指向唯一目录。POSIX 相对路径、`C:work` 等 Windows 驱动器相对路径,以及 `\\work` 等 Windows 当前驱动器根相对路径,可能依据宿主 cwd 或驱动器保留的当前目录完成解析。把这些拼写传给 `realpath`,会在宿主状态变化时注册不同目录。文件系统根目录的 basename 也为空,可能产生空的默认 Workspace 标题。 + +浏览器安全的相对路径连接还需要单独处理 Windows 驱动器根。移除 `C:\\` 的尾部分隔符会得到 `C:`,从而把绝对路径变成驱动器相对路径。 + +## 决策 + +`WorkspaceRegistry.create()` 和 `resolveByPath()` 会在调用 `realpath` 前拒绝不是完全限定形式的路径。POSIX 要求绝对路径。Windows 要求 `win32.isAbsolute(path)`,且解析出的根既不是 `\\` 也不是 `/`;该规则接受驱动器限定路径与 UNC 路径,同时无需维护第二套路径语法即可拒绝当前驱动器根拼写和驱动器相对拼写。 + +规范路径继续作为注册表身份。默认标题使用最终路径段;该路径段为空时,则使用 `node:path` 解析出的根。该规则细化了[接纳 basename 相同 Workspace](2026-07-31-same-basename-workspace-adoption.zh.md)拥有的显示规则,但不会要求标题唯一。 + +浏览器安全的 `resolveWorkspacePath()` 会先根据 Workspace 拼写选择分隔符,再移除尾部分隔符。使用反斜杠的驱动器与 UNC 路径保留 `\\`,使用正斜杠的驱动器路径保留 `/`,与驱动器根连接时始终保留冒号后的分隔符。 + +## 考虑过的替代方案 + +**依据宿主 cwd 解析相对路径。** 不予采纳,因为 Workspace 身份将依赖调用方未提供、远程客户端无法观察的进程状态。 + +**依据另一个已存储 Workspace 解析相对路径。** 不予采纳,因为 create 和 lookup 请求没有指定这种锚点,猜测锚点会让同一路径拼写指向不同记录。 + +**维护用于驱动器和 UNC 语法的正则表达式。** 不予采纳,因为 `node:path.win32` 已经解析根和绝对路径;第二套语法可能在分隔符变体和 UNC 根上发生偏差。 + +**为文件系统根目录使用空标题。** 不予采纳,因为标题是 Workspace 的主要标签。根路径拼写简短、稳定,并且已经可以区分驱动器与 UNC 根。 + +## 后果 + +调用方必须提交完全限定的 Workspace 路径。无效路径拼写会在文件系统访问前失败,而不存在的完全限定路径仍返回原始文件系统错误。Windows 驱动器根与 UNC 根继续作为有效身份,并具有非空默认标题;UNC share 根使用 share 名称作为最终路径段。 + +Workspace 相对路径连接会保留 Workspace 根中已有的分隔符风格。单元测试覆盖 POSIX 根、驱动器根、UNC 根、被拒绝的驱动器相对路径与当前驱动器根路径,以及两种 Windows 分隔符风格。 diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.i18n.yaml similarity index 55% rename from .agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.i18n.yaml index 6851450147..49d1e84293 100644 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.i18n.yaml @@ -1,6 +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-17-command-image-attachment-envelope.md -2026-08-17-command-image-attachment-envelope.md: 328a3fffa1d8db3ac9be42983965ef7f9578dec9 -2026-08-17-command-image-attachment-envelope.zh.md: 9de197d06692523b7b5bca4f7aac36ce9f44b105 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.md +2026-09-03-hidden-windows-subprocess-windows.md: 97af84ddc0a51483fa0f1147caee0ea0374c240d +2026-09-03-hidden-windows-subprocess-windows.zh.md: d8f986278e974342cd24abd6fd3192165762c1f9 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.md b/.agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.md new file mode 100644 index 0000000000..97af84ddc0 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.md @@ -0,0 +1,29 @@ +# Agent Note: Suppressing Windows subprocess windows + +Status: implemented + +English | [中文](2026-09-03-hidden-windows-subprocess-windows.zh.md) + +## Problem + +The local subprocess provider can run under a GUI or service host with no visible console. Windows creates a new visible window for a child process when the host does not supply one, so an ordinary command or a `taskkill` helper can flash and take focus even though the harness has no user-facing terminal for that process. + +## Decision + +The provider sets `windowsHide: true` on every non-terminal `spawn` and on both synchronous `taskkill` call sites. The main child uses this option only for the Windows execution path; `taskkill` is itself Windows-only. Terminal processes retain the visibility and console behavior owned by the PTY implementation. + +The option hides console windows and GUI windows that honor the Windows process startup visibility setting. Callers do not choose this behavior because the local provider owns whether its background process management creates host windows. + +## Alternatives considered + +**Hide only the main child.** Rejected because cancellation, timeout escalation, terminal teardown, and host-exit cleanup can still launch `taskkill` and flash a console window. + +**Expose a caller option.** Rejected because consumers cannot reliably know whether the local host has a console, and inconsistent choices would reintroduce focus-stealing process-management windows. + +**Hide only console programs.** Rejected because Node exposes one Windows startup option rather than a reliable pre-spawn executable classification, and probing the target would add platform-specific races without preserving a useful product behavior. + +## Consequences + +Background subprocess operations do not create visible Windows child or `taskkill` windows. A directly launched GUI program that honors the startup visibility setting also starts hidden; consumers that need an interactive visible application must use a capability that owns that user interaction instead of the background subprocess provider. + +Unit tests inject the process launchers and pin `windowsHide` for the main child and both `taskkill` paths without creating host-global windows or terminating real processes. diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.zh.md b/.agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.zh.md new file mode 100644 index 0000000000..d8f986278e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-hidden-windows-subprocess-windows.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 隐藏 Windows 子进程窗口 + +Status: implemented + +[English](2026-09-03-hidden-windows-subprocess-windows.md) | 中文 + +## 问题 + +本地 subprocess provider 可以在没有可见控制台的 GUI 或服务宿主中运行。宿主未提供控制台时,Windows 会为子进程创建新的可见窗口,因此普通命令或 `taskkill` 辅助进程可能闪现并抢占焦点,即使 harness 并未为该进程提供面向用户的 terminal。 + +## 决策 + +provider 对每次非 terminal `spawn` 以及两处同步 `taskkill` 调用都设置 `windowsHide: true`。主子进程只在 Windows 执行路径使用此选项;`taskkill` 本身只用于 Windows。terminal 进程继续采用 PTY 实现拥有的可见性与控制台行为。 + +该选项会隐藏控制台窗口,以及遵循 Windows 进程启动可见性设置的 GUI 窗口。调用方不能选择此行为,因为本地 provider 负责决定其后台进程管理是否创建宿主窗口。 + +## 考虑过的替代方案 + +**只隐藏主子进程。** 不予采纳,因为取消、超时升级、terminal 拆卸与宿主退出清理仍可能启动 `taskkill` 并闪现控制台窗口。 + +**暴露调用方选项。** 不予采纳,因为消费方无法可靠判断本地宿主是否拥有控制台,不一致的选择会重新引入抢占焦点的进程管理窗口。 + +**只隐藏控制台程序。** 不予采纳,因为 Node 只暴露一个 Windows 启动选项,无法在 spawn 前可靠区分可执行文件类型;探测目标还会增加平台特定竞态,却不能保留有用的产品行为。 + +## 后果 + +后台 subprocess 操作不会创建可见的 Windows 子进程或 `taskkill` 窗口。直接启动且遵循启动可见性设置的 GUI 程序也会以隐藏方式运行;需要交互式可见应用的消费方必须使用拥有该用户交互的能力,而不是后台 subprocess provider。 + +单元测试注入进程 launcher,固定主子进程和两条 `taskkill` 路径的 `windowsHide`,且不会创建宿主全局窗口或终止真实进程。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.i18n.yaml new file mode 100644 index 0000000000..3f2dc80d45 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.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-09-03-normalized-unread-fs-tool-diagnostic.md +2026-09-03-normalized-unread-fs-tool-diagnostic.md: e7ae930ee6b5577d23a58caaeb096929255305a7 +2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md: 907cd09ea28c79d0b26cb4791198ef055774d593 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.md b/.agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.md new file mode 100644 index 0000000000..e7ae930ee6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.md @@ -0,0 +1,29 @@ +# Agent Note: Normalized unread filesystem tool diagnostic + +Status: implemented + +English | [中文](2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md) + +## Problem + +The `dsh-tool-fs` write and edit operations can receive `FS_NOT_OBSERVED` from either the observation policy or a filesystem provider. Those sources describe the same requirement with operation-specific messages, so identical recovery conditions reach the model with different wording. Provider text can also expose whether the rejected operation would overwrite an existing target, although the model only needs to read the target and retry. + +## Decision + +`remediateFsError(error, displayPath)` replaces every `FS_NOT_OBSERVED` message at the `dsh-tool-fs` model boundary with `cannot modify "": file has not been read — read the file, then retry`. The wrapper preserves the structured error code and chains the source error as `cause`, so machine routing and diagnostics can still inspect the original failure. + +`FS_STALE_VERSION` retains the appended re-read remedy owned by the [guarded-mutation remedy note](../feature/2026-08-03-fs-tool-error-remedy.md). Filesystem providers and policies keep their operation-specific messages because other consumers do not share the tool's model-facing presentation. + +## Alternatives considered + +**Append the same recovery suffix to each source message.** Rejected because the model would still receive different reasons for one required action, including provider-specific target-existence detail that does not change recovery. + +**Normalize the provider and policy messages at their source.** Rejected because those components own machine-oriented errors used by consumers other than `dsh-tool-fs`; only the tool owns this model-visible wording. + +**Introduce another error code for the normalized result.** Rejected because the underlying condition and recovery routing remain `FS_NOT_OBSERVED`; changing the code would discard useful compatibility for machine consumers. + +## Consequences + +Write and edit expose one stable unread-target diagnostic regardless of whether policy or provider rejects the mutation. The model gives up source-specific wording and the provider's target-existence hint in exchange for one actionable recovery instruction. The original message remains available through `cause`. + +Unit and integration tests pin both source paths, code preservation, cause chaining, and the exact model-visible text. The `fs-policy-reject` recorded session carries the same diagnostic for replay. diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md b/.agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md new file mode 100644 index 0000000000..907cd09ea2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 统一未读取文件系统工具诊断 + +Status: implemented + +[English](2026-09-03-normalized-unread-fs-tool-diagnostic.md) | 中文 + +## 问题 + +`dsh-tool-fs` 的 write 和 edit 操作可能从观测策略或文件系统提供方收到 `FS_NOT_OBSERVED`。这些来源用操作特定消息描述相同要求,因此相同恢复条件会以不同措辞到达模型。提供方文本还可能暴露被拒绝的操作是否会覆盖既有目标,但模型只需读取目标后重试。 + +## 决策 + +`remediateFsError(error, displayPath)` 在 `dsh-tool-fs` 模型边界把每条 `FS_NOT_OBSERVED` 消息替换为 `cannot modify "": file has not been read — read the file, then retry`。包装层保留结构化错误码,并把来源错误链为 `cause`,因此机器路由与诊断仍能检查原始故障。 + +`FS_STALE_VERSION` 继续使用[受防护变更恢复指令记录](../feature/2026-08-03-fs-tool-error-remedy.zh.md)拥有的追加式重新读取指令。文件系统提供方与策略保留其操作特定消息,因为其他消费方并不共享该工具面向模型的呈现。 + +## 考虑过的替代方案 + +**为每条来源消息追加相同恢复后缀。** 不予采纳,因为模型仍会为同一项必要操作收到不同原因,其中包含不会改变恢复方式的提供方目标存在性细节。 + +**在提供方与策略源头统一消息。** 不予采纳,因为这些组件拥有供 `dsh-tool-fs` 之外消费方使用的面向机器错误;只有该工具拥有这段模型可见措辞。 + +**为统一后的结果引入另一个错误码。** 不予采纳,因为底层条件与恢复路由仍是 `FS_NOT_OBSERVED`;改变错误码会丢失机器消费方需要的兼容性。 + +## 后果 + +无论变更由策略还是提供方拒绝,write 和 edit 都会给出同一条稳定的未读取目标诊断。模型放弃来源特定措辞和提供方的目标存在性提示,以换取一条统一且可执行的恢复指令。原始消息仍可通过 `cause` 获取。 + +单元与集成测试固定两条来源路径、错误码保留、cause 链和模型可见文本全文。`fs-policy-reject` 录制会话携带同一条诊断用于重放。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml new file mode 100644 index 0000000000..d7871098ba --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.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-09-03-resume-headers-do-not-repeat-system-prompts.md +2026-09-03-resume-headers-do-not-repeat-system-prompts.md: de5e509c9e57af35bc36c24004457f02b63fd99d +2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: 008e25d8d507aac8fbaec7230bad433732f0efd0 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md new file mode 100644 index 0000000000..de5e509c9e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md @@ -0,0 +1,31 @@ +# Agent Note: Resume headers do not repeat system prompts + +Status: implemented + +English | [中文](2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md) + +## Problem + +Forking a Session copies the source history into the child. The child's first model request then records a `request/header` with reason `resume`, even when its system field is identical to the preceding copied header. Chat treated every resume header as a new display point, so continuing the fork showed a second `System prompt` row and suggested that the system prompt had been injected twice. The provider request still carried the system field once; the duplicate existed only in Chat presentation. + +## Decision + +The durable resume header records the request boundary needed for exact Session reconstruction. Chat compares that full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged resume does not create a visible repetition. + +A partial history window may begin with a non-initial header and lack the predecessor needed for comparison. Chat renders that system prompt conservatively. If prepend later supplies an identical predecessor, the existing request-prompt Node becomes hidden instead of being withdrawn; its key and page-lifetime anchor stay stable. A different system field remains visible. + +Trajectory exposes every request header and its classified changes. Chat presentation does not alter provider requests, Session events, or reconstruction. + +## Alternatives considered + +**Omit unchanged resume headers from the Session log.** Rejected: resume is a real request boundary, and removing it would make exact reconstruction depend on process history that the durable log does not contain. + +**Special-case only forked Sessions.** Rejected: an ordinary process resume has the same presentation semantics, and the request headers already contain the system fields needed for a direct comparison. + +**Keep the duplicate row as a lifecycle marker.** Rejected: `System prompt` describes model-visible request content, so using it to mark a loop restart incorrectly implies another prompt injection. Request lifecycle evidence remains available in Trajectory. + +## Consequences + +Continuing a fork or resuming a process with an unchanged system field leaves one visible `System prompt` row for the current message series. Explicit series starts and real system changes still repeat the row. A partial window can initially show a conservative row and hide it after older history loads, while retaining the same materialized Node. + +The unit regression covers initial, series, unchanged resume, system-change, and prepend cases. The Web recorded-session scenario contains an unchanged resume header and asserts that the settled Chat renders exactly one `System prompt` control. diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md new file mode 100644 index 0000000000..008e25d8d5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Resume header 不重复系统提示词 + +Status: implemented + +[English](2026-09-03-resume-headers-do-not-repeat-system-prompts.md) | 中文 + +## 问题 + +fork Session 会把源会话历史复制到子会话。即使 system 字段与前一条被复制的 header 完全相同,子会话的第一个模型请求仍会记录一条 reason 为 `resume` 的 `request/header`。Chat 把每条 resume header 都视作新的展示点,因此继续 fork 会显示第二行`系统提示词`,让人误以为系统提示词被注入了两次。提供方请求实际仍只携带一次 system 字段;重复仅存在于 Chat 展示中。 + +## 决策 + +持久化 resume header 记录精确重建 Session 所需的请求边界。Chat 会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的 resume 不创建可见的重复行。 + +部分历史窗口可能以非初始 header 开头,因缺少前序 header 而无法比较。Chat 会保守渲染该系统提示词。如果 prepend 随后补入相同的前序 header,既有 request-prompt Node 会转为隐藏而不是被撤回;其 key 和页面生命周期内的 anchor 保持稳定。不同的 system 字段仍然可见。 + +Trajectory 会展示每一条请求 header 及其变化分类。Chat 展示不会改变提供方请求、Session event 或重建行为。 + +## 考虑过的替代方案 + +**从 Session log 省略未变化的 resume header。** 否决:resume 是真实的请求边界,移除后精确重建将依赖持久日志未记录的进程历史。 + +**只对 fork Session 做特殊处理。** 否决:普通进程恢复具有相同的展示语义,请求 header 已经包含可直接比较的 system 字段。 + +**把重复行保留为生命周期标记。** 否决:`系统提示词`描述模型可见的请求内容,用它标记 loop 重启会错误暗示再次注入提示词。请求生命周期证据仍可在 Trajectory 中查看。 + +## 后果 + +继续 fork 或在 system 字段未变时恢复进程,当前消息序列只保留一行可见的`系统提示词`。显式序列起点与真实 system 变化仍会重复该行。部分窗口起初可以显示保守行,并在更早历史加载后将其隐藏,同时保留同一个已物化 Node。 + +单元回归覆盖初始请求、显式序列、未变化 resume、system 变化与 prepend 场景。Web 录制会话场景包含一条未变化的 resume header,并断言稳定后的 Chat 只渲染一个`系统提示词`控件。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml index 671ca8f796..706794e459 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.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-23-web-assistant-markdown.md -2026-07-23-web-assistant-markdown.md: d2b8e30d779656636f70b05524c96796a254b57b -2026-07-23-web-assistant-markdown.zh.md: c1542d75faf1b484160f98b4217164b5df4e4b99 +2026-07-23-web-assistant-markdown.md: 4063ad647c485be295a55087247681a494dbeabf +2026-07-23-web-assistant-markdown.zh.md: 6b062e155c88bc8c3f3cf048ec463f3db0b4f7bc diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md index d2b8e30d77..4063ad647c 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md @@ -10,7 +10,7 @@ The Web conversation preserves assistant Markdown source through session events, ## Decision -`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal. +`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages render through `projectUserText` (inline plain runs plus reference chips) and remain literal. `MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences highlight incrementally: each chunk tokenizes newly completed text from a saved grammar state plus the still-growing last line, excluding the completed prefix from repeated work (the [streaming fence-highlight note](2026-08-20-web-streaming-fence-highlight.md) owns that mechanism). @@ -28,7 +28,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen **Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. *Later reversed on new evidence — incremental streaming parsing needs AST-level input the string-only wrapper cannot provide; the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that decision.* -**Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly. +**Render user prompts and steering as Markdown too.** This formats authored input as a side effect. Those authored inputs remain literal until the product chooses that behavior explicitly. **Parse Markdown into session snapshots.** This would make React nodes or presentation ASTs durable runtime state and reintroduce a final-versus-streaming mode boundary. Parsing stays at the presentation leaf instead. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md index c1542d75fa..6b062e155c 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md @@ -10,7 +10,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd ## 决策 -`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。 +`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息经 `projectUserText` 渲染(行内普通片段加引用 chip),并保持按字面渲染。 `MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有该机制及其 DOM 一致性约定)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏增量高亮:每个分片从保存的 grammar state 出发 tokenize 新完成的文本以及仍在增长的最后一行,不重复处理已完成的前缀([流式围栏高亮 Note](2026-08-20-web-streaming-fence-highlight.zh.md) 拥有该机制)。 @@ -28,7 +28,7 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。 **将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。*后因新证据被推翻——增量流式解析需要纯字符串封装无法提供的 AST 级输入;该决策由[增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有。* -**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。 +**把用户提示词与 steering 也按 Markdown 渲染。**这会产生格式化用户输入的副作用。在产品明确选择此行为之前,这些输入仍按字面渲染。 **将 Markdown 解析为会话快照。**这会让 React 节点或呈现层 AST 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index 346c742905..e17531633a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.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-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: 7f97bc395ed694a0a750d3b744cc9887834ef32f -2026-07-27-trajectory-inspection-ledger.zh.md: 6a38147a075480882db5c39a0810404654c6c849 +2026-07-27-trajectory-inspection-ledger.md: 75f8480d3e52c6cc1f2eefd7ef9d5a2c03b40dee +2026-07-27-trajectory-inspection-ledger.zh.md: 82af4904d90a6fbfc811d176dd4b859b26d4774c diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index 7f97bc395e..75f8480d3e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -23,7 +23,7 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. - A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes. While an older Session prefix remains unloaded, an interactive first row precedes the loaded records and requests one older page; the same row becomes a disabled loading status for a pending page and disappears only when paging completes. - The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. -- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write. +- Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. The virtualizer's stable-key `followOnAppend` behavior owns structural appends while the user remains at the bottom; the component writes the tail position itself only for a non-virtual ledger. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write. - Token streaming updates only the matching Trajectory Assistant Context, while publication is coalesced to at most once per animation frame. The target snapshot preserves the existing stage, layout, Request numbering, Overview, and search inputs; completed Assistant State retains assembled blocks, timing, and usage rather than every raw chunk payload, while Session keeps the raw Event window. - Each Trajectory Definition extracts a stable ID from the current Event, and the shared Assembler replays only Contexts affected by matching, Location, or Reader changes. Older Session pages prepend into the same engine window; the Trajectory target builder converts its materialized Nodes into the existing stage-oriented snapshot consumed by the ledger. - Trajectory opts into a conversation-owned composer overlay through `data-conversation-composer-overlay`. `ConversationRoot` positions the composer seat and publishes its live height; Trajectory keeps the ledger at full height and reserves that height plus 16 px inside its vertical table and inspector scrollers. Those panes adapt to the available width instead of exposing horizontal scrollbars beneath the overlay. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index 6a38147a07..82af4904d9 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -23,7 +23,7 @@ Status: implemented - 折叠轮次时保留其第一条记录,并用紧凑的步骤数和工具调用数替换后续所有行;折叠助手时对其工具调用后代应用相同操作。全局控件会折叠或展开这两个层级。 - 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前,明确的加载行会遮住真实记录。更早的 Session 前缀仍未加载时,交互式首行位于已加载记录之前,可请求一页更早历史;页面加载期间,同一行会变为禁用的加载状态,仅在分页完成时消失。 - 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 -- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。 +- 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。用户保持在底部时,虚拟化器基于稳定键的 `followOnAppend` 行为负责结构性追加;组件只对非虚拟记录表自行写入末尾位置。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。 - token 流式输出只更新命中的 Trajectory Assistant Context,发布则合并为每个 animation frame 最多一次。target snapshot 继续提供既有 stage、layout、请求编号、Overview 与搜索输入;已完成的 Assistant State 只保留组装后的 blocks、计时与 usage,不保留每条原始 chunk payload,而 Session 继续保存原始 Event 窗口。 - 每个 Trajectory Definition 都从当前 Event 提取稳定 ID,共享 Assembler 只 replay 因 Match、Location 或 Reader 变化而受影响的 Context。更早 Session 页面 prepend 到同一个引擎窗口;Trajectory target builder 再把已物化 Node 转换为记录表继续消费的 stage-oriented snapshot。 - Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml index 2d0ef48750..d16f8155e1 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.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-session-search.md -2026-07-27-web-session-search.md: bb41028ec4732a602ab1570b22b5c51160692ab2 -2026-07-27-web-session-search.zh.md: 9f03c42cfba018aa379ac2de6f78c6cdbfdb2ee0 +2026-07-27-web-session-search.md: d7a979a278113f9dcc170ec12f9d0fb37e1d55b7 +2026-07-27-web-session-search.zh.md: ba125ba884a1c93f3d2ac23b7f750c6ca216ed5f diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md index bb41028ec4..d7a979a278 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -14,7 +14,7 @@ The shared Web/headless composition mounts [`@deepseek-ai/dsh-session-query-sqli The host gateway exposes `session.search` through the existing typed RPC stack. It derives the authorization set from the same visible summaries as `session.list`, asks `ctx.sessionQuery.searchSessions` for globally ranked current-surface `user/message` and `assistant/message` matches, and consumes provider pages until it has 20 authorized sessions plus one lookahead or exhausts the stream. The first provider page requests 20 hits; a first-page `SESSION_QUERY_INVALID_LIMIT` halves that size through 10, 5, 2, and 1, retaining the learned size across continuations and stale-generation restarts. Every hit's session id, best-match session id, surface, and event type are revalidated before its snippet leaves the Host. Emitted snippets contain at most 240 Unicode code points; the Host and wire schema share the protocol bounds and code-point-safe truncation helper, while the wire schema independently enforces the snippet bound at client parse. Keeping the potentially large authorization set out of SQLite bindings avoids the portable variable ceiling while preserving global ranking. The response remains one bounded page; `hasMore` tells the UI to ask for a narrower query rather than exposing pagination. A stale continuation discards the current attempt's partial results, deduplication entries, and cursors, then restarts from the first page against the original visibility snapshot. Limit probes and stale retries share the limit of 100 provider calls (and therefore at most 2,000 inspected hits); a page larger than its requested limit, a repeated continuation cursor, or a still-unexhausted stream at that call budget fails closed as an `internal` business error. The carrier signal cancels superseded work, including persistence listing, bounded batches of cold-session metadata stats, and each provider call, and wins over a concurrent limit or stale rejection. A missing query service or an unrecovered indexing/query failure remains a business error and does not mutate the canonical session store. -[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. Its default copy is English, and its input plus defensive request path remove NUL and cap queries at the request schema's 500 UTF-16 code units without splitting a surrogate pair. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event. +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. Its default copy is English, and its input plus defensive request path remove NUL and cap queries at the request schema's 500 UTF-16 code units without splitting a surrogate pair. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session, exits search, and reveals the selected row in the normal browser; it does not navigate to an exact event. The result bound is one protocol constant, not per-connection state. `SESSION_SEARCH_RESULT_LIMIT` lives with the request and result types in `@deepseek-ai/dsh-api-session-controller/types`; Session Controller enforces it, and `ClientSessions.searchResultLimit` re-exposes it for presentation plugins. Reaching it from a feature is an explicit widening of the sessions domain: `ISessions` — the face injected as `ctx.sessions`, and therefore what the test runtime's sessions double must implement — declares the search verb next to that bound. The Connection handle does not carry it: a per-connection field would imply a transport-varying or server-negotiated bound and leave the same fact with two owners. @@ -41,4 +41,4 @@ The first content query can take longer because it imports and opens SQLite befo ## Testing -Host tests pin request and response validation, visible-session filtering, event/surface filters, result and snippet bounds, adaptive provider limits inside the shared call budget, learned-limit stale restarts, cursor and cross-page deduplication behavior, cancellation precedence, and failure mapping. SQLite lifecycle tests pin eager activation, first-search opening and failure, shared readiness, and unopened disposal; semantic extraction and SQLite/fixture search tests pin exclusion of reasoning-only text. The Node 22 compatibility gate builds the CLI and Web artifacts, boots the shipped `dsh web`/`AppCLIEntry` composition under plain Node with ambient warning suppression removed and an isolated temporary home/provider environment, waits for settled startup, and disposes it through the shipped signal path. Fixture, runtime, and UI tests pin match-centered bounded snippets, stateless delegation, the 500-code-unit query boundary, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, English copy, ARIA tree membership, row rendering, and navigation semantics. A keyless assembled Web test preserves the lazy-open config while seeding an unopened persisted conversation, finds it by visible message content through the SQLite index, captures the sidebar result, opens it, and verifies that the query remains. +Host tests pin request and response validation, visible-session filtering, event/surface filters, result and snippet bounds, adaptive provider limits inside the shared call budget, learned-limit stale restarts, cursor and cross-page deduplication behavior, cancellation precedence, and failure mapping. SQLite lifecycle tests pin eager activation, first-search opening and failure, shared readiness, and unopened disposal; semantic extraction and SQLite/fixture search tests pin exclusion of reasoning-only text. The Node 22 compatibility gate builds the CLI and Web artifacts, boots the shipped `dsh web`/`AppCLIEntry` composition under plain Node with ambient warning suppression removed and an isolated temporary home/provider environment, waits for settled startup, and disposes it through the shipped signal path. Fixture, runtime, and UI tests pin match-centered bounded snippets, stateless delegation, the 500-code-unit query boundary, debounce/abort/stale-response behavior, local fallback, merge order, deduplication, English copy, ARIA tree membership, row rendering, and navigation semantics. A keyless assembled Web test preserves the lazy-open config while seeding an unopened persisted conversation, finds it by visible message content through the SQLite index, captures the sidebar result, opens it, and verifies that search exits with the selected Session visible in the normal browser. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md index 9f03c42cfb..ba125ba884 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -14,7 +14,7 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据 宿主网关通过现有的类型化 RPC 栈公开 `session.search`。它根据 `session.list` 使用的同一组可见摘要推导授权集合,向 `ctx.sessionQuery.searchSessions` 请求全局排序后的当前 surface `user/message` 和 `assistant/message` 匹配项,并持续消费提供方分页,直到获得 20 个已授权会话及一个前瞻项,或结果流耗尽。首个提供方页面请求 20 个命中;如果第一页返回 `SESSION_QUERY_INVALID_LIMIT`,页面大小会依次折半为 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的大小。每个命中的会话 id、最佳匹配会话 id、surface 和事件类型都会经过重新校验,其 snippet 才能离开宿主。发出的 snippet 最多包含 240 个 Unicode 码点;宿主与传输 schema 共用协议边界及码点安全的截断辅助函数,而传输 schema 会在客户端解析时独立强制执行 snippet 上限。将可能很大的授权集合排除在 SQLite 绑定之外,可避开可移植变量上限,同时保持全局排序。响应仍只有一个有界页面;`hasMore` 会指示 UI 提示用户缩小查询范围,而不是公开分页能力。陈旧的续传会丢弃当前尝试的部分结果、去重条目和游标,然后依据原始可见性快照从第一页重新开始。上限探测与陈旧重试共用 100 次提供方调用的限制(因此最多检查 2,000 个命中);如果某页命中数超过其请求的上限、续传游标重复,或用尽该调用预算后结果流仍未耗尽,都会直接返回 `internal` 业务错误,不返回部分结果。载体信号会取消已被取代的工作,包括持久化列表枚举、分批受限执行的冷会话元数据 stat,以及每一次提供方调用;即使同时收到上限拒绝或陈旧拒绝,也以取消为准。查询服务缺失或索引/查询故障无法恢复时,仍作为业务错误处理,不会修改规范会话存储。 -[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 +[`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行会打开对应 Session、退出搜索,并在常规浏览器中显露选中行;不会跳转至确切事件。 结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 与请求和结果类型一起位于 `@deepseek-ai/dsh-api-session-controller/types`;Session Controller 强制执行它,`ClientSessions.searchResultLimit` 则把它重新公开给呈现插件。功能包要取用它,必须显式扩展 sessions 域的对外面:`ISessions`(即注入为 `ctx.sessions` 的那个面,也因此是测试运行时的 sessions 替身必须实现的面)在该上限旁声明搜索动作。Connection handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,并让同一事实拥有两处归属。 @@ -41,4 +41,4 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据 ## 测试 -宿主测试将请求与响应校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享调用预算内的自适应提供方上限、沿用探测所得上限的陈旧世代重启、游标与跨页去重行为、取消优先级及故障映射固定为约定。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为约定;语义提取测试与 SQLite/fixture 搜索测试将排除仅存在于推理中的文本固定为约定。Node 22 兼容性门禁会构建 CLI 与 Web 产物,在移除环境级警告抑制并采用隔离的临时 home/提供方环境后,以普通 Node 启动随产品交付的 `dsh web`/`AppCLIEntry` 组合,等待启动完成并稳定,再沿随产品交付的信号路径对其执行 dispose(资源释放)。fixture(测试前置数据)、运行时与 UI 测试将以匹配位置为中心的有界 snippet、无状态委托、500 个 code unit 的查询边界、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、英文文案、ARIA 树成员关系、行渲染与导航语义固定为约定。无密钥的组装层 Web 测试会在保留惰性打开配置的同时,播种一段尚未打开的持久化对话,通过 SQLite 索引按可见消息内容找到它,捕获侧边栏结果,打开该会话,并验证查询条件仍然保留。 +宿主测试将请求与响应校验、可见会话过滤、事件和 surface 过滤、结果与 snippet 边界、共享调用预算内的自适应提供方上限、沿用探测所得上限的陈旧世代重启、游标与跨页去重行为、取消优先级及故障映射固定为约定。SQLite 生命周期测试将启动时激活、首次搜索时的打开与失败、共享就绪状态以及未打开状态下的处置固定为约定;语义提取测试与 SQLite/fixture 搜索测试将排除仅存在于推理中的文本固定为约定。Node 22 兼容性门禁会构建 CLI 与 Web 产物,在移除环境级警告抑制并采用隔离的临时 home/提供方环境后,以普通 Node 启动随产品交付的 `dsh web`/`AppCLIEntry` 组合,等待启动完成并稳定,再沿随产品交付的信号路径对其执行 dispose(资源释放)。fixture(测试前置数据)、运行时与 UI 测试将以匹配位置为中心的有界 snippet、无状态委托、500 个 code unit 的查询边界、防抖/中止/陈旧响应行为、本地回退、合并顺序、去重、英文文案、ARIA 树成员关系、行渲染与导航语义固定为约定。无密钥的组装层 Web 测试会在保留惰性打开配置的同时,播种一段尚未打开的持久化对话,通过 SQLite 索引按可见消息内容找到它,捕获侧边栏结果,打开该会话,并验证搜索退出且选中的 Session 在常规浏览器中可见。 diff --git a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml index 4c44614835..675822cf97 100644 --- a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.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-03-fs-tool-error-remedy.md -2026-08-03-fs-tool-error-remedy.md: 66d47b750afdd43c4e2b830e73e21a3341f26c4b -2026-08-03-fs-tool-error-remedy.zh.md: efa3062f65fe458cd44fadef074583c1b15a3365 +2026-08-03-fs-tool-error-remedy.md: ae72e04b5b662cba79fe52538ff190a25f74eceb +2026-08-03-fs-tool-error-remedy.zh.md: 3c65ca06583c47c93081d74606f489c4bcdc81f2 diff --git a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md index 66d47b750a..ae72e04b5b 100644 --- a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md +++ b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md @@ -1,4 +1,4 @@ -# Agent Note: Guarded-mutation errors append the recovery instruction at the model boundary +# Agent Note: Stale-version errors append the recovery instruction at the model boundary Status: implemented @@ -10,14 +10,13 @@ Guarded `write` and `edit` failures reach the model with messages that state the ## Decision -`dsh-tool-fs` owns a model-facing error wrapper, `remediateFsError` in `src/error.ts`, applied in `write.ts` and `edit.ts` after the sandbox denial mapping. It appends the recovery instruction to the two guarded-mutation codes and passes everything else through untouched: +`dsh-tool-fs` owns a model-facing error wrapper, `remediateFsError` in `src/error.ts`, applied in `write.ts` and `edit.ts` after the sandbox denial mapping. It appends the recovery instruction to stale-version failures and passes unrelated errors through untouched. The [normalized unread-mutation diagnostic](../bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.md) supersedes this note's original `FS_NOT_OBSERVED` text treatment. - `FS_STALE_VERSION` (including a missing edit target, which shares the stale code) gains `— re-read the file, then retry`. -- `FS_NOT_OBSERVED` gains `— read the file, then retry`. The structured `FsError` code is preserved so retry/permission/UI layers keep routing on it, and the original error chains as `cause`. Provider messages stay machine-oriented and unchanged. -In `edit.ts` the `fs/edit-intent` waterfall now sits inside the same `try` as the provider mutation, so the policy plugin's `FS_NOT_OBSERVED` refusal thrown from the intent slot also receives the remedy — both refusal paths reach the model with the same recovery wording. +In `edit.ts` the `fs/edit-intent` waterfall sits inside the same `try` as the provider mutation, so the policy plugin's `FS_NOT_OBSERVED` refusal and the provider refusal both pass through the model-facing wrapper. ## Alternatives considered @@ -27,6 +26,6 @@ In `edit.ts` the `fs/edit-intent` waterfall now sits inside the same `try` as th ## Consequences -Model-visible text for the two codes changes; the `fs-policy-reject` keyless snapshot is re-recorded, and the READMEs of `dsh-tool-fs` and `dsh-fs-observation-policy` pin the exact appended text. Unit tests cover the wrapper directly (remedy text, code preservation, cause chaining, passthrough of other codes and non-`FsError` values) and the assembled tool paths assert the remedy reaches the model for both codes. +The `FS_STALE_VERSION` model-visible text includes its appended remedy. Unit tests cover its text, code preservation, cause chaining, and passthrough of unrelated values; assembled tool paths assert that the remedy reaches the model. The [filesystem absence-observation follow-up](../bug-fix/2026-08-09-filesystem-absence-observation.md) makes the stale remedy actionable for external deletion. The failed reread still returns `FS_NOT_FOUND`, but records confirmed absence: edit then returns `FS_NOT_FOUND` without another stale remedy, while write retries as an atomic `createIfAbsent` and preserves any concurrent creator. diff --git a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md index efa3062f65..3c65ca0658 100644 --- a/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md +++ b/.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 受防护变更错误在模型边界追加恢复指令 +# Agent Note: 陈旧版本错误在模型边界追加恢复指令 Status: implemented @@ -10,14 +10,13 @@ Status: implemented ## 决策 -`dsh-tool-fs` 拥有一个面向模型的错误包装层 `remediateFsError`(位于 `src/error.ts`),在 `write.ts` 与 `edit.ts` 中于沙箱拒绝映射之后应用。它为两个受防护变更错误码追加恢复指令,其余错误原样透传: +`dsh-tool-fs` 拥有一个面向模型的错误包装层 `remediateFsError`(位于 `src/error.ts`),在 `write.ts` 与 `edit.ts` 中于沙箱拒绝映射之后应用。它为陈旧版本错误追加恢复指令,其余无关错误原样透传。[未读取变更的统一诊断](../bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md)取代本记录最初对 `FS_NOT_OBSERVED` 文本的处理方式。 - `FS_STALE_VERSION`(包括缺失的编辑目标——它与陈旧错误共用同一错误码)追加 `— re-read the file, then retry`。 -- `FS_NOT_OBSERVED` 追加 `— read the file, then retry`。 结构化 `FsError` 错误码保持不变,使重试/权限/UI 层继续基于它路由;原始错误作为 `cause` 链入。提供方消息保持面向机器且不变。 -在 `edit.ts` 中,`fs/edit-intent` waterfall(瀑布式事件)现在与提供方变更位于同一个 `try` 内,因此策略插件从 intent slot 抛出的 `FS_NOT_OBSERVED` 拒绝也会获得恢复指令——两条拒绝路径都以相同的恢复措辞到达模型。 +在 `edit.ts` 中,`fs/edit-intent` waterfall(瀑布式事件)与提供方变更位于同一个 `try` 内,因此策略插件的 `FS_NOT_OBSERVED` 拒绝和提供方拒绝都会经过面向模型的包装层。 ## 考虑过的替代方案 @@ -27,6 +26,6 @@ Status: implemented ## 后果 -两个错误码的模型可见文本发生变化;`fs-policy-reject` 无密钥快照被重新录制,`dsh-tool-fs` 与 `dsh-fs-observation-policy` 的 README 逐字固定追加后的文本。单元测试直接覆盖包装层(恢复指令文本、错误码保留、cause 链、其他错误码与非 `FsError` 值的透传),组装后的工具路径断言两个错误码的恢复指令都到达模型。 +`FS_STALE_VERSION` 的模型可见文本包含追加的恢复指令。单元测试覆盖该文本、错误码保留、cause 链和无关值透传;组装后的工具路径断言恢复指令到达模型。 [文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.zh.md)使外部删除场景下的陈旧恢复指令能够生效。失败的重新读取仍返回 `FS_NOT_FOUND`,但会记录确认缺失:随后 edit 返回 `FS_NOT_FOUND`,不再附加陈旧恢复指令;write 则以原子 `createIfAbsent` 重试,并保留任何并发创建者写入的文件。 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml index 1e3ada6894..51848d9768 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.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-04-web-slash-command-fuzzy-discovery.md -2026-08-04-web-slash-command-fuzzy-discovery.md: 8d7fe88f8d19a6edc7b51e63578c468df085c238 -2026-08-04-web-slash-command-fuzzy-discovery.zh.md: a96f9c984e32dd777950b9f9d8594b3a9c8b7c17 +2026-08-04-web-slash-command-fuzzy-discovery.md: 17ba1a1cba4876140bada74e65ba1b10ed5fdd76 +2026-08-04-web-slash-command-fuzzy-discovery.zh.md: fe23dcb5d3c14a492a5882b5bad4c84e256ef064 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md index 8d7fe88f8d..17ba1a1cba 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md @@ -6,13 +6,13 @@ English | [中文](2026-08-04-web-slash-command-fuzzy-discovery.zh.md) ## Problem -The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command. +The web command menu required a command-name prefix, so discovery failed when a user remembered the significant letters but not their exact positions. The skill source of the same `/` menu later kept a case-sensitive prefix filter, so the two groups of one menu answered the same keystrokes differently. Broadening menu matching could make discovery easier, but command execution must remain exact and deterministic: an approximate line must never execute a nearby command. ## Decision -The `/` command source fuzzy-matches the typed query against command names as a case-insensitive ordered subsequence. Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host-directory and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking. +The `/` menu's command and skill sources fuzzy-match the typed query against candidate names as a case-insensitive ordered subsequence through one ranker, `rankByName` in ui-primitives, the narrow static owner for shared browser code ([client rules](../../../../packages/client/AGENTS.md)). Exact prefixes form the highest ranking class. Within each class, the strongest alignment score rewards separator boundaries and adjacent characters while penalizing leading characters and gaps; equal scores retain the host catalog and client-contribution order. Position filtering still removes argument-taking commands from inline menus before ranking. -The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, while space and Enter adjudication continue to require an exact command token. +The scorer uses dynamic programming in `O(query length × name length)` time and `O(name length)` memory per candidate. Candidate scoring stays client-side and examines names only; descriptions do not affect matching. Menu selection still dispatches the selected exact name, the skill source still lands the literal `/name ` text the host resolves exactly, and space and Enter adjudication continue to require an exact command token. ## Alternatives considered @@ -22,6 +22,8 @@ The scorer uses dynamic programming in `O(query length × name length)` time and **Use a general fuzzy-search dependency.** Rejected because this surface needs one constrained subsequence rule over a small command catalog; a configurable search index would add bundle weight and ranking behavior not used by the product. +**Export the ranker from the command plugin or the trigger pipeline.** Rejected because a feature plugin exports no values beyond what cordis loading needs and never runtime-imports another feature plugin; peer agent products that share one matcher between commands and skills (Claude Code, Pi, Kimi Code) keep it in a shared library for the same reason. + ## Consequences -Users can discover a command from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. Package tests pin each ranking factor and stable ties, while the assembled Web replay snapshot pins `/cpt` resolving to `/compact`. Exact execution semantics are unchanged. +Users can discover a command or a skill from remembered in-order letters, and ranking remains stable across identical catalogs. The score is deliberately heuristic: a separator-aligned match can outrank a match with a shorter raw span. ui-primitives tests pin each ranking factor and stable ties, the command and skill sources pin that they rank through the shared ranker, and the assembled Web goldens pin `/cpt` resolving to `/compact` and a subsequence query resolving to one skill. Exact execution semantics are unchanged. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md index a96f9c984e..fe23dcb5d3 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。 +Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字母却不记得其准确位置时,就无法发现命令。同一 `/` 菜单的 skill source 后来仍保留区分大小写的前缀过滤,同一菜单的两个分组对同样的按键给出不同答案。扩大菜单的匹配范围可使命令更易发现,但命令执行仍必须保持精确匹配和确定性:近似输入行绝不能执行相近命令。 ## 决策 -`/` 命令 source 将键入的查询作为不区分大小写的有序子序列,与命令名进行模糊匹配。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。 +`/` 菜单的命令 source 与 skill source 将键入的查询作为不区分大小写的有序子序列,与候选名进行模糊匹配,二者共用一个排序器:ui-primitives 中的 `rankByName`,即共享浏览器代码的窄静态归属方([client 规则](../../../../packages/client/AGENTS.md))。精确前缀构成排名最高的一类匹配。在每类匹配中,对齐分数越高越优先:分隔符边界和相邻字符会提高分数,前导字符和间隔会降低分数;分数相同则保持 host 目录和 client contribution 的顺序。位置过滤仍会在排名前从行内菜单中移除接收参数的命令。 -评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,而空格键和 Enter 键的判定逻辑仍要求命令 token 精确匹配。 +评分器对每个候选项使用动态规划,时间复杂度为 `O(query length × name length)`,空间复杂度为 `O(name length)`。候选项评分只在客户端进行且只检查命令名;命令描述不影响匹配。菜单选择仍派发所选的精确名称,skill source 仍落下由宿主精确解析的字面 `/name ` 文本,而空格键和 Enter 键的判定逻辑仍要求命令 token 精确匹配。 ## 考虑过的替代方案 @@ -22,6 +22,8 @@ Web 命令菜单要求按命令名前缀匹配,因此用户只记得关键字 **使用通用模糊搜索依赖。** 否决,因为该界面只需对小型命令目录使用一种受限的子序列规则;可配置搜索索引会增加 bundle 体积,并引入产品未使用的排名行为。 +**从命令插件或触发管线导出排序器。** 否决,因为特性插件除 cordis 加载所需之外不导出任何值,也绝不运行时导入另一个特性插件;在命令与 skill 之间共用一个匹配器的同行产品(Claude Code、Pi、Kimi Code)出于同样的原因把它放在共享库中。 + ## 后果 -用户可以凭按顺序记得的字母发现命令;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。包测试固定各项排名因素以及同分时的稳定顺序,组装后的 Web 回放快照固定 `/cpt` 解析为 `/compact` 的行为。精确执行语义保持不变。 +用户可以凭按顺序记得的字母发现命令或 skill;只要目录相同,排名就保持稳定。评分刻意采用启发式规则:与分隔符对齐的匹配可能排在原始跨度更短的匹配之前。ui-primitives 的测试固定各项排名因素以及同分时的稳定顺序,命令 source 与 skill source 的测试固定二者经共享排序器排名,组装后的 Web golden 固定 `/cpt` 解析为 `/compact`、以及一个子序列查询解析为唯一 skill 的行为。精确执行语义保持不变。 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 dcb1144635..a55f1f953f 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: 4582631da79c946a2d3a713bc0d3a085ebed4cd0 -2026-08-11-workspace-sidebar-order-and-folding.zh.md: 6dfd443599b6c6696bbe12c19ebcde411d86a517 +2026-08-11-workspace-sidebar-order-and-folding.md: a8026936905d42ccf5f30a02684310324d8ee359 +2026-08-11-workspace-sidebar-order-and-folding.zh.md: 83a66c9b4a5f8dd34b56c6d8a8af2d18f77e983b 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 4582631da7..a802693690 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 @@ -20,7 +20,7 @@ The client installs a Workspace drag optimistically. Request and frame generatio ### Session folding and view order -Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five non-blank rows plus the selected blank New Session as one provisional extra row. **Show more** reveals only the hidden remainder for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to the bounded folded projection. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts. +Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five non-blank rows plus the selected blank New Session as one provisional extra row. **Show more** reveals only the hidden remainder for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to the bounded folded projection. Selecting a Session search result waits for the current Workspace stream's complete Host baseline before opening its owning group, and reveals the hidden remainder for the current mount only when the target is behind the five-row fold. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. Creating a Session from a Workspace row opens the target group before starting the Session, keeping the new row visible when state propagation completes. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped and flat-list accounts. The combined view menu offers **Manual** and **Last updated** in grouped and flat presentation, with one browser-local persisted order per account. A real Workspace initializes from `WorkspaceView.sessionIds`; Ungrouped and the cross-Workspace flat list initialize from recency and have no Host Session account. Entering Last updated performs one complete recency sort; a later user prompt or steer promotes that Session once, and dragging may edit the resulting order. Returning to Manual preserves the current order and only disables later activity promotion. Manual-mode drags for a real Workspace also write the Host Session account, while Ungrouped and flat-list drags and activity promotion remain browser-local. Flat rows omit an empty leading status slot because they have no parent hierarchy, while a visible status retains its slot. @@ -51,9 +51,9 @@ Search is a header action while collapsed and expands across the title and trail - Workspace order is durable and shared through the Host, while grouping, open state, per-account Session view order, and query state remain browser-local presentation preferences. Ungrouped and the flat list support the same drag and promotion rules, but their orders are browser-local because neither has one Workspace account. - Last updated performs a complete recency sort on entry, then preserves manual adjustments until a user prompt or steer advances one Session and moves it to the front. Returning to Manual preserves every current position. - A newly selected blank New Session row enters grouped and flat orders first once. Grouped folding renders it in addition to five non-blank rows until its first prompt, then applies the ordinary quota. -- Opening a Workspace never shows more than five non-blank Sessions without an explicit **Show more** gesture; the selected blank New Session may add one provisional row. Closing the Workspace resets only the transient gesture. +- Opening a Workspace never shows more than five non-blank Sessions without an explicit **Show more** gesture or search-result navigation; the selected blank New Session may add one provisional row. Closing the Workspace resets transient remainder expansion. - The Host Session account retains the manual-order meaning established by [Session List Browsing and Manual Workspace Order](2026-07-25-session-list-browsing-and-manual-order.md). ## Testing -Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, the blank-row quota and hidden count, collapsed drag anchors across hidden rows, transient expansion reset, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update and New Session promotion, Manual drag retention after the first prompt, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions. A shipped-composition Web snapshot pins five established rows beside the provisional New Session. +Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, the blank-row quota and hidden count, collapsed drag anchors across hidden rows, transient expansion reset, search-result reveal beyond the fold, initial and reconnecting Workspace-baseline waits, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update and New Session promotion, Manual drag retention after the first prompt, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions. A shipped-composition Web snapshot pins five established rows beside the provisional New Session. 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 6dfd443599..83a66c9b4a 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 @@ -20,7 +20,7 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins ### Session 折叠与视图顺序 -每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条非空白行,再把当前选中的空白“新会话”作为一条临时额外行。**展开其余**只在当前挂载期间显示仍被隐藏的条目;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为有界折叠投影。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。 +每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条非空白行,再把当前选中的空白“新会话”作为一条临时额外行。**展开其余**只在当前挂载期间显示仍被隐藏的条目;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为有界折叠投影。选择 Session 搜索结果会等待当前 Workspace stream 取得完整 Host 基线后再打开所属分组,并且仅当目标位于五行折叠之后时,才会在当前挂载期间显露其余隐藏条目。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。从 Workspace 行创建 Session 时会在启动 Session 前打开目标分组,使状态传播完成后新行保持可见。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 和单列表记账。 组合视图菜单在分组和单列表呈现中都提供**手动排序**和**最近更新**,每个记账各自持有一份浏览器本地持久顺序。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化;Ungrouped 和跨 Workspace 的单列表从最近更新时间顺序初始化,且没有 Host Session 记账。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。真实 Workspace 在手动模式下的拖拽还会写入 Host Session 记账,而 Ungrouped 和单列表的拖拽与活动置顶保留在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;存在可见状态时仍保留该槽。 @@ -51,9 +51,9 @@ Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行 - Workspace 顺序通过 Host 持久并共享;分组方式、打开状态、每个记账的 Session 视图顺序和查询状态仍是浏览器本地呈现偏好。Ungrouped 和单列表支持相同的拖拽与置顶规则,但因没有单一 Workspace 记账,其顺序只保存在浏览器本地。 - 最近更新模式会在进入时执行完整时间排序,随后保持手动调整,直到 user prompt 或 steer 推进某条 Session 并将其置顶。返回手动排序会保留所有当前位置。 - 新选中的空白“新会话”行会在分组和单列表顺序中各置顶一次。分组折叠会在五条非空白行之外额外渲染该行,直至首条提示词落地后恢复普通配额。 -- 未执行明确的**展开其余**手势时,打开 Workspace 最多显示五条非空白 Session;当前选中的空白“新会话”可以增加一条临时行。关闭分组只重置这项临时手势。 +- 未执行明确的**展开其余**手势或搜索结果导航时,打开 Workspace 最多显示五条非空白 Session;当前选中的空白“新会话”可以增加一条临时行。关闭分组会重置临时的其余条目展开状态。 - Host Session 记账继续采用[会话列表浏览与 Workspace 手动排序](2026-07-25-session-list-browsing-and-manual-order.zh.md)确立的手动顺序含义。 ## 测试 -领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、空白行配额与隐藏数量、跨隐藏行的折叠拖拽锚点、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新与“新会话”置顶、首条提示词落地后保留手动拖拽、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。真实组合 Web 快照固定五条既有行与临时“新会话”并列显示。 +领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、空白行配额与隐藏数量、跨隐藏行的折叠拖拽锚点、临时展开重置、折叠范围外的搜索结果显露、初始与重连 Workspace 基线等待、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新与“新会话”置顶、首条提示词落地后保留手动拖拽、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。真实组合 Web 快照固定五条既有行与临时“新会话”并列显示。 diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md deleted file mode 100644 index 328a3fffa1..0000000000 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.md +++ /dev/null @@ -1,46 +0,0 @@ -# Agent Note: Command image-attachment envelope - -Status: implemented - -English | [中文](2026-08-17-command-image-attachment-envelope.zh.md) - -## Problem - -The Web composer submits one envelope — draft text, attached images, and delivery mode — but the two submission planes consumed it asymmetrically. A plain message rode `defaultSink → conversation.sendSession`, which serialized the images into prompt content and cleared them on success. A claimed slash command rode `claim.submit(args, actx)`, a text-only transaction: `/goal rebuild the cathedral` with four reference photos executed the command, cleared the draft, and silently stranded the images in the composer rail. The model never saw them, and no surface said so. The defect was contract-level, not a missed call site: nothing in the claim, the adjudication, or the host executor modeled attachments, so any command could consume the text half of a submission and drop the rest. - -Merging the two planes was not on the table — the [plugin command registration Agent Note](2026-07-19-plugin-command-registration.md) deliberately keeps human commands out of the model plane, and that separation is correct. The gap was that the envelope fractured at the plane fork. - -## Decision - -The submission envelope is modeled end to end, and every command route either consumes it whole or refuses it loudly. - -**Declaration.** `CommandDefinition.input.images: boolean` (absent = false) declares whether composer images may accompany an invocation. The flag rides the frozen `CommandDescriptor` through `commands/list` to every client, onto the minted `CommandClaim` (`images: true`), and into the input machine's published claim snapshot. - -**Generic identity, image-specific payload.** Browser drafts and durable references already use `DraftAttachmentId` and `AttachmentId`; the command RPC carries encoded bytes rather than an image identifier. The wire remains `EncodedImageAttachment[]`, and the declaration remains `input.images`, while images are the only non-text attachment with defined admission and model-block semantics. - -**Executor enforcement.** `CommandRuntime.execute(agent, line, images, signal)` carries the submission's base64 images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`). The executor — not the composer — enforces the declaration: images to a non-declaring command, an absent attachment store, and an exceeded batch limit each settle as a logged `command/done` error before the handler runs. Admission goes through the attachment package's `admitEncodedImages` — the shared wire entry that enforces canonical base64 and delegates batch admission (limits, validation, ordered commit) to `AttachmentStore.saveImages` — so both wire endpoints (prompt RPC and command executor) share one sequence and a rejected batch publishes no durable object. An admitted batch reaches the handler as frozen ordered `ImageBlock`s on `invocation.attachments`. - -**Producer-owned model visibility.** The registry never schedules the images itself. `/goal` submits one `agent.followup` user message — image blocks plus the fixed text `Reference images for the goal objective.` — after a successful create or edit, so later goal rounds read the images from ordinary session history and the goal domain stores no attachment state. `/plan ` folds the images into its steered text message, while bare `/plan` steers an image-only user message because the images may contain the whole task. Producer control forms with no model input (`/goal pause`, `/plan off`) return a direct error and keep the composer's images in place. The plan projection treats `command/run` as a candidate and drops it on a paired `command/done` error, so a rejected image-carrying `/plan off` cannot leave a pending exit. - -**Composer refusal is a visible banner, everything retained.** ui-commands' `matchEnter` receives a `SubmitEnvelope` (image count) from adjudication and throws a localized `notice.imagesUnsupported` refusal for every enter route that cannot consume images: contribution popups, decorated popups, non-declaring claims, and bare detached executes. The input machine publishes one error notice, which the composer renders through its transient Toast banner with draft and images untouched. A pre-claimed submit (space/menu claim) is gated in the facade with the same copy from the `conversation` namespace. On the accepting path the facade serializes the draft images through the hub's `commandImages` plumbing, passes them to `claim.submit`, and clears plus releases them only on a success outcome; an error result (including a producer grammar rejection) keeps them. - -## Testing - -Registry executor enforcement, admission failure settlement, and frozen invocation attachments are covered in `packages/interaction/commands/tests/commands.spec.ts`; batch admission ordering and limits in `packages/attachment/attachment/tests/admission.spec.ts`; producer behavior in `packages/goal/command-goal/tests/command-goal.spec.ts` and `packages/plan/plan-mode/tests/plan-mode.spec.ts`; client refusal and consumption paths in the ui-commands, ui-conversation, and ui-input-trigger client suites; and the assembled-application flow in the apps/web keyless lanes. - -## Alternatives considered - -- **Block commands whenever images are attached (no acceptance path)** — rejected: predictable, but `/goal` with reference images is the motivating use case; the user's images would have no route to the model at all. -- **Auto-send stranded images as a follow-up user message after any command** — rejected: surprising for host-state commands (`/model`, `/compact`), and it moves the message contract from the producer to the composer, against the command registry's "producer owns model-visible work" rule. -- **Store attachment references in the goal domain and render them into round prompts** — rejected: requires durable goal schema changes and either duplicates image blocks into every round prompt or adds round-one-only prompt shape; the round-prompt invariant would need attachment state. One ordinary logged user message achieves the same model visibility. -- **Consume images on any command success regardless of grammar** — rejected: `/goal pause` with images attached would silently discard them, recreating the original defect one layer deeper. Consumption is tied to the producer's explicit success, and grammar misfits return errors. -- **Keep enforcement client-side only** — rejected: schema omission is not enforcement; direct RPC callers could bypass the composer. The executor settles the declaration itself. -- **Generalize the command wire to a multimedia identifier** — rejected: the two identifiers are already attachment-generic, while the wire transports bytes and its image-specific fields state the admission rules the Host enforces. Files and videos lack shared admission and model-visible semantics, and an untagged multimedia identifier would not supply them. A second supported attachment kind is the reintroduction condition; the command envelope then widens to a tagged attachment union and commands declare the accepted kinds while retaining `AttachmentId`. - -## Consequences - -- No command route can consume a submission's text and strand its images: the contract forces whole-envelope consumption or a visible refusal, for current and future commands alike. -- The commands package now depends on `dsh-attachment` and `dsh-llm`, and `commands/execute` carries a required `images` wire parameter — every caller states its envelope explicitly. -- `/goal` and `/plan` gain reference-image input at the cost of one extra logged user message (goal) and image blocks in the steered message (plan), including an image-only message for bare `/plan`; all are billed like any image prompt. -- Menu-pick popup flows do not consult the envelope: picking a popup command from the menu while images are attached leaves the images visibly in the rail rather than refusing the interaction. Enter-submission is the enforced envelope boundary. -- "A rejected batch publishes no durable object" covers exactly the pre-admission settlements (declaration, missing store, batch limit). A handler-level grammar rejection (`/goal pause` with images) and a post-admission cancellation settle AFTER the batch committed, leaving content-addressed objects without a referencing session event — harmless under sha256 dedup and the attachment store's deferred reference-aware GC, but not "no object was written". diff --git a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md b/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md deleted file mode 100644 index 9de197d066..0000000000 --- a/.agents/notes/implemented/feature/2026-08-17-command-image-attachment-envelope.zh.md +++ /dev/null @@ -1,46 +0,0 @@ -# Agent Note: Command image-attachment envelope - -Status: implemented - -[English](2026-08-17-command-image-attachment-envelope.md) | 中文 - -## Problem - -Web composer 的一次提交是一个信封——草稿文本、已附加图片、投递模式——但两条提交平面对它的消费是不对称的。普通消息走 `defaultSink → conversation.sendSession`,图片被序列化进 prompt 内容并在成功后清除。被 claim 的斜杠命令走 `claim.submit(args, actx)`,一个纯文本事务:`/goal rebuild the cathedral` 带四张参考照片时,命令执行、草稿清空,图片却静默滞留在 composer 附件栏。模型从未看到它们,也没有任何界面提示。这个缺陷在契约层面而非某个漏掉的调用点:claim、裁决、宿主执行器都没有建模附件,因此任何命令都可能消费提交的文本一半而丢弃其余部分。 - -合并两个平面从未在考虑范围内——[插件命令注册 Agent Note](2026-07-19-plugin-command-registration.zh.md)刻意让人类命令留在模型平面之外,这个分离是正确的。问题在于信封在平面分叉处被拆散了。 - -## Decision - -提交信封被端到端建模,每条命令路径要么整体消费它,要么响亮拒绝。 - -**声明。**`CommandDefinition.input.images: boolean`(缺省为 false)声明 composer 图片是否可以随调用提交。该标志随冻结的 `CommandDescriptor` 经 `commands/list` 到达每个客户端,进入铸造出的 `CommandClaim`(`images: true`),再进入输入状态机发布的 claim 快照。 - -**通用标识,图片专用载荷。**浏览器草稿与持久化引用已经使用 `DraftAttachmentId` 和 `AttachmentId`;命令 RPC 传输的是编码字节,而非图片标识。图片仍是唯一已经定义准入规则和模型块语义的非文本附件,因此 wire 保持 `EncodedImageAttachment[]`,声明保持 `input.images`。 - -**执行器强制。**`CommandRuntime.execute(agent, line, images, signal)` 携带本次提交的 base64 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`)。强制执行声明的是执行器而非 composer:把图片发给未声明的命令、附件存储缺失、批量超限,都会在处理器运行前以记录在案的 `command/done` 错误结算。准入经由 attachment 包的 `admitEncodedImages`——共享 wire 入口,强制执行规范 base64 并把批量准入(限额、校验、有序提交)委托给 `AttachmentStore.saveImages`——使两个 wire 端点(prompt RPC 与命令执行器)共享同一序列,被拒绝的批量不会发布任何持久化对象。通过准入的批量以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器。 - -**模型可见性由生产方负责。**注册表自身绝不调度这些图片。`/goal` 在 create 或 edit 成功后通过 `agent.followup` 提交一条用户消息——图片块加固定文本 `Reference images for the goal objective.`——后续 Goal Round 从普通会话历史读取图片,goal 领域不存储附件状态。`/plan ` 把图片并入其 steer 的文本消息;不带参数的 `/plan` 则 steer 一条只含图片的用户消息,因为图片可能包含全部任务内容。不会发送模型输入的控制形式(`/goal pause`、`/plan off`)会直接返回错误,composer 的图片原地保留。plan 投影会把 `command/run` 视为候选选择,并在配对的 `command/done` 报错时丢弃它,因此被拒绝的带图 `/plan off` 不会留下待退出状态。 - -**composer 的拒绝是可见横幅,一切保留。**ui-commands 的 `matchEnter` 从裁决收到 `SubmitEnvelope`(图片数量),对每条无法消费图片的回车路径抛出本地化的 `notice.imagesUnsupported` 拒绝:contribution 弹窗、decoration 弹窗、未声明的 claim、bare 分离执行。输入状态机发布一条错误通知,composer 通过瞬态 Toast 横幅呈现它,草稿与图片不动。已 claim 状态下的提交(空格或菜单 claim)由 facade 用 `conversation` 命名空间的同款文案把关。接受路径上,facade 经 hub 的 `commandImages` 管道序列化草稿图片、传给 `claim.submit`,仅在成功 outcome 后清除并释放;错误结果(包括生产方的语法拒绝)保留它们。 - -## Testing - -注册表执行器强制、准入失败结算、冻结的调用附件由 `packages/interaction/commands/tests/commands.spec.ts` 覆盖;批量准入顺序与限额在 `packages/attachment/attachment/tests/admission.spec.ts`;生产方行为在 `packages/goal/command-goal/tests/command-goal.spec.ts` 与 `packages/plan/plan-mode/tests/plan-mode.spec.ts`;客户端拒绝与消费路径在 ui-commands、ui-conversation、ui-input-trigger 客户端套件;组装后应用流程在 apps/web 的 keyless 通道。 - -## Alternatives considered - -- **附加图片时一律拦截命令(没有接受路径)**——被拒绝:可预测,但带参考图的 `/goal` 正是驱动这次修复的用例,用户的图片将完全没有通往模型的路径。 -- **任何命令后把滞留图片自动作为后续用户消息发送**——被拒绝:对宿主状态命令(`/model`、`/compact`)令人意外,且把消息契约从生产方挪到 composer,违反命令注册表「生产方负责模型可见工作」的规则。 -- **在 goal 领域存储附件引用并渲染进 Round 提示词**——被拒绝:需要持久化 goal schema 变更,且要么把图片块复制进每轮提示词,要么引入仅首轮的提示词形态;round 提示词不变量将需要附件状态。一条普通的已记录用户消息达到同样的模型可见性。 -- **只要命令成功就消费图片,不管语法**——被拒绝:`/goal pause` 带图会把图片静默丢弃,在更深一层重演原始缺陷。消费与生产方的显式成功绑定,语法不匹配返回错误。 -- **只在客户端强制**——被拒绝:schema 省略不是强制执行;直接 RPC 调用方可以绕过 composer。执行器自己结算声明。 -- **把命令 wire 泛化成多媒体标识**——被拒绝:两个标识已经是附件通用类型,wire 传输的是字节,其图片专用字段明确表达了 Host 强制执行的准入规则。文件和视频尚无共同的准入规则与模型可见语义,一个不带类型标记的多媒体标识也无法提供这些信息。出现第二种受支持附件时再引入泛化:命令信封扩展为带类型标记的附件联合类型,命令声明接受的类型,`AttachmentId` 保持不变。 - -## Consequences - -- 任何命令路径都不可能消费提交的文本而滞留图片:契约强制整信封消费或可见拒绝,对现有与未来命令一体适用。 -- commands 包新增对 `dsh-attachment` 与 `dsh-llm` 的依赖,`commands/execute` 携带必填的 `images` wire 参数——每个调用方都显式陈述其信封。 -- `/goal` 与 `/plan` 获得参考图输入,代价是一条额外的已记录用户消息(goal)与 steer 消息中的图片块(plan),其中不带参数的 `/plan` 会产生只含图片的消息;所有这些输入的计费都与常规图片提示词相同。 -- 菜单点选的弹窗流程不查询信封:附有图片时从菜单点选弹窗命令,图片会可见地留在附件栏,而不是拒绝该交互。回车提交是被强制执行的信封边界。 -- 「被拒绝的批量不发布任何持久化对象」只覆盖准入前的三种结算(声明、存储缺失、批量超限)。handler 级语法拒绝(如 `/goal pause` 带图)与准入后取消发生在批量已提交之后,会留下没有会话事件引用的内容寻址对象——在 sha256 去重与附件存储延后的引用感知 GC 下无害,但并非「未写入任何对象」。 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml new file mode 100644 index 0000000000..fa6f76a300 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.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-26-generic-file-upload.md +2026-08-26-generic-file-upload.md: d8643f5f1a3e0dcdfdc7fc8b79cbb156b07f4402 +2026-08-26-generic-file-upload.zh.md: 38de46a424e3f02b7c0293e15835d096b108f071 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md new file mode 100644 index 0000000000..d8643f5f1a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -0,0 +1,50 @@ +# Agent Note: Generic file upload + +Status: implemented + +English | [中文](2026-08-26-generic-file-upload.zh.md) + +## Problem + +Sessions accepted pasted or dropped images as durable attachments, but no other file kind could enter a conversation: the composer refused non-image bytes, and the only file affordance was the `@` path mention, which requires the file to already be readable from the host workspace. A user could not hand the agent a PDF, a data file, or an archive from a browser, and DeepSeek Chat users expect the paperclip-and-file-card upload flow (#2984). + +The command plane had already established a whole-envelope rule for images: a command consumes the text and attachments together or visibly refuses the submission. Its `input.images` declaration and image-only RPC payload made generic files a separate refusal path even after ordinary messages could carry them. Plan and goal entry therefore needed the same attachment vocabulary without weakening executor enforcement or silently clearing a draft. + +## Decision + +Files and images use separate storage schemes and one ordered message attachment list. Images keep their normalized-attachment pipeline unchanged. Other picked files enter one FIFO background-upload queue and are stored byte-for-byte with no type or size limits, then staged in-process for the same Session. Equal bytes have one canonical object under `DSH_HOME/attachments/v1/file-objects//`; each read-only model-facing path at `DSH_HOME/attachments/v1/files///` is a hard link, so different display names do not duplicate content. The configurable queue defaults to two active transfers. The independent dual-face `file-upload` Cordis package owns transfer and staging. Its Client `fileUpload` service accepts the owning Session identity together with a `Blob`, exact bytes, or a one-shot `ReadableStream`; it uses that identity to assemble the raw route request or invoke the generated Remote directly. Its Host `fileUploads` service owns the streaming route, encoded Remote fallback, command receipt resolver, and receipt tables keyed by the receiving Agent's Session object. Encoded admission and attachment-error recognition go through `ctx.attachments`, whose provider owns byte storage. Session Controller registers the resolver for cold ordinary Agents and consumes receipts during prompt admission; Session objects do not expose file transfer. Served Web pages hand each active body to a dedicated upload Worker: Blob bodies use XMLHttpRequest for total-aware byte progress, while streams transfer ownership and feed Fetch incrementally. Worker-hosted pages transfer the body through their page-owned Fetch carrier to the Host Worker, which reads bounded chunks. The page thread never reads, base64-encodes, or JSON-serializes generic file bytes. The authenticated Host route opts into Connection's streaming request mode, so the bridge dispatches before consuming the body and the local store hashes and writes bounded chunks to a private staging object. Completion atomically publishes the content-addressed object; cancellation or failure removes the staging file. Upload operations and draft state belong to the resident conversation root, so navigation to another Session does not stop the transfer; returning to the original Session shows the same card and progress. Removing the file card skips its queued transfer or aborts its active transfer. The sent message carries a structured `FileBlock` (`{ name, bytes, attachmentId }`) inside `user/message`; no new session event type exists. Request assembly in `LlmRuntime` projects every file block, including nested tool-result occurrences, to one deterministic handle naming the file and its saved read-only path. The handle tells a main agent to include that path when delegating file work and to report when the child's execution environment cannot see it. No provider receives file bytes; the model reads the stored copy with existing file tools on demand. + +Default mode, active Plan Mode, and an active goal share ordinary queue, follow-up, and steer attachment admission. `/goal` create and edit and `/plan` entry accept the same ordered image and file batches; attachment-bearing control subcommands reject before changing state. Command submissions cite completed staged-file receipts, so generic file bytes are not read or copied again. Session Controller resolves every file receipt to a durable reference before `ctx.attachments.admitPromptContent()` receives the complete ordered content list; attachment admission persists images and passes file references unchanged. The Host rejects upload completion after the original Agent is disposed and deduplicates prompt retries already identified by `rpcId`. Prompt receipts remain available through failure recovery, then retire after the accepted queue or history occurrence is observed; command-only receipts remain until Session disposal. Submission echoes register through `beginSubmission` before serialization, carry mixed attachments through `pendingSubmissions`, correlate by `rpcId`, retire once after queue or history observation, and keep drafts on failure. Before submission, the composer presents the ordered batch in one non-wrapping horizontal rail: images use 64 by 64 pixel thumbnails and generic files use 240 by 64 pixel cards with a 16 pixel radius, blue document glyph, filename plus extension and size, and a hover or keyboard removal action. QueueDock uses the same order with compact thumbnails and file cards. After submission, Chat presents the ordered batch in one right-aligned wrapping attachment flow. A lone image retains the large gallery presentation; a message with multiple attachments uses 64 by 64 pixel image tiles and 240 by 64 pixel file cards, so files and images share each row until the available width forces a wrap. Trajectory keeps the user-message preview compact and adds a localized generic-file count. Paperclip, drag, and paste classify by MIME; send waits for every file upload. Continuable subagent composers disable attachment intake, and the Host rejects any file block received for a child session. + +## Alternatives considered + +- **Store uploads in the project root** (the OpenHands / ChatGPT `/mnt/data` shape). Rejected: files in the user's tree can be edited, moved, or deleted, which breaks the session log's stable reference, pollutes `git status`, and requires a workspace to exist. `DSH_HOME` keeps frozen read-only bytes with content addressing; execution-world visibility is served by the projected path and can gain a copy-into-workspace step later. +- **A dedicated `read_file` tool.** Rejected: the handle line points at an ordinary path, so the existing `read` tool (and the model's other file tools) already covers it; a second read tool would split model behavior without adding capability. +- **Eager content injection into the prompt.** Rejected for files exactly as it was for `@` mentions ([web file references](2026-07-27-web-file-and-session-references.md)): relevance is unknown at attach time, and lazy tool reads keep every content access logged. +- **Type whitelist and size caps.** Rejected for now: DeepSeek Chat's whitelist exists because its server parses uploads, while this harness only stores bytes and lets the model read them. The streaming carrier and store keep memory proportional to bounded chunks, so a cap would govern disk and transfer policy rather than request safety. +- **A new attachment/file session event.** Rejected: the block-inside-`user/message` pattern images already use satisfies model-visible ⟺ logged without widening the event vocabulary. +- **Block every command while attachments are present.** Rejected: `/goal` objectives and `/plan` entry need reference files, so a global refusal would leave those files with no command route to the model. +- **Automatically send attachments after any successful command.** Rejected: state commands such as `/model` and `/compact` do not imply model-visible input. The command producer owns whether an admitted batch becomes a user message. +- **Store objective attachments in the goal domain.** Rejected: one ordinary logged user message gives later goal rounds the same history without widening the goal schema or duplicating attachments into round prompts. +- **Consume attachments on any command success.** Rejected: `/goal pause` or `/plan off` would clear unrelated cards. Consumption follows the producer's explicit success; grammar forms that cannot use attachments return an error. +- **Enforce declarations only in the client.** Rejected: direct RPC callers can bypass the composer. `CommandRuntime` enforces declaration, store availability, same-Session file-receipt resolution, image base64 admission, and image limits before entering the handler. +- **Expose uploads through `ctx.connection` and pass that object through Session constructors.** Rejected: Connection does not own file transfer, and the intermediate object layers do not use the dependency. The independent upload service accepts an explicit Session identity and lets browser and Worker-hosted pages replace the carrier without widening Connection or Session. + +## Consequences + +- Uploaded bytes are never deleted and carry no size limit, so `DSH_HOME` growth is unbounded; garbage collection stays deferred with the image-retention question. +- The projected path is execution-environment-local: a sandboxed or remote filesystem that cannot map `DSH_HOME` yields an explicit no-path handle, and the model reports that it cannot read the file. A store-backed read fallback is deferred. +- Staged uploads live in Host memory per Session; a Host restart between upload and send surfaces `FILE_NOT_STAGED` and the client re-adds the file. +- The browser-to-Host transfer is one streaming HTTP request without resumable offsets; retrying after connection or Host failure retransmits the file from byte zero. +- A `ReadableStream` upload consumes and transfers the stream once; retrying requires a newly created stream, and progress cannot report a total unless the producer supplies length through a future API. +- The browser upload Worker is emitted from the string form of a self-contained function. Runtime imports require a standalone Worker entry bundled by tsdown. +- Commands must declare attachment acceptance. Rejected commands preserve the text draft and every attachment card for retry. +- Subagents do not upload files independently. A main agent delegates with the saved path; only same-machine or inherited-history children whose execution environment maps that path can read it. +- Uploaded files do not appear in `@` completion yet; that integration is a planned later phase. +- The command registry admits a tagged `CommandSubmitAttachment` union whose file member carries a staged receipt, then hands handlers one frozen ordered `ImageBlock | FileBlock` array. The registry never schedules model input; `/goal` adds one logged follow-up and `/plan` steers the ordered blocks. +- Menu popup selection does not submit the composer envelope. Attachments remain visible until the user submits a command line or removes them. +- Declaration, missing-store, unknown-receipt, and image-limit rejections occur before handler entry. A producer grammar rejection and cancellation after image admission may leave unreferenced content-addressed objects for deferred attachment garbage collection. + +## Testing + +Unit and integration coverage pins verbatim and streamed storage, name sanitization, a declared 2.19 GiB request passing through the streaming bridge without aggregate buffering, raw and RPC wire admission, Blob and transferable `ReadableStream` carriers, bounded background-upload concurrency, progress and cancellation, cross-Session upload residency, command file-receipt submission without a second byte read, native and PTC projection under read-only and workspace-write permissions, upload staging, ordered queue and steer submission, queue-to-steer conversion, pending echo retirement and failure recovery, active plan and goal intake, `/plan` and `/goal` mixed attachments, subagent refusal, composer and Chat attachment layouts, and Trajectory file summaries. The keyless `file-upload-round` snapshot records the browser upload, model file read, and rendered answer. diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md new file mode 100644 index 0000000000..38de46a424 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 通用文件上传 + +Status: implemented + +[English](2026-08-26-generic-file-upload.md) | 中文 + +## 问题 + +会话可以把粘贴或拖入的图片作为持久附件接收,但其他任何文件都进不了对话:composer 拒绝非图片字节,唯一与文件相关的入口是 `@` 路径引用,而它要求文件本来就能从宿主工作区读到。用户无法从浏览器把 PDF、数据文件或压缩包交给智能体,而 DeepSeek Chat 用户期待回形针加文件卡的上传交互(#2984)。 + +命令平面已经为图片建立了完整提交规则:命令必须同时消费文本与附件,或明确拒绝整次提交。`input.images` 声明和只支持图片的 RPC 载荷使通用文件在普通消息可用后仍走独立拒绝路径。Plan 和 goal 入口需要使用同一附件词汇,同时保留执行器强制规则,不能静默清除草稿。 + +## 决定 + +文件与图片使用不同存储方案,并进入同一个有序消息附件列表。图片的规范化附件流水线保持不变。其他被选中的文件进入同一个先进先出的后台上传队列,按字节原样保存且不设类型与大小限制,再在本进程内按同一 Session 暂存。相同字节在 `DSH_HOME/attachments/v1/file-objects//` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files///` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的双端 `file-upload` Cordis 包拥有传输与暂存。其 Client `fileUpload` 服务同时接收所属 Session 标识与 `Blob`、精确字节或只能使用一次的 `ReadableStream`;它使用该标识组装原始路由请求,或直接调用生成的 Remote。其 Host `fileUploads` 服务拥有流式路由、编码 Remote 兜底、命令凭证解析器,以及以接收方 Agent 的 Session 对象为键的凭证表。编码准入和附件错误识别通过 `ctx.attachments` 完成,字节存储由附件提供方拥有。Session Controller 注册用于休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证;Session 对象不公开文件传输。普通 Web 页面把每个活动请求体交给专用上传 Worker:Blob 请求体通过 XMLHttpRequest 发送并报告包含总量的字节进度,stream 则转移所有权并增量传入 Fetch。Worker-hosted 页面通过页面自有 Fetch 载体把请求体转交给 Host Worker,后者读取有界分片。页面线程不读取通用文件字节,也不做 base64 编码或 JSON 序列化。经过认证的 Host 路由显式使用 Connection 的流式请求模式,因此 bridge 会在消费请求体前分发,本地存储则把有界分片写入私有暂存对象并同时计算摘要。接收完成后,内容寻址对象以原子方式发布;取消或失败会移除暂存文件。上传任务和草稿状态属于常驻的 conversation 根,因此切到另一条 Session 不会停止传输,返回原 Session 时仍显示同一张文件卡和当前进度。移除文件卡会跳过排队中的传输或中止正在运行的传输。发送的消息在 `user/message` 里携带结构化的 `FileBlock`(`{ name, bytes, attachmentId }`),不新增会话事件类型。`LlmRuntime` 的请求组装把每个文件块,包括嵌套工具结果中的文件块,投影成确定性 handle 文本,指出文件与其只读保存路径。handle 会要求主 agent 委派文件任务时附上该路径,并在子级执行环境看不到路径时明确报告。提供方不会收到文件字节,模型在需要时用现有文件工具读取存储副本。 + +默认模式、激活的 Plan Mode 与 active goal 共用普通 queue、follow-up 和 steer 的附件准入。`/goal` create 和 edit 以及 `/plan` 进入接受同一套有序图片与文件批量;携带附件的控制子命令会在状态变更前拒绝。命令提交引用已完成暂存的文件凭证,不会再次读取或复制通用文件字节。Session Controller 先把每个文件凭证解析为持久引用,再由 `ctx.attachments.admitPromptContent()` 接收完整有序内容列表;附件准入会持久化图片,并让文件引用原样通过。原 Agent 已销毁时,Host 会拒绝发布上传完成凭证,并按 `rpcId` 去重已经存在的 prompt 重试。prompt 凭证在失败恢复期间保持可用,已接受的 queue 或历史 occurrence 被观察后才退休;只被命令使用的凭证保留到 Session 销毁。提交回显在序列化前经 `beginSubmission` 注册,通过 `pendingSubmissions` 携带混合附件,以 `rpcId` 关联,在 queue 或历史观察后只退休一次,并在失败时保留草稿。发送前,composer 把有序附件放进同一条不换行的横向附件栏:图片使用 64 乘 64 像素缩略图,通用文件使用 240 乘 64 像素卡片,带 16 像素圆角、蓝色文档图标、文件名加扩展名与大小,以及悬停或键盘操作时可用的移除按钮。QueueDock 使用相同顺序以及紧凑的缩略图和文件卡。发送后,Chat 把有序附件放进同一个右对齐、可换行的附件区域。只有一张图片时保留图片画廊的大图样式;一条消息含有多个附件时,图片使用 64 乘 64 像素缩略图,文件使用 240 乘 64 像素卡片,文件与图片在可用宽度内共用一行,空间不足时再换行。Trajectory 保持紧凑的用户消息预览,并增加本地化的通用文件数量。回形针、拖拽和粘贴按 MIME 分类;发送会等待每个文件上传完成。continuable 子代理 composer 禁用附件入口,Host 会拒绝收到的任何子会话文件块。 + +## 考虑过的替代方案 + +- **把上传存进项目根目录**(OpenHands / ChatGPT `/mnt/data` 的形态)。拒绝:用户目录里的文件可能被编辑、移动或删除,会打断会话日志的稳定引用,污染 `git status`,还要求工作区必须存在。`DSH_HOME` 用内容寻址保存冻结的只读字节;执行世界可见性由投影路径提供,之后还可以补一步拷贝进工作区。 +- **专用的 `read_file` 工具。** 拒绝:handle 文本指向一条普通路径,现有 `read` 工具(以及模型的其他文件工具)已经覆盖它;再加一个读工具只会分裂模型行为而不增加能力。 +- **把内容急切注入提示词。** 对文件的拒绝理由与当年对 `@` 引用完全相同([web 文件引用](2026-07-27-web-file-and-session-references.zh.md)):附加时相关性未知,惰性工具读取让每次内容访问都留在日志里。 +- **类型白名单与大小上限。** 现阶段拒绝:DeepSeek Chat 的白名单存在是因为其服务端要解析上传,而本 harness 只存字节、让模型自己读。流式载体和存储让内存占用与有界分块大小相关,因此上限属于磁盘与传输策略,不用于保证请求安全。 +- **新增 attachment/file 会话事件。** 拒绝:图片已经采用的「块内联在 `user/message`」模式即可满足模型可见 ⟺ 可日志重建,无需扩大事件词汇。 +- **有附件时阻止所有命令。** 拒绝:`/goal` 目标与 `/plan` 进入需要参考文件,统一拒绝会使这些文件没有进入模型的命令路径。 +- **任何命令成功后自动发送附件。** 拒绝:`/model` 与 `/compact` 等状态命令不表示模型可见输入。附件是否成为用户消息由命令生产方负责。 +- **把目标附件存进 goal 领域。** 拒绝:一条普通的已记录用户消息就能让后续 Goal Round 获得相同历史,无需扩大 goal schema 或把附件复制进 Round 提示词。 +- **任何命令成功都消费附件。** 拒绝:`/goal pause` 或 `/plan off` 会清除无关卡片。只有生产方明确成功才消费附件;无法使用附件的语法形式返回错误。 +- **只在客户端执行声明。** 拒绝:直接 RPC 调用方可以绕过 composer。`CommandRuntime` 在处理器运行前强制检查声明、存储可用性、同一 Session 的文件凭证解析、图片 base64 准入与图片限制。 +- **通过 `ctx.connection` 暴露上传并沿 Session 构造器传递该对象。** 拒绝:Connection 不拥有文件传输,中间对象层也不使用这项依赖。独立上传服务直接接收 Session 标识,并允许普通浏览器与 Worker-hosted 页面替换载体,无需扩大 Connection 或 Session。 + +## 后果 + +- 上传字节永不删除且没有大小上限,`DSH_HOME` 的增长无界;垃圾回收与图片保留问题一同继续挂起。 +- 投影路径只在对应执行环境中有效:无法映射 `DSH_HOME` 的沙箱或远程文件系统会得到显式的无路径 handle,模型会报告无法读取该文件。基于存储的读取兜底被推迟。 +- 暂存上传按 Session 存在 Host 内存里;上传与发送之间 Host 重启会出现 `FILE_NOT_STAGED`,客户端重新添加文件即可。 +- 浏览器到 Host 的传输使用一次不带断点续传偏移的流式 HTTP 请求;连接或 Host 失败后的重试会从第一个字节重新传输整个文件。 +- `ReadableStream` 上传只消费并转移一次 stream;重试必须重新创建 stream,而且在未来 API 允许生产方提供长度前,进度不能报告总量。 +- 浏览器上传 Worker 由自包含函数的字符串形式生成。需要运行时 import 时,必须改用由 tsdown 打包的独立 Worker 入口。 +- 命令必须声明接受附件。命令拒绝时保留文本草稿与全部附件卡,供用户重试。 +- 子代理不独立上传文件。主 agent 委派时传递保存路径;只有同机或继承父级历史且执行环境能映射该路径的子级可以读取。 +- 上传的文件尚未进入 `@` 补全;该联动是计划中的后续阶段。 +- 命令注册表准入带类型标记的 `CommandSubmitAttachment` 联合类型,其中的文件成员携带暂存凭证,再向处理器提供一个冻结的有序 `ImageBlock | FileBlock` 数组。注册表不安排模型输入;`/goal` 增加一条已记录 follow-up,`/plan` steer 有序附件块。 +- 从菜单选择弹窗命令不会提交 composer 的整套内容。附件保持可见,直到用户提交命令行或移除附件。 +- 声明、存储缺失、未知凭证与图片限制拒绝发生在处理器运行前。生产方语法拒绝和图片准入后的取消可能留下无引用的内容寻址对象,由后续附件垃圾回收处理。 + +## 测试 + +单元与集成覆盖原样与流式存储、文件名清洗、声明为 2.19 GiB 的请求通过流式 bridge 且不发生总量缓冲、原始字节与 RPC wire 准入、Blob 与可转移 `ReadableStream` 载体、后台上传并发上限、进度与取消、跨 Session 上传常驻、命令通过文件凭证提交且不再次读取字节、read-only 与 workspace-write 权限下的 native 和 PTC 投影、上传暂存、有序 queue 与 steer 提交、队列转 steer、pending 回显退休与失败恢复、active plan 和 goal 的附件入口、`/plan` 与 `/goal` 混合附件、子代理拒绝、composer 与 Chat 附件布局,以及 Trajectory 文件摘要。keyless `file-upload-round` 快照记录浏览器上传、模型读取文件与答案呈现。 diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml new file mode 100644 index 0000000000..908d48ae05 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.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-31-cross-process-session-write-lease.md +2026-08-31-cross-process-session-write-lease.md: 174c5152ea62e01e30ade9a68b6786638acb8ada +2026-08-31-cross-process-session-write-lease.zh.md: e4246f12f7ed8d8b304ca7f7514117f03f32267b diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md new file mode 100644 index 0000000000..174c5152ea --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md @@ -0,0 +1,29 @@ +# Agent Note: cross-process session write lease + +Status: implemented + +English | [中文](2026-08-31-cross-process-session-write-lease.zh.md) + +## Problem + +The JSONL backend's write-handle claim excluded a second writer only inside one backend instance. Two processes — two CLI sessions, or a host beside an SDK runtime — could write-open the same session and interleave appends into one log file, tearing compressed frames and seq contiguity. The seam needed durable cross-process write ownership whose arbiter lives outside every writer process, because no writer outlives every failure mode. + +## Decision + +`SessionWriteLease` (packages/session/session-persistence-jsonl/src/lease.ts) holds a kernel lock on `session.lock` beside the log for the whole life of a write handle: POSIX takes a non-blocking `flock(2)` through the pinned native dependency `fs-ext`, and Windows holds a named kernel semaphore (count 1) derived from the canonical lock path (`CreateSemaphoreW` in src/win32.ts beside the existing koffi bindings) — a kernel object with no filesystem footprint, destroyed with its last handle. Contention maps to `SessionAlreadyOwnedError`; the kernel releases the lock when the holder's descriptor or handle closes, including on any process death, so a crashed holder never blocks a successor and no expiry bookkeeping exists. A live but wedged holder keeps the lock until its process exits: expropriating a stalled writer was rejected because its resumed appends would tear the log, and on POSIX removing the lock file remains the explicit forfeit for that case. Because a POSIX lock names an inode rather than a path, acquisition verifies the locked inode is still the file at the lock path and retries otherwise. The lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write — an unmaterialized session leaves no filesystem footprint, and a handle that acquired the lock keeps it through close even when materialization fails; release never removes the lock file, preserving the stable inode later lockers verify against. The browser worker deployment stubs fs-ext to immediate success: it is single-process, so the in-process write claim already excludes every writer. + +## Alternatives considered + +**TTL record with renewal and claim-by-rename (implemented first, replaced in review)** — a JSON record beside the log carrying an owner token and expiry, renewed on an interval, taken over by atomic rename after expiry. It survives every filesystem but is a distributed algorithm in miniature: renewal timers, loss detection, takeover claiming with re-judgment and give-back — and its residual multi-actor races still allowed bounded dual-writer overlap (one renewal interval). Kernel arbitration deletes the whole family plus the machinery, at the cost of a native build dependency and the wedged-holder semantics above. + +**`proper-lockfile`** — the npm ecosystem's staleness-plus-touch implementation of the same TTL model. It retains the delete-then-recreate takeover race, detects compromise by mtime and inode (weaker than an owner token), and has had no release since 2021. + +**fs-ext's own Windows face (`LockFileEx` byte-range locks)** — rejected after CI proof: Windows byte-range locks are mandatory, so any reader touching the locked file hard-fails (ripgrep died with os error 33 walking a session directory). + +**Windows exclusive-open sharing mode (`CreateFileW` denying `FILE_SHARE_WRITE`)** — leaves readers untouched but pins the lock file's name and directory while held: CI showed dozens of suites failing their temp-root cleanup with EBUSY because a still-open handle blocks recursive removal, and users deleting a session directory would hit the same wall. The named semaphore keeps kernel arbitration with zero filesystem footprint. + +**Hand-rolled ffi for POSIX too (`flock(2)` via koffi)** — avoids the node-gyp install-time build, but means owning both platform lock implementations plus their error mapping; `fs-ext` ships the POSIX code maintained and pinned, and the Windows side reuses the koffi bindings `win32.ts` already owns. + +## Consequences + +Cross-process exclusion costs a node-gyp-compiled native dependency (`fs-ext`, allow-listed in `pnpm-workspace.yaml` `allowBuilds`), one lock file per materialized session that release deliberately leaves in place, and the wedged-holder rule: a stuck process blocks that session's writers until it exits. It buys immediate crash recovery (no waiting period), no renewal traffic, and the removal of every takeover race the TTL design managed rather than prevented. Advisory `flock` is unreliable on some network filesystems (NFSv3); a root on such a mount degrades toward in-process-only exclusion. Deleting a live session's lock file forfeits exclusion on POSIX by design — the harness never does so; the agent-loop resume test uses it deliberately to simulate a wedged first lifecycle, and skips on Windows, where the lock is a kernel object no file operation can forfeit. diff --git a/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md new file mode 100644 index 0000000000..e4246f12f7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 跨进程会话写租约 + +Status: implemented + +[English](2026-08-31-cross-process-session-write-lease.md) | 中文 + +## Problem + +JSONL 后端的写句柄认领只在单个后端实例内部排除第二个写入方。两个进程——两个 CLI 会话,或宿主与 SDK 运行时并存——可以对同一会话执行写打开,把追加交错写进同一个日志文件,撕坏压缩帧与 seq 连续性。该 seam 需要一份仲裁者位于所有写入进程之外的持久跨进程写所有权,因为没有任何写入方能活过所有故障模式。 + +## Decision + +`SessionWriteLease`(packages/session/session-persistence-jsonl/src/lease.ts)在日志旁的 `session.lock` 上持有内核锁,贯穿写句柄的整个生命期:POSIX 经由固定版本的原生依赖 `fs-ext` 以非阻塞 `flock(2)` 加锁,Windows 持有由规范锁路径派生的命名内核信号量(计数 1,`CreateSemaphoreW`,实现在 src/win32.ts 既有 koffi 绑定旁)——零文件系统足迹的内核对象,随最后一个句柄关闭而销毁。竞争映射为 `SessionAlreadyOwnedError`;持有者的描述符或句柄关闭时内核释放锁,包括任何形式的进程死亡,因此崩溃的持有者从不阻塞后继者,也不存在任何过期簿记。活着但卡死的持有者保有锁直到其进程退出:剥夺停顿写入方的所有权被否决,因为其复活后的追加会撕坏日志;POSIX 上删除锁文件仍是该场景的显式放弃手段。由于 POSIX 锁指向 inode 而非路径,获取后会校验所锁 inode 仍是锁路径上的文件,否则重试。锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取——未物化的会话不留任何文件系统足迹,已取得锁的句柄即使物化失败也保有锁直到关闭;释放从不删除锁文件,保住后续加锁者用于校验的稳定 inode。浏览器 worker 部署将 fs-ext 存根为立即成功:它是单进程部署,进程内写认领已排除所有写入方。 + +## Alternatives considered + +**TTL 记录加续约与 rename 认领(最初实现,review 中被替换)** —— 日志旁的 JSON 记录携带 owner 令牌与过期时间,按间隔续约,过期后以原子 rename 接管。它在所有文件系统上都能活,但本质是一个微缩的分布式算法:续约定时器、丢失检测、带复核与归还的接管认领——而其残余的多方竞态仍允许有界的双写重叠(一个续约间隔)。内核仲裁删除了整族竞态及其全部机制,代价是一个原生构建依赖和上述卡死持有者语义。 + +**`proper-lockfile`** —— npm 生态对同一 TTL 模型的"过期判定加 touch"实现。它保留"先删后建"的接管竞态,用 mtime 加 inode 检测失主(弱于 owner 令牌),且自 2021 年起再无发布。 + +**fs-ext 自带的 Windows 实现(`LockFileEx` 字节区间锁)** —— 被 CI 实证否决:Windows 的字节区间锁是强制锁,任何读到被锁文件的进程都会硬失败(ripgrep 遍历会话目录时以 os error 33 崩掉)。 + +**Windows 共享模式独占打开(`CreateFileW` 拒绝 `FILE_SHARE_WRITE`)** —— 读者不受影响,但持有期间钉住锁文件的名字与目录:CI 显示数十个套件的临时根清理因仍打开的句柄阻塞递归删除而报 EBUSY,用户删除会话目录也会撞上同一堵墙。命名信号量保住内核仲裁,且文件系统足迹为零。 + +**POSIX 也手写 ffi(经 koffi 调 `flock(2)`)** —— 免去 node-gyp 安装期编译,但意味着自有两个平台的锁实现及其错误映射;`fs-ext` 交付了有维护、可固定版本的 POSIX 侧,Windows 侧复用 `win32.ts` 已自有的 koffi 绑定。 + +## Consequences + +跨进程排他的代价是一个 node-gyp 编译的原生依赖(`fs-ext`,已在 `pnpm-workspace.yaml` 的 `allowBuilds` 列入允许)、每个物化会话一个由释放刻意留下的锁文件,以及卡死持有者规则:卡住的进程阻塞该会话的写入方直到其退出。它换来的是即时崩溃恢复(无等待期)、零续约流量,以及删除了 TTL 设计只能"管理"而非"消除"的全部接管竞态。咨询式 `flock` 在部分网络文件系统(NFSv3)上不可靠;位于此类挂载上的根目录会退化为仅进程内排他。POSIX 上删除活跃会话的锁文件按设计即放弃排他——harness 自身从不这样做;agent-loop 的 resume 测试刻意用它模拟卡死的第一个生命周期,并在 Windows 上跳过:那里的锁是任何文件操作都无法放弃的内核对象。 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml new file mode 100644 index 0000000000..4aec891a9f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.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-09-04-web-clickable-link-styles.md +2026-09-04-web-clickable-link-styles.md: 8781990a40de30e26d0c59c0ecc1a08ba41643ac +2026-09-04-web-clickable-link-styles.zh.md: 7b5fde4c87319f6ae34b76d1c8e712e6f9f18392 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md new file mode 100644 index 0000000000..8781990a40 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md @@ -0,0 +1,34 @@ +# Agent Note: Web clickable-link language — link alias, dotted hover underline, category glyphs + +Status: implemented + +English | [中文](2026-09-04-web-clickable-link-styles.zh.md) + +## Problem + +Clickable artifact links in the chat transcript wore four different costumes: markdown anchors and prose file mentions were business-primary blue with a solid hover underline, web search/fetch links matched that pair, produced-file chips were grey pills (label-secondary text on interactive-bg-hover, 96px max width), and workflow member links carried a resting solid underline. Nothing marked what a link opens (browser, host app, Finder, in-app view), and link color was coupled to `--dsw-alias-state-business-primary`, which also drives focus rings and state dots, so tuning link color risked unrelated surfaces. + +## Decision + +One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links: + +- Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus. +- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url` (globe), `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; `classifyLinkPath` derives the file kinds from the extension, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. +- Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show. +- Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style). +- In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border. + +Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification table), refreshed markdown-dom fixtures, and the `clickable-links-gallery` web e2e — one settled keyless turn rendering every clickable link form — registered in the host compiler face (`tsconfig.host.json`) like its other scaffold-importing siblings. + +## Alternatives considered + +- **Colored Word/Excel/PPT/PDF brand glyphs.** Implemented, then removed: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. +- **Per-extension icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise, and per-site favicons remain possible later behind the same `url` category. +- **Keeping links on `state-business-primary`.** Darker link blues (blue-600/650/700 were auditioned and reverted) would have dragged focus rings and state dots along; the dedicated alias localizes any future tuning to one line. +- **Glyphs on ToolRow path links.** Rejected: tool rows keep their quieter grey dotted affordance, and leading glyphs there would stack icons in already dense rows. + +## Consequences + +- A new clickable artifact surface should consume `--dsw-alias-link` and the LinkIcon vocabulary rather than introduce another color or underline form; the rule lives in [docs/web-styling.md](../../../../docs/web-styling.md). +- Long produced-file names take their natural width; when a row overflows, flex shrinks all chips proportionally, so several long names shrink together instead of the last one yielding first. +- mailto links currently share the `url` globe; a distinct mail category is a one-line addition if ever wanted. diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md new file mode 100644 index 0000000000..7b5fde4c87 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md @@ -0,0 +1,34 @@ +# Agent Note: Web 可点击链接语言——链接别名、hover 点状下划线、分类图标 + +Status: implemented + +[English](2026-09-04-web-clickable-link-styles.md) | 中文 + +## 问题 + +会话记录里的可点击产物链接有四套互不一致的样式:Markdown 锚点和正文文件引用是 business-primary 蓝加 hover 实线下划线,网页搜索/抓取链接与之相同,产物 chips 是灰色药丸(interactive-bg-hover 底上的 label-secondary 文字,96px 最大宽度),workflow 成员链接则带常驻实线下划线。没有任何标记说明链接点开后去哪儿(浏览器、宿主应用、Finder、应用内视图),而且链接颜色耦合在 `--dsw-alias-state-business-primary` 上——它同时驱动焦点环和状态点,调链接色会波及无关表面。 + +## 决策 + +会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言: + +- 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。 +- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`(地球)、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`classifyLinkPath` 按扩展名推导文件类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 +- 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。 +- 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。 +- 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。 + +覆盖:LinkIcon 单测(每个 kind 一个独立图形、分类表)、刷新后的 markdown-dom 夹具,以及 `clickable-links-gallery` web e2e——一个 settled 的 keyless 回合渲染全部可点击链接形态——像其他引 scaffold 的同类一样注册进 host 编译面(`tsconfig.host.json`)。 + +## 备选方案 + +- **彩色 Word/Excel/PPT/PDF 品牌图形。** 实现后又移除:固定品牌填充违反图标集 currentColor-only 规则,这些扩展名并入单一的 outline `document` 图形。 +- **每个扩展名一个图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音,按站点的 favicon 以后仍可在同一 `url` 类别之下引入。 +- **链接继续用 `state-business-primary`。** 更深的链接蓝(试过 blue-600/650/700 又回退)会连带焦点环和状态点;专用别名把未来的调色收敛到一行。 +- **给 ToolRow 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。 + +## 后果 + +- 新的可点击产物表面应消费 `--dsw-alias-link` 和 LinkIcon 词汇,而不是引入另一种颜色或下划线形态;规则记录在 [docs/web-styling.md](../../../../docs/web-styling.zh.md)。 +- 长产物文件名按自然宽度展示;整行溢出时 flex 按比例收缩所有 chip,几个长名字一起收缩,而不是最后一个先让位。 +- mailto 链接目前共用 `url` 地球图形;若将来需要独立的邮件类别,一行即可加上。 diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.i18n.yaml new file mode 100644 index 0000000000..cb40bd0029 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.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/process/2026-08-28-test-temp-dir-self-cleanup.md +2026-08-28-test-temp-dir-self-cleanup.md: 175fe82b0ad01c2afda3abb5f66ff455045a3cd6 +2026-08-28-test-temp-dir-self-cleanup.zh.md: c2fcd07d1619d1556ac99b336f27b89a5f46e426 diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md new file mode 100644 index 0000000000..175fe82b0a --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.md @@ -0,0 +1,39 @@ +# Agent Note: Unit tests remove the dsh-* temp dirs they create + +Status: implemented + +English | [中文](2026-08-28-test-temp-dir-self-cleanup.zh.md) + +## Problem + +Test processes create `/tmp/dsh-*` directories with `mkdtemp(join(tmpdir(), 'dsh-*'))` and leave them behind. On the self-hosted Linux CI host (32 runner instances sharing one `/tmp`) the residue exhausted the root partition's inode capacity twice (issue #3134, 2026-08-13 and 2026-08-26). The machine-side `dsh-tmp-sweep` timer and the CI lane sweep (kept, unmerged, on branch `fix/ci-tmp-residue-cleanup`) remove residue after the fact but leave the producing defect in place. Human review of #3233 (2026-08-28) rejected the sweep: unit tests must clean up the directories they create instead. + +## Decision + +Retrofit removal of every `dsh-*` temp dir a spec file creates, at the owning test's teardown: + +- Spec files that created dirs without removing any now track each created root in a module-level list and delete the list in `afterEach`/`afterAll` (`rm`/`rmSync` with `recursive: true, force: true`), the convention already used across the session packages. Root-creating helpers (`tmp()`, `tempDir()`, `fakeLauncher()`, harness functions) register the root at creation, so every caller is covered at one point. +- Module-scope fixture dirs shared by a whole file (executor spill dirs) are removed in `afterAll` after the last test. +- The file list came from the observed-residue inventory on the CI host (a template histogram of current `/tmp/dsh-*` dirs): only spec files whose dirs actually appeared were leak sources. Files that already remove their dirs (agent-team, tool-subagent, list-children, hooks coverage cases) were confirmed clean on the normal-exit path and left unchanged. +- Product cleanup is limited to the per-process spill directory of `dsh-subprocess-local/spawn` (`privateSpillDir`): it is removed at a JavaScript-observable process exit when it holds no completed spill file — completed spill files are retained as full-output recovery artifacts until an external cleanup, so only directories that never spilled (the dominant residue shape on the CI host: 92% of sampled `dsh-subprocess-*` dirs are empty) are removed. The removal is best-effort (ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code). `dsh-spill-local`'s default root is deliberately NOT exit-deleted: it is covered by the package's own 30-day startup sweep, and the [retention decision](../architecture/2026-07-17-local-spill-startup-cleanup.md) forbids deleting fresh spill artifacts that resumed or forked sessions may still reference. + +## Verification + +- Targeted local runs of every changed unit spec passed (the 36 changed `*.spec.ts` files, exercised in grouped runs), including the suites that exercise the changed product source; the two changed web `*.e2e.ts` files run under the web e2e lane. +- CI runs the changed specs on the Linux and Windows coverage lanes; after a full green run, the fixed files' residue templates (observed at up to ~5,000 dirs per two hours each, e.g. `dsh-profile-`, `dsh-app-boot-`, `dsh-presets-*`, `dsh-upload-index-`) should no longer appear in fresh `/tmp` residue on the CI host. + +## Alternatives considered + +### Keep the sweep-only approach (rejected in review) + +Sweep steps and timers delete residue after it exists; they do not stop local runs from accumulating, and a machine sweep cannot distinguish a dead run's residue from a live one's. The reviewer decision was per-test cleanup, implemented here for the normal-exit path. + +### Introduce a shared temp-dir helper package + +Not chosen: the files that leak each create roots through their own small helpers, and tracking them at those helpers is a per-file one-point change. A new test-support package would add a dependency without reducing the per-file audit. + +## Consequences + +- Bought: on normal completion — including failed tests — a spec's `dsh-*` dirs are removed at teardown; a `dsh-subprocess-local` per-process spill directory holding no completed spill file is removed at a JavaScript-observable process exit. +- Cost: a process killed with SIGKILL (a cancelled run, a timeout kill) cannot run any in-process teardown; its in-flight residue remains. The machine-side timer stays as the backstop for that path. +- Cost: dirs created by a spawned child are covered only when the test knows their paths; product-owned spill dirs holding spill files, and `dsh-spill-local`'s default root, keep their files per the existing retention policy and are cleaned by that package's own sweep. diff --git a/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md new file mode 100644 index 0000000000..c2fcd07d16 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-28-test-temp-dir-self-cleanup.zh.md @@ -0,0 +1,39 @@ +# Agent Note:单测删除自己创建的 dsh-* 临时目录 + +Status: implemented + +[English](2026-08-28-test-temp-dir-self-cleanup.md) | 中文 + +## Problem + +测试进程用 `mkdtemp(join(tmpdir(), 'dsh-*'))` 创建 `/tmp/dsh-*` 目录后不清理。在自托管 Linux CI 主机上(32 个 runner 实例共享一个 `/tmp`),残留两次耗尽根分区 inode(issue #3134,2026-08-13 与 2026-08-26)。机器侧 `dsh-tmp-sweep` timer 与 CI lane sweep(保留未合并,在分支 `fix/ci-tmp-residue-cleanup` 上)都是事后删除残留,未修掉产生残留的缺陷本体。人类 review #3233(2026-08-28)否决了 sweep:单测应改为自己清理创建的目录。 + +## Decision + +为 spec 文件创建的每个 `dsh-*` 临时目录补上删除路径,挂在所属测试的 teardown 上: + +- 创建目录但从不删除的 spec 文件,现在把每个创建的 root 记入模块级列表,并在 `afterEach`/`afterAll` 里删除(`rm`/`rmSync` 带 `recursive: true, force: true`)——与 session 包既有的 `roots.splice(0)` 约定一致。创建 root 的 helper(`tmp()`、`tempDir()`、`fakeLauncher()`、harness 函数)在创建处登记,一个点覆盖全部调用方。 +- 整文件共享的模块级 fixture 目录(executor spill 目录)在最后一个测试之后的 `afterAll` 里删除。 +- 目标文件清单来自 CI 主机上的残留实测清单(当前 `/tmp/dsh-*` 目录的模板直方图):只有目录确实出现在残留里的 spec 文件才是泄漏源。已有删除逻辑的文件(agent-team、tool-subagent、list-children、hooks coverage cases)确认在正常结束路径上本来干净,不改。 +- 产品侧清理限定在 `dsh-subprocess-local/spawn` 的每进程 spill 目录(`privateSpillDir`):在 JavaScript 可观察的进程退出时,仅当目录**未持有任何已完成的 spill 文件**才删除——已完成的 spill 文件作为完整输出恢复产物保留到外部清理,因此只有从未 spill 过的目录(CI 主机残留的主流形态:抽样 `dsh-subprocess-*` 目录 92% 为空)会被删除。删除是 best-effort(ENOENT/ENOTEMPTY/EBUSY/EPERM 不得改变退出码)。`dsh-spill-local` 的默认 root **刻意不做**退出删除:该 root 由包自带的 30 天启动 sweep 覆盖,且[保留策略 note](../architecture/2026-07-17-local-spill-startup-cleanup.zh.md)禁止删除 resume/fork 会话仍可能引用的新 spill 产物。 + +## Verification + +- 本地定向跑过全部改动单测 spec 通过(36 个改动的 `*.spec.ts` 文件,分组运行),含直接使用改动后产品源码的套件;2 个改动的 web `*.e2e.ts` 由 web e2e lane 承载。 +- CI 在 Linux 与 Windows coverage lane 跑改动 spec;一次全绿后,被修文件的残留模板(实测每两小时最多各约 5,000 个目录,如 `dsh-profile-`、`dsh-app-boot-`、`dsh-presets-*`、`dsh-upload-index-`)应不再出现在 CI 主机的新鲜 `/tmp` 残留里。 + +## Alternatives considered + +### 保留纯 sweep 方案(review 否决) + +Sweep 步骤与 timer 只删已存在的残留;本地运行仍会累积,机器 sweep 也区分不了已死 run 的残留与存活 run 的目录。review 的决定是逐测试清理,本实现覆盖正常结束路径。 + +### 引入共享临时目录 helper 包 + +未选:泄漏文件各自通过自己的小 helper 创建 root,在那些 helper 处登记是每个文件单点改动;新增 test-support 包只会增加依赖,不减少逐文件审计量。 + +## Consequences + +- 收益:正常结束(含测试失败)时,spec 的 `dsh-*` 目录在 teardown 删除;`dsh-subprocess-local` 未持有任何已完成 spill 文件的每进程 spill 目录在 JavaScript 可观察的进程退出时删除。 +- 代价:被 SIGKILL 的进程(run 被取消、超时被杀)无法运行任何进程内 teardown,飞行中的残留仍在——机器侧 timer 继续兜底该路径。 +- 代价:子进程创建的目录只有在测试知道其路径时才被覆盖;产品自有、仍持有 spill 文件的目录与 `dsh-spill-local` 的默认 root 按既有保留策略保留文件,由该包自身的 sweep 清理。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index c4dcff9e57..8991fe86e8 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: 5a88c49b0811e80c19e3a39cf5604a74460fc3e2 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 829b07b789c2cfe74bcaf3b9db03acffd156887b +2026-07-24-web-gui-browser-e2e-lane.md: 2047351b5642664a800fb048acee56dc80dcc77d +2026-07-24-web-gui-browser-e2e-lane.zh.md: dad0f69d64dbb802d3a5db097720bb97c30ea8cf diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index 5a88c49b08..2047351b56 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -28,6 +28,8 @@ The barrier stack for replay-mode browser assertions is, in order: (1) host-side No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted through the ordered `agent/assistant-stream` follow path, while the final durable `assistant/message` or `assistant/attempt` embeds the exact stream used for replay. `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly. +Pagination drivers wait for the interactive load row to leave its pending state and record the pre-request row count before scrolling. An immediately committed resident page therefore remains observable instead of becoming the baseline for a request that the scroll gesture does not repeat. + Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. Standard scenarios open an `en-US` browser so localized role locators and goldens use one explicit language; scenarios asserting Chinese copy open a `zh-CN` browser instead, because the client derives its provisional locale from `navigator` when the Host settings document has no explicit preference ([browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)). `settings-chrome.e2e.ts` additionally covers both switch directions, a fresh English-browser default, and preference persistence across distinct ports sharing one DSH home. ### Expected outputs diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 829b07b789..dad0f69d64 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -28,6 +28,8 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 不做单次瞬态 DOM 断言:从 replay yield 到 React commit 的每一跳都可能合并 chunk,采样 `[data-streaming]` 天然就是竞态。流式增量性通过有序 `agent/assistant-stream` follow path 断言,最终持久 `assistant/message` 或 `assistant/attempt` 则嵌入 replay 使用的精确 stream。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = burst)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且 pace wait 期间 abort 会即时取消。 +分页驱动会等待交互式加载行退出 pending 状态,并在滚动前记录请求前的行数。因此,即时提交的常驻页仍然可观测,不会变成一次滚动手势不会重复触发的请求基线。 + 每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。常规场景开启 `en-US` 浏览器,使本地化的 role 定位器和预期输出统一采用明确指定的语言;断言中文文案的场景则开启 `zh-CN` 浏览器,因为 Host settings 文档没有显式偏好时,客户端的暂定 locale 由 `navigator` 推导([由浏览器推导初始 locale](../feature/2026-07-31-browser-derived-initial-locale.zh.md))。`settings-chrome.e2e.ts` 还额外覆盖双向切换、全新英文浏览器默认态,以及共享同一 DSH home 的不同端口之间的偏好持久化。 ### 预期输出 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 694964b0ce..1be29ddbaf 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -70,6 +70,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`e2b`](https://github.com/e2b-dev/e2b) | MIT | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`fflate`](https://github.com/101arrowz/fflate) | MIT | +| [`fs-ext`](https://github.com/baudehlo/node-fs-ext) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | | [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | @@ -148,6 +149,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/fs-ext`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | diff --git a/apps/cli/package.json b/apps/cli/package.json index 7a389053d4..204bdb8a9e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts index 8f97b62ced..54b4c734e7 100644 --- a/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts @@ -106,8 +106,10 @@ describe('session format guard through the assembled app', () => { version: SESSION_FORMAT_VERSION, }) expect(current.trimEnd().split('\n').length).toBeGreaterThan(closedTurn().length + 1) + // `session.lock` is the write handle's kernel lock file, published + // with the first materializing write and kept across release. expect((await readdir(dirname(sourcePath))).sort()) - .toEqual(['session.jsonl', generationLogFilename(SESSION_FORMAT_VERSION, 'none')]) + .toEqual(['session.jsonl', 'session.lock', generationLogFilename(SESSION_FORMAT_VERSION, 'none')]) }, }) }, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 6c2ac3db7b..557cabe74a 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -82,6 +82,9 @@ async function bootWeb( // skills test below proves it reaches preset-composed agents. { id: 'skill-badge', disabled: false }, { id: 'modules', disabled: true }, + // The physical Connection row owns the disabled HTTP server. bootWeb + // supplies only its in-process registries so Host services still prove + // their shipped dependency graph without binding a port. { id: 'connection', disabled: true }, // Export owns a Connection Fetch route, so this Host-only composition // disables it with the transport service above. @@ -139,6 +142,10 @@ async function bootWeb( const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') return await boot('dsh-test', rootConfig, [...bundlePatches, ...overrides], (bootCtx) => { + bootCtx.provide('connection', { + fetch: { register: () => () => {} }, + rpc: { intercept: () => () => {} }, + } as never) provideCmdline(bootCtx, { args: [], exit: () => {} }) }) } diff --git a/apps/web/package.json b/apps/web/package.json index a9c1e12c98..3c9aa151da 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 72b8317fe4..db33227043 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -44,17 +44,6 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { return page.getByRole('dialog', { name: '设置' }) } - /** Tokenize the lane-owned preset root after general aria normalization. */ - function withPresetRoot(snapshot: string): string { - const rootSuffix = `/${userRoot.split('/').pop()!}` - return snapshot.split('\n').map((line) => { - const rootStart = line.indexOf(rootSuffix) - if (rootStart === -1) return line - const pathStart = line.lastIndexOf(' ', rootStart) + 1 - return `${line.slice(0, pathStart)}{{presetRoot}}${line.slice(rootStart + rootSuffix.length)}` - }).join('\n') - } - beforeAll(async () => { userRoot = await realpath(await mkdtemp(join(tmpdir(), 'dsh-web-e2e-presets-'))) scaffold = await launchWebScaffold({ @@ -76,6 +65,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { afterAll(async () => { await browser?.close() await scaffold?.close() + await rm(userRoot, { recursive: true, force: true }) }) it('offers the roster with copy as the only way to create', async () => { @@ -146,8 +136,9 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { // The copy dialog is detached, so the settings dialog is the only one // left (it names itself via aria-labelledby, which a CSS attribute // selector cannot address). - const snapshot = withPresetRoot( - await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, { + replacements: [[userRoot, '{{presetRoot}}']], + }) await compareOrRefreshGolden(CREATED_EXPECTED, snapshot, MODE) expect(snapshot).toContain('{{presetRoot}}/my-agent') @@ -196,8 +187,9 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { await dialog.getByRole('button', { name: 'Agent 预设' }).click() await dialog.getByText('加载失败').first().waitFor({ timeout: 10_000 }) - const snapshot = withPresetRoot( - await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, { + replacements: [[userRoot, '{{presetRoot}}']], + }) await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE) // Both damage shapes surface as marked, unselectable, uncopyable cards // that still carry their metadata and the discovery-reported reason. diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 432c34a201..49e302c4c3 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -9,7 +9,7 @@ // // Zero model calls: no replay fixture mounts, so a stray stream fails loud. import { fileURLToPath } from 'node:url' -import { mkdir, mkdtemp, realpath, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -232,6 +232,7 @@ describe('web e2e: agent-preset selection', () => { afterAll(async () => { await browser?.close() await scaffold?.close() + await rm(presetRoot, { recursive: true, force: true }) }) it('offers the chip on the new-session screen, beside the workspace picker', async () => { diff --git a/apps/web/tests/chat-long-interactions.e2e.ts b/apps/web/tests/chat-long-interactions.e2e.ts index b8e9e4c042..17e639814c 100644 --- a/apps/web/tests/chat-long-interactions.e2e.ts +++ b/apps/web/tests/chat-long-interactions.e2e.ts @@ -90,7 +90,6 @@ async function openSeed(page: Page): Promise { const resultCount = await results.count() if (resultCount !== 1) throw new Error(`expected one seeded search result, received ${String(resultCount)}`) await results.click() - await results.click() await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }) .last().waitFor({ timeout: 30_000 }) await nextPaint(page) @@ -174,6 +173,10 @@ describe('web e2e: long Chat interaction contract', () => { it.skipIf(MODE === 'record')('keeps heterogeneous rows and their actions bound to exact semantic identities', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-long-interactions')) + await expect.poll( + () => scaffold.ctx.agents.get(SessionId(SESSION_ID)) !== undefined, + { timeout: 10_000 }, + ).toBe(true) const source = scaffold.ctx.agents.get(SessionId(SESSION_ID)) if (source === undefined) throw new Error('seeded long-history agent is not attached') diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts new file mode 100644 index 0000000000..47a7869b1c --- /dev/null +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -0,0 +1,431 @@ +// Web e2e gallery: every clickable link and artifact form the chat renders, +// in one settled keyless turn — the regression anchor for unifying link +// styles. One fixture turn produces: +// - prose: Markdown link, reference-style link, mailto link, inline-code URL, +// produced-file mention, plus inert contrasts (ambiguous basename, unwritten +// file, command code, URL-with-flags code, javascript: destination, +// footnote superscript, remote image, fenced code block with its copy chrome) +// - artifacts: seven produced files (chips overflow into the "+N" remainder +// and the show-in-folder affordance) with a failed write excluded +// - tool rows: write/edit/str_replace_editor/read file links, a failure row +// without one, and — expanded — the web-search source links (one non-http +// source stays inert) and answer link, the web-fetch URL, the read card's +// inert path label and fold toggle, the diff card's inert path header, the +// grep card's fold-only file headers, a failing bash card's exit status, and +// a generic tool card's IN/OUT surfaces. +// Image thumbnails and subagent rows stay out: the first needs real attachment +// bytes (image-display.expected.e2e.ts owns that route) and the second needs a +// child session (subagent-conversation owns it). +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + captureStableAria, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/clickable-links-gallery', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./expected/clickable-links-gallery/ui.expected.md', import.meta.url)) +// The golden holds the show-in-folder affordance; pin the native-opener +// capability so headless Linux CI and desktop developer hosts expose the same +// UI branch (same pin as produced-files.e2e.ts, whose overlay this shares). +const OVERLAY = fileURLToPath(new URL('./produced-files.overlay.yml', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'clickable-links-gallery-web-e2e' +const DONE = 'LINK_GALLERY_DONE' + +const GUIDE_URL = 'https://docs.example.test/guide' +const API_URL = 'https://docs.example.test/api' +const RELEASES_URL = 'https://docs.example.test/releases' +const MAILTO_URL = 'mailto:owner@example.test' +const SOURCE_URL = 'https://docs.example.test/links' +const INERT_SOURCE_URL = 'ftp://mirror.example.test/spec' +const FETCH_URL = 'https://docs.example.test/tokens' + +/** One-part text content for a built message. */ +function text(value: string): { type: 'text'; text: string }[] { + return [{ type: 'text', text: value }] +} + +/** A settled root tool call: its call event arguments plus result content/meta. */ +interface GalleryCall { + name: string + args: Record + result: string + meta?: JsonValue + isError?: true +} + +/** The five successful writes; docs/press.md and src/tokens.css join via other tools. */ +const WRITES = ['site/report.html', 'a/style.css', 'b/style.css', 'site/index.html', 'site/app.js'] + +/** The gallery turn's tool calls, in surface order. */ +const CALLS: GalleryCall[] = [ + ...WRITES.map(path => ({ + name: 'write', + args: { file_path: path, content: `content of ${path}\n` }, + result: `Created ${path}`, + })), + { + name: 'edit', + args: { + file_path: 'src/tokens.css', + old_string: '--inline-code: #EBEEF2;', + new_string: '--inline-code: #F5F5F5;', + }, + result: 'Edited src/tokens.css', + meta: { + diffs: [{ + path: 'src/tokens.css', + oldText: '--inline-code: #EBEEF2;\n', + newText: '--inline-code: #F5F5F5;\n', + }], + }, + }, + { + name: 'str_replace_editor', + args: { command: 'create', path: 'docs/press.md', file_text: '# Press kit\n' }, + result: 'Created docs/press.md', + meta: { diffs: [{ path: 'docs/press.md', oldText: null, newText: '# Press kit\n' }] }, + }, + { + name: 'write', + args: { file_path: 'c/broken.css', content: 'nope\n' }, + result: 'permission denied: c/broken.css', + isError: true, + }, + { + name: 'read', + args: { file_path: 'docs/guide.md' }, + // The read card validates the model-facing envelope, not just the meta: + // without // the row falls back to the generic card. + result: [ + 'docs/guide.md', + 'file', + '', + ...Array.from({ length: 12 }, (_, index) => + index === 0 ? '# Link style guide' : `guide line ${String(index + 1)}`), + '', + ].join('\n'), + meta: { + path: 'docs/guide.md', + offset: 1, + lines: Array.from({ length: 12 }, (_, index) => ({ + number: index + 1, + text: index === 0 ? '# Link style guide' : `guide line ${String(index + 1)}`, + })), + totalLines: 12, + }, + }, + { + name: 'grep', + args: { pattern: 'linkColor' }, + result: 'nine matches across three files', + meta: { + shape: 'matches', + files: ['a/style.css', 'b/style.css', 'src/tokens.css'].map(path => ({ + path, + matches: [3, 7, 11].map(lineNumber => ({ lineNumber, line: ' color: var(--linkColor);' })), + })), + truncated: false, + total: 9, + }, + }, + { + name: 'glob', + args: { pattern: '**/*.css' }, + result: 'a/style.css\nb/style.css\nsrc/tokens.css', + meta: { shape: 'paths', paths: ['a/style.css', 'b/style.css', 'src/tokens.css'], truncated: false, total: 3 }, + }, + { + name: 'bash', + args: { command: 'ls site', description: 'List the built site' }, + result: 'report.html\nindex.html\napp.js\n', + }, + { + name: 'bash', + args: { command: 'pnpm run lint', description: 'Run the lint gate', workdir: 'site' }, + result: 'style.css: unexpected hex literal\n[exit code: 1]', + }, + { + name: 'web_search', + args: { queries: ['clickable link styles', 'produced files ui'] }, + result: 'Two sources found.', + meta: { + truncated: false, + answer: `Unify links per [the guide](${GUIDE_URL}).`, + sources: [ + { url: SOURCE_URL, title: 'Link styles reference', snippet: 'One cursor token, one focus ring.' }, + { url: INERT_SOURCE_URL, title: 'Mirror spec (non-http)', snippet: 'A non-http source renders inert.' }, + ], + }, + }, + { + name: 'web_fetch', + args: { url: FETCH_URL }, + result: 'Design token reference page.', + meta: { url: FETCH_URL, statusCode: 200, truncated: false }, + }, + { + name: 'design_tokens_sync', + args: { source: 'design-platform.css', dryRun: false }, + result: '{"synced":true,"tokens":12}', + }, +] + +/** + * Build the settled gallery turn: every call above plus a link-dense closing prose. + * @param imageUrl - scaffold-hosted image the prose embeds (kept same-origin so + * the tripwire stays clean; the varying origin is tokenized in the golden). + */ +function galleryFixture(imageUrl: string): string { + const session = Session.create(SessionId('clickable-links-gallery-source')) + const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: text('Assemble the link gallery: write the report and styles, inspect the sources, and summarize.'), + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Clickable links gallery', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + const calls = CALLS.map((call, index) => ({ + ...call, + callId: ToolCallId(`gallery-${String(index)}`), + argsJson: JSON.stringify(call.args), + })) + session.append('assistant/message', { + stream: [], + turn: 1, + step: 1, + message: createAssistantMessage({ + content: calls.map(call => ({ + type: 'tool-call' as const, + id: call.callId, + name: call.name, + arguments: call.argsJson, + })), + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + for (const call of calls) { + const source = session.append('tool/call', { + turn: 1, + step: 1, + callId: call.callId, + name: call.name, + arguments: call.argsJson, + }) + session.append('tool/result', { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId: call.callId, + content: text(call.result), + isError: call.isError === true, + }), + ...(call.meta === undefined ? {} : { meta: call.meta }), + }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] }) + } + session.append('step/start', { turn: 1, step: 2 }) + session.append('assistant/message', { + stream: [], + turn: 1, + step: 2, + message: createAssistantMessage({ + content: text([ + '## Link gallery', + '', + `Docs: [style guide](${GUIDE_URL}) and \`${API_URL}\`; see [the release notes][rel], ` + + `contact [the maintainer](${MAILTO_URL}), and check the fine print[^1].`, + '', + `Inert contrasts: \`curl ${API_URL}\`, \`javascript:alert(1)\`, and \`pnpm run build\`.`, + '', + 'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.', + '', + `![Token preview](${imageUrl})`, + '', + '```css', + '--inline-code: #F5F5F5;', + '```', + '', + DONE, + '', + `[rel]: ${RELEASES_URL}`, + '', + '[^1]: Footnote references stay inert superscripts.', + ].join('\n')), + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + isSeeded: false, + delegationDepth: 0, + }), + ...session.snapshotEvents().map(event => JSON.stringify({ + ...event, + time: eventTimeOrigin + event.seq * 1_000, + })), + '', + ].join('\n') +} + +describe('web e2e: clickable links gallery', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let imageUrl: string + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) + imageUrl = new URL('/favicon.svg', scaffold.baseUrl).toString() + await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('renders every clickable link and artifact form of the settled turn', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-clickable-links-gallery')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + + // Markdown prose: plain, reference-style, and mailto links plus the + // inline-code URL are anchors; the URL-with-flags code, the javascript: + // destination, and plain command code stay inert. + const markdown = page.locator('[class*="markdown"]') + await expect.poll(() => markdown.locator(`a[href="${GUIDE_URL}"]`).count(), { timeout: 10_000 }).toBe(1) + expect(await markdown.locator(`a[href="${RELEASES_URL}"]`).count()).toBe(1) + expect(await markdown.locator(`a[href="${MAILTO_URL}"]`).count()).toBe(1) + const inlineCodeLink = markdown.locator(`code a[href="${API_URL}"]`) + expect(await inlineCodeLink.count()).toBe(1) + expect(await inlineCodeLink.getAttribute('target')).toBe('_blank') + expect(await page.getByText(`curl ${API_URL}`, { exact: true }).locator('a').count()).toBe(0) + expect(await page.getByText('javascript:alert(1)', { exact: true }).locator('a').count()).toBe(0) + expect(await markdown.locator(`img[src="${imageUrl}"]`).count()).toBe(1) + + // Produced files: one unique-basename mention links; the shared basename + // and the unwritten file stay inert code. Seven produced paths overflow + // the chip row; the failed write joins neither surface. + const mentions = markdown.locator('code button') + expect(await mentions.count()).toBe(1) + expect(await mentions.first().getAttribute('title')).toBe('site/report.html') + expect(await page.getByText('Produced', { exact: true }).count()).toBe(1) + expect(await page.locator('[class*="centerCol"] button[aria-label^="Open "]').count()).toBeGreaterThanOrEqual(5) + expect(await page.locator('button[aria-label="Open c/broken.css"]').count()).toBe(0) + + // Tool rows: five writes, the edit, and the read carry the dotted file + // link; the failed write row does not, and neither does str_replace_editor + // — TOOL_VARIANTS has no entry for it, so it falls to the generic row with + // no openable path even though its create still joins the produced chips. + expect(await page.locator('button[class*="fileLink"]').count()).toBe(7) + + // Expanded cards. The turn-process group collapses a multi-call turn, so + // it opens first. Rows expand via a right-edge click: the row center can + // land on the nested fileLink button, which would hand the path to the + // Host's opener. + await page.getByRole('button', { name: `${String(CALLS.length)} tool calls` }).click() + for (const row of [ + /^Search clickable link styles/, + /^Fetch /, + /^Read docs\/guide\.md/, + /^Edit src\/tokens\.css/, + /^Grep linkColor/, + /Run the lint gate|pnpm run lint/, + ]) { + const toggle = page.getByRole('button', { name: row }).first() + await toggle.waitFor({ timeout: 10_000 }) + const box = await toggle.boundingBox() + await toggle.click(box === null ? {} : { position: { x: box.width - 8, y: box.height / 2 } }) + } + // Both generic rows (the unclassified str_replace_editor and the unknown + // design_tokens_sync) expand to their IN/OUT surfaces. + const genericRows = page.getByRole('button', { name: /^Tool call/ }) + for (let index = 0; index < await genericRows.count(); index += 1) { + const toggle = genericRows.nth(index) + const box = await toggle.boundingBox() + await toggle.click(box === null ? {} : { position: { x: box.width - 8, y: box.height / 2 } }) + } + const sourceLink = page.locator(`a[href="${SOURCE_URL}"]`) + await expect.poll(() => sourceLink.count(), { timeout: 10_000 }).toBe(1) + expect(await page.locator('a[href^="ftp:"]').count()).toBe(0) + expect(await page.locator(`a[href="${FETCH_URL}"]`).count()).toBe(1) + expect(await page.locator(`a[href="${GUIDE_URL}"]`).count()).toBe(2) + + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + .split(imageUrl).join('{{imageUrl}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + + // The link language itself — ARIA records none of it, so pin the computed + // styles: link-blue 500-weight text, no underline at rest, dotted underline + // on hover, and a leading currentColor glyph. Light theme, so the link + // alias resolves to deepseek-500. + const LINK_BLUE = 'rgb(65, 118, 230)' + const styleOf = async (target: ReturnType, property: string): Promise => + target.evaluate((el, p) => getComputedStyle(el).getPropertyValue(p), property) + const guideLink = markdown.locator(`a[href="${GUIDE_URL}"]`).first() + const chip = page.locator('button[aria-label="Open site/report.html"]').first() + for (const [name, link] of [ + ['markdown anchor', guideLink], + ['file mention', mentions.first()], + ['search source', sourceLink.first()], + ['fetch url', page.locator(`a[href="${FETCH_URL}"]`).first()], + ['produced chip', chip], + ] as const) { + expect.soft(await styleOf(link, 'color'), `${name} color`).toBe(LINK_BLUE) + expect.soft(await styleOf(link, 'font-weight'), `${name} weight`).toBe('500') + expect.soft(await styleOf(link, 'text-decoration-line'), `${name} at rest`).toBe('none') + expect.soft(await link.locator('svg').count(), `${name} glyph`).toBe(1) + } + await guideLink.hover() + expect(await styleOf(guideLink, 'text-decoration-line')).toBe('underline') + expect(await styleOf(guideLink, 'text-decoration-style')).toBe('dotted') + expect(await styleOf(guideLink, 'text-underline-offset')).toBe('3px') + await chip.hover() + expect(await styleOf(chip, 'text-decoration-style')).toBe('dotted') + expect(await styleOf(chip, 'background-color')).toBe('rgba(0, 0, 0, 0)') + // The excluded grey affordance: tool-row file links keep their own color. + expect(await styleOf(page.locator('button[class*="fileLink"]').first(), 'color')).not.toBe(LINK_BLUE) + }, 90_000) +}) diff --git a/apps/web/tests/command-image-envelope.expected.e2e.ts b/apps/web/tests/command-image-envelope.expected.e2e.ts index 48d5f8f407..36c25232fd 100644 --- a/apps/web/tests/command-image-envelope.expected.e2e.ts +++ b/apps/web/tests/command-image-envelope.expected.e2e.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom -// The command image-attachment envelope over the BUILT client graph (real +// The command attachment envelope over the BUILT client graph (real // bundles via AppWebEntry, keyless fixture Connection RPC): an enter -// submission carrying composer images resolves only through a command whose -// descriptor declares `input.images`. A non-declaring command refuses with +// submission carrying composer attachments resolves only through a command whose +// descriptor declares `input.attachments`. A non-declaring command refuses with // one composer error banner and everything retained; a declaring command // consumes the images — serialized through the real draft-image chain into // the commands/execute payload — and clears the composer on success, including @@ -47,7 +47,7 @@ async function pasteImage(textarea: HTMLElement, name: string): Promise { }, }) await waitFor(() => { - const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]') if (rail === null) throw new Error('attachment rail missing') expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toContain(name) }, { timeout: 5_000 }) @@ -58,7 +58,7 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft const textarea = await freshComposer() await pasteImage(textarea, 'ref.png') - // /echo is a leadingInput fixture command without `input.images`. + // /echo is a leadingInput fixture command without `input.attachments`. await pasteText(textarea, '/echo hello') fireEvent.keyDown(textarea, { key: 'Enter' }) @@ -66,16 +66,16 @@ it('refuses an image-carrying submit to a non-declaring command and keeps draft // failures; session activity remains on its separate status live region. const notice = await waitFor(() => { const el = [...document.querySelectorAll('[role="alert"]')] - .find(candidate => candidate.textContent?.includes('image attachments') ?? false) + .find(candidate => candidate.textContent?.includes('attachments') ?? false) if (el === undefined) throw new Error('composer refusal banner missing') return el }, { timeout: 5_000 }) - expect(notice.textContent).toBe('/echo does not accept image attachments; remove them first') + expect(notice.textContent).toBe('/echo does not accept attachments; remove them first') expect([...document.querySelectorAll('[role="status"]')] - .some(candidate => candidate.textContent?.includes('image attachments') ?? false)).toBe(false) + .some(candidate => candidate.textContent?.includes('attachments') ?? false)).toBe(false) // The whole envelope is retained: draft text and the rail thumbnail. await waitFor(() => { expect(textarea.textContent).toBe('/echo hello') }) - const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]') expect([...(rail?.querySelectorAll('img') ?? [])].map(img => img.getAttribute('alt'))).toEqual(['ref.png']) }) @@ -84,14 +84,14 @@ it('consumes images through a declaring command and clears the composer on succe const textarea = await freshComposer() await pasteImage(textarea, 'goal-ref.png') - // /goal declares `input.images` in the fixture catalog; the claim submit + // /goal declares `input.attachments` in the fixture catalog; the claim submit // serializes the pasted bytes and the fixture executor admits them. await pasteText(textarea, '/goal rebuild the cathedral') fireEvent.keyDown(textarea, { key: 'Enter' }) await waitFor(() => { expect(textarea.textContent).toBe('') - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull() }, { timeout: 5_000 }) }) @@ -107,7 +107,7 @@ it('submits a bare /plan with an image as an image-only plan request', async () await waitFor(() => { expect(textarea.textContent).toBe('') - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull() }, { timeout: 5_000 }) expect([...document.querySelectorAll('[role="alert"]')] .some(candidate => candidate.textContent?.includes('/plan') ?? false)).toBe(false) diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md new file mode 100644 index 0000000000..4babf591db --- /dev/null +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -0,0 +1,204 @@ +- banner: + - navigation "Session hierarchy": + - button "Clickable links gallery" [disabled] + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: "Assemble the link gallery: write the report and styles, inspect the sources, and summarize. {{clock}}" +- button "Copy": + - img +- button "16 tool calls" [expanded]: + - text: 16 tool calls + - img +- button "Write site/report.html +1 -0": + - img + - img + - text: Write + - button "site/report.html" + - text: +1 -0 +- button "Write a/style.css +1 -0": + - img + - img + - text: Write + - button "a/style.css" + - text: +1 -0 +- button "Write b/style.css +1 -0": + - img + - img + - text: Write + - button "b/style.css" + - text: +1 -0 +- button "Write site/index.html +1 -0": + - img + - img + - text: Write + - button "site/index.html" + - text: +1 -0 +- button "Write site/app.js +1 -0": + - img + - img + - text: Write + - button "site/app.js" + - text: +1 -0 +- button "Edit src/tokens.css +1 -1" [expanded]: + - img + - text: Edit + - button "src/tokens.css" + - text: +1 -1 +- button "Copy" +- text: "src/tokens.css - --inline-code: #EBEEF2; + --inline-code: #F5F5F5; └ +1 -1 · 1 file" +- button "Inspect" +- button "Tool call str_replace_editor · create" [expanded]: + - img + - text: Tool call str_replace_editor · create +- text: "IN { \"command\": \"create\", \"path\": \"docs/press.md\", \"file_text\": \"# Press kit\\n\" } OUT Created docs/press.md" +- button "Inspect" +- text: Failed +- 'button "Write permission denied: c/broken.css"': + - img + - text: "Write permission denied: c/broken.css" +- button "Read docs/guide.md" [expanded]: + - img + - text: Read + - button "docs/guide.md" +- text: docs/guide.md +- button "Copy" +- text: "# Link style guide guide line 2 guide line 3 guide line 4" +- button "Expand 4 more lines": … 4 more lines +- text: guide line 9 guide line 10 guide line 11 guide line 12 +- button "Inspect" +- button "Grep linkColor" [expanded]: + - img + - text: Grep linkColor +- text: 9 matches · 3 files +- button "Copy" +- button "a/style.css 3" [expanded] +- text: "3: color: var(--linkColor); 7: color: var(--linkColor); 11: color: var(--linkColor);" +- button "Expand 4 more result lines": … 4 more lines +- button "src/tokens.css 3" [expanded] +- text: "3: color: var(--linkColor); 7: color: var(--linkColor); 11: color: var(--linkColor);" +- button "Inspect" +- button "Glob **/*.css": + - img + - img + - text: Glob **/*.css +- button "Bash List the built site": + - img + - img + - text: Bash List the built site +- button "Failed Bash Run the lint gate" [expanded]: + - img + - text: Failed Bash Run the lint gate +- text: Failed site pnpm run lint exit code 1 +- button "Copy" +- text: "style.css: unexpected hex literal" +- button "Inspect" +- button "Search clickable link styles, produced files ui" [expanded]: + - img + - text: Search clickable link styles, produced files ui +- paragraph: + - text: Unify links per + - link "the guide": + - /url: https://docs.example.test/guide + - text: . +- list: + - listitem: + - link "Link styles reference": + - /url: https://docs.example.test/links + - text: One cursor token, one focus ring. + - listitem: Mirror spec (non-http) A non-http source renders inert. +- button "Inspect" +- button "Fetch https://docs.example.test/tokens" [expanded]: + - img + - text: Fetch https://docs.example.test/tokens +- link "https://docs.example.test/tokens": + - /url: https://docs.example.test/tokens +- text: HTTP 200 +- button "Inspect" +- button "Tool call design_tokens_sync · design-platform.css" [expanded]: + - img + - text: Tool call design_tokens_sync · design-platform.css +- text: "IN { \"source\": \"design-platform.css\", \"dryRun\": false } OUT {\"synced\":true,\"tokens\":12}" +- button "Inspect" +- heading "Link gallery" [level=2] +- paragraph: + - text: "Docs:" + - link "style guide": + - /url: https://docs.example.test/guide + - text: and + - code: + - link "https://docs.example.test/api": + - /url: https://docs.example.test/api + - text: ; see + - link "the release notes": + - /url: https://docs.example.test/releases + - text: ", contact" + - link "the maintainer": + - /url: mailto:owner@example.test + - text: ", and check the fine print" + - superscript: "1" + - text: . +- paragraph: + - text: "Inert contrasts:" + - code: curl https://docs.example.test/api + - text: "," + - code: javascript:alert(1) + - text: ", and" + - code: pnpm run build + - text: . +- paragraph: + - text: Wrote + - code: + - button "Open site/report.html": report.html + - text: plus two + - code: style.css + - text: copies; + - code: notes.md + - text: untouched. +- paragraph: + - img "Token preview" +- text: css +- button "Copy" +- code: "--inline-code: #F5F5F5;" +- paragraph: LINK_GALLERY_DONE +- heading "Footnotes" [level=2] +- list: + - listitem: + - paragraph: Footnote references stay inert superscripts. ↩ +- text: Produced +- button "Open site/report.html": report.html +- button "Open a/style.css": style.css +- button "Open b/style.css": style.css +- button "Open site/index.html": index.html +- button "Open site/app.js": app.js +- button "Open src/tokens.css": tokens.css +- text: + 1 file +- button "Show in folder" +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} +- button "Back to bottom": + - img +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- button "Add attachment": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}} diff --git a/apps/web/tests/expected/file-upload-round/draft.expected.md b/apps/web/tests/expected/file-upload-round/draft.expected.md new file mode 100644 index 0000000000..aa5ef30e82 --- /dev/null +++ b/apps/web/tests/expected/file-upload-round/draft.expected.md @@ -0,0 +1,9 @@ +# Mixed composer attachment rail + +- selection order: poem.txt > reference-1.png > reference-2.png > reference-3.png > reference-4.png > reference-5.png > reference-6.png > reference-7.png > reference-8.png > reference-9.png > reference-10.png +- one attachment group: true +- all cards share one row: true +- every card is 64px high: true +- the file card is wider than an image: true +- overflowing cards scroll horizontally: true +- the rail does not wrap: true diff --git a/apps/web/tests/expected/file-upload-round/history.expected.md b/apps/web/tests/expected/file-upload-round/history.expected.md new file mode 100644 index 0000000000..1427ef8004 --- /dev/null +++ b/apps/web/tests/expected/file-upload-round/history.expected.md @@ -0,0 +1,10 @@ +# Mixed history attachment flow + +- source order: poem.txt > reference-1.png +- one attachment group: true +- file and image share one row: true +- both cards are 64px high: true +- the image is a 64px tile: true +- the file card is wider than the image: true +- the group wraps when needed: true +- the group is right-aligned: true diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index 9f350d3f40..521443e706 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -51,6 +51,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current github-webhook-review-test/reply": - text: github-webhook-review-test/reply diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index b57f39a3c4..089e6206b4 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -43,6 +43,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current github-webhook-review-test/reply": - text: github-webhook-review-test/reply diff --git a/apps/web/tests/expected/goal-command-presentation/ui.expected.md b/apps/web/tests/expected/goal-command-presentation/ui.expected.md index 7567b6422f..5a991063cd 100644 --- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md +++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md @@ -17,6 +17,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index 0cf58cabfa..bff877489a 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -51,6 +51,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index c4fa04a381..307ea19de3 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -30,6 +30,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index 63013648ea..4f0d02556c 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -42,6 +42,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index bf0ed86acf..3898fca4b0 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -46,6 +46,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 6b17de40cb..192e5157d0 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -16,6 +16,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md b/apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md new file mode 100644 index 0000000000..798d9510c4 --- /dev/null +++ b/apps/web/tests/expected/skill-invocation-policy/menu-fuzzy.expected.md @@ -0,0 +1,3 @@ +- listbox "Trigger suggestions": + - text: Skills + - option "policy-user-only user-only · Available only to user invocation" [selected] diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index 6d615be5d1..a99a0b3fa3 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -43,6 +43,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index 6285f1f4a9..604c97db5f 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index b0aeaeb11c..4341818041 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -462,6 +462,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index fd3d079115..fe5e529df5 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -34,6 +34,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index 326c57de80..6939a3c0ba 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -53,6 +53,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index 88f6522fd1..39547a7fa7 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -41,6 +41,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/file-upload-round.e2e.ts b/apps/web/tests/file-upload-round.e2e.ts new file mode 100644 index 0000000000..409c46ca6c --- /dev/null +++ b/apps/web/tests/file-upload-round.e2e.ts @@ -0,0 +1,283 @@ +// Web e2e scenario: generic file upload round trip. A real chromium picks a +// file through the composer paperclip input; the upload RPC stores the exact +// bytes below the scaffold's isolated DSH_HOME, the prompt cites the staged +// reference, request assembly projects the file block to handle text, and the +// model (replayed or live) reads the saved copy with the REAL read tool. The +// content-addressed store makes the saved path identical across record and +// replay once the workspace cwd is tokenized, so the recorded read arguments +// replay verbatim against a freshly re-uploaded object. +// Record: DSH_SNAPSHOT=record rewrites session.v2.jsonl, then a keyless +// DSH_SNAPSHOT=refresh regenerates ui.expected.md. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/file-upload-round', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/session.v2.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/ui.expected.md', import.meta.url)) +const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/trajectory.expected.md', import.meta.url)) +const OVERRIDE = fileURLToPath(new URL('../../../snapshots/web/file-upload-round/replay.override.json', import.meta.url)) +const DRAFT_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/draft.expected.md', import.meta.url)) +const HISTORY_EXPECTED = fileURLToPath(new URL('./expected/file-upload-round/history.expected.md', import.meta.url)) +const IMAGE_FIXTURE = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url)) +const MODE = webSnapshotMode() + +/** The uploaded fixture file: constant bytes so record and replay share one content digest. */ +const FILE_NAME = 'poem.txt' +const FILE_TEXT = 'UPLOAD_ROUND_OK\n' +const PROMPT = 'Read the attached file with the read tool, reply with exactly the single word it contains, and stop.' +const IMAGE_NAMES = Array.from({ length: 10 }, (_unused, index) => `reference-${String(index + 1)}.png`) + +/** Browser-measured relations for the mixed composer attachment rail. */ +interface DraftRailGeometry { + readonly order: readonly string[] + readonly oneGroup: boolean + readonly oneRow: boolean + readonly equalHeight: boolean + readonly fileWider: boolean + readonly horizontalOverflow: boolean + readonly noWrap: boolean +} + +/** Render stable relations instead of platform-dependent absolute coordinates. */ +function renderDraftRailGeometry(geometry: DraftRailGeometry): string { + return [ + '# Mixed composer attachment rail', + '', + `- selection order: ${geometry.order.join(' > ')}`, + `- one attachment group: ${String(geometry.oneGroup)}`, + `- all cards share one row: ${String(geometry.oneRow)}`, + `- every card is 64px high: ${String(geometry.equalHeight)}`, + `- the file card is wider than an image: ${String(geometry.fileWider)}`, + `- overflowing cards scroll horizontally: ${String(geometry.horizontalOverflow)}`, + `- the rail does not wrap: ${String(geometry.noWrap)}`, + ].join('\n') +} + +/** Browser-measured relations for one durable mixed-attachment message. */ +interface HistoryAttachmentGeometry { + readonly order: readonly string[] + readonly oneGroup: boolean + readonly oneRow: boolean + readonly equalHeight: boolean + readonly imageIsTile: boolean + readonly fileWider: boolean + readonly wrapsWhenNeeded: boolean + readonly rightAligned: boolean +} + +/** Render stable history-layout relations instead of absolute coordinates. */ +function renderHistoryAttachmentGeometry(geometry: HistoryAttachmentGeometry): string { + return [ + '# Mixed history attachment flow', + '', + `- source order: ${geometry.order.join(' > ')}`, + `- one attachment group: ${String(geometry.oneGroup)}`, + `- file and image share one row: ${String(geometry.oneRow)}`, + `- both cards are 64px high: ${String(geometry.equalHeight)}`, + `- the image is a 64px tile: ${String(geometry.imageIsTile)}`, + `- the file card is wider than the image: ${String(geometry.fileWider)}`, + `- the group wraps when needed: ${String(geometry.wrapsWhenNeeded)}`, + `- the group is right-aligned: ${String(geometry.rightAligned)}`, + ].join('\n') +} + +describe('web e2e: generic file upload through the real assembly', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + compareReplaySession: true, + // The override rescripts the recorded read arguments with a + // `{{fromRequest:…}}` placeholder: the saved-copy path differs per run, + // and the live handle line in the request carries the current one. + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, replayOverride: OVERRIDE, paceMs: 15 }), + }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + await page.setViewportSize({ width: 900, height: 900 }) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('uploads on pick, gates send on the staged receipt, and settles the turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-drive')) + if (MODE !== 'record') { + // Drift guard: the committed fixture must carry exactly the drive prompt. + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('[data-composer-input]').first() + await input.waitFor({ timeout: 10_000 }) + const modelTrigger = page.getByRole('button', { name: /^Select model, current/ }) + await modelTrigger.click() + await page.getByRole('menuitem', { name: /^Model\b/ }).click() + await page.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash-Vision-Exp' }).click() + await expect.poll(() => modelTrigger.getAttribute('aria-label'), { timeout: 10_000 }) + .toContain('DeepSeek-V4-Flash-Vision-Exp') + const imageBytes = await readFile(IMAGE_FIXTURE) + // Pick through the composer's hidden file input: the upload RPC runs + // immediately and the pending card appears before any prompt is typed. + await page.locator('input[type="file"]').setInputFiles([ + { name: FILE_NAME, mimeType: 'text/plain', buffer: Buffer.from(FILE_TEXT) }, + ...IMAGE_NAMES.map(name => ({ name, mimeType: 'image/png', buffer: imageBytes })), + ]) + await page.getByTitle(FILE_NAME).waitFor({ timeout: 10_000 }) + const rail = page.getByRole('group', { name: 'Pending attachments' }) + await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 }) + .toBe(IMAGE_NAMES.length + 1) + const geometry = await rail.evaluate((element): DraftRailGeometry => { + const cards = [...element.children] as HTMLElement[] + const boxes = cards.map(card => card.getBoundingClientRect()) + const imageWidth = boxes[cards.findIndex(card => card.querySelector('img') !== null)]?.width ?? 0 + const fileWidth = boxes[cards.findIndex(card => card.querySelector('[title="poem.txt"]') !== null)]?.width ?? 0 + return { + order: cards.map(card => card.querySelector('img')?.getAttribute('alt') + ?? card.querySelector('[title]')?.title ?? ''), + oneGroup: document.querySelectorAll('[role="group"][aria-label="Pending attachments"]').length === 1, + oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5), + equalHeight: boxes.every(box => Math.abs(box.height - 64) < 0.5), + fileWider: fileWidth > imageWidth, + horizontalOverflow: element.scrollWidth > element.clientWidth, + noWrap: getComputedStyle(element).flexWrap === 'nowrap', + } + }) + await compareOrRefreshGolden(DRAFT_EXPECTED, renderDraftRailGeometry(geometry), MODE) + for (const name of IMAGE_NAMES.slice(1)) { + await page.getByRole('button', { name: `Remove image ${name}` }).click({ force: true }) + } + await expect.poll(() => rail.locator(':scope > *').count(), { timeout: 10_000 }).toBe(2) + await input.fill(PROMPT) + // Send unlocks only after the upload receipt lands (the staged file gate). + const send = page.getByRole('button', { name: 'Send message' }) + await send.waitFor({ state: 'visible', timeout: 15_000 }) + await expect.poll(() => send.isEnabled(), { timeout: 15_000 }).toBe(true) + const settled = scaffold.whenTurnSettled() + await send.click() + const restoreDeadline = Date.now() + 10_000 + let retainedCards = 0 + while (retainedCards === 0 + && !sessionEvents.some(event => event.type === 'turn/start') + && Date.now() < restoreDeadline) { + await page.waitForTimeout(100) + retainedCards = await rail.locator(':scope > *').count() + } + if (retainedCards !== 0) { + const feedback = await page.locator('[role="alert"], [role="status"]').allTextContents() + throw new Error(`submission retained ${String(retainedCards)} draft cards; feedback=${JSON.stringify(feedback)}; events=${sessionEvents.map(event => event.type).join(',')}`) + } + const sessionId = await settled + if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE) + }, 200_000) + + it.skipIf(MODE === 'record')('persists the file block and reads the stored copy with the real read tool', () => { + const userMessage = sessionEvents.find( + (event): event is Extract => + event.type === 'user/message' && event.data.source.kind === 'user', + ) + if (userMessage === undefined) throw new Error('the replayed turn recorded no user message') + const fileBlock = userMessage.data.content.find(block => block.type === 'file') + if (fileBlock?.type !== 'file') throw new Error('the user message carries no file block') + expect(fileBlock.attachment.name).toBe(FILE_NAME) + expect(fileBlock.attachment.bytes).toBe(Buffer.byteLength(FILE_TEXT)) + expect(String(fileBlock.attachment.attachmentId)).toMatch(/^sha256:[0-9a-f]{64}$/) + + const readCall = sessionEvents.find( + (event): event is Extract => + event.type === 'tool/call' && event.data.name === 'read', + ) + if (readCall === undefined) throw new Error('the replayed turn did not call the read tool') + expect(readCall.data.arguments).toContain(FILE_NAME) + const readResult = sessionEvents.find( + (event): event is Extract => + event.type === 'tool/result' && event.data.message.source.callId === readCall.data.callId, + ) + if (readResult === undefined) throw new Error('the read call produced no durable result') + const content = readResult.data.message.content[0] + expect(content.isError).toBe(false) + expect(content.content.filter(block => block.type === 'text').map(block => block.text).join('')) + .toContain('UPLOAD_ROUND_OK') + + const turnEnds = sessionEvents.filter(event => event.type === 'turn/end') + expect(turnEnds.length).toBe(1) + expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed') + }) + + it.skipIf(MODE === 'record')('renders the durable file card beside the settled answer', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-aria')) + await expect.poll(() => page.getByText('UPLOAD_ROUND_OK', { exact: false }).count(), { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1) + await page.getByTitle(FILE_NAME).first().waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('keeps a mixed durable message in one ordered wrapping attachment flow', async () => { + const groups = page.locator('[data-message-attachments]') + await expect.poll(() => groups.count(), { timeout: 10_000 }).toBe(1) + const geometry = await groups.first().evaluate((element): HistoryAttachmentGeometry => { + const cards = [...element.children] as HTMLElement[] + const renderedCards = cards.map((card) => { + const box = card.getBoundingClientRect() + return box.width === 0 && box.height === 0 && card.firstElementChild instanceof HTMLElement + ? card.firstElementChild + : card + }) + const boxes = renderedCards.map(card => card.getBoundingClientRect()) + const imageIndex = cards.findIndex(card => card.querySelector('img') !== null) + const fileIndex = cards.findIndex(card => card.getAttribute('title') === 'poem.txt') + const imageBox = boxes[imageIndex] + const fileBox = boxes[fileIndex] + return { + order: cards.map(card => card.getAttribute('title') ?? card.querySelector('img')?.getAttribute('alt') ?? ''), + oneGroup: document.querySelectorAll('[data-message-attachments]').length === 1, + oneRow: boxes.every(box => Math.abs(box.top - (boxes[0]?.top ?? box.top)) < 0.5), + equalHeight: boxes.every(box => Math.abs(box.height - 64) < 0.5), + imageIsTile: imageBox !== undefined && Math.abs(imageBox.width - 64) < 0.5, + fileWider: fileBox !== undefined && imageBox !== undefined && fileBox.width > imageBox.width, + wrapsWhenNeeded: getComputedStyle(element).flexWrap === 'wrap', + rightAligned: getComputedStyle(element).justifyContent === 'flex-end', + } + }) + await compareOrRefreshGolden(HISTORY_EXPECTED, renderHistoryAttachmentGeometry(geometry), MODE) + }) + + it.skipIf(MODE === 'record')('marks the durable file in Trajectory without copying the Chat card', async () => { + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) + await page.getByRole('row', { name: /Files ×1/ }).waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria( + page, + '[data-trajectory-row-key][aria-label*="Files ×1"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.v2.jsonl', 'replay.override.json', 'ui.expected.md', 'trajectory.expected.md', + ]) + }) +}) diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts index 1139b878c6..70ad37a799 100644 --- a/apps/web/tests/goal-command-presentation.e2e.ts +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -67,17 +67,26 @@ describe('web e2e: /goal human transcript presentation', () => { const typography = await commandInput.evaluate((element) => { const bubble = element.firstElementChild?.firstElementChild if (!(bubble instanceof HTMLElement)) throw new Error('command input bubble is missing') + const chip = bubble.querySelector('[data-ref-chip="command"]') + if (!(chip instanceof HTMLElement)) throw new Error('command chip is missing') const rootStyle = getComputedStyle(element) const bubbleStyle = getComputedStyle(bubble) + const chipStyle = getComputedStyle(chip) return { fontFamily: bubbleStyle.fontFamily, parentFontFamily: rootStyle.fontFamily, fontSize: bubbleStyle.fontSize, lineHeight: bubbleStyle.lineHeight, + chipText: chip.textContent, + chipFontFamily: chipStyle.fontFamily, + chipFontSize: chipStyle.fontSize, } }) - expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px' }) - expect(typography.fontFamily).not.toBe(typography.parentFontFamily) + expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px', chipText: '/goal', chipFontSize: '14px' }) + // The bubble reads in the body face like a user bubble; only the command + // chip carries the code face that marks the echoed token as a command. + expect(typography.fontFamily).toBe(typography.parentFontFamily) + expect(typography.chipFontFamily).not.toBe(typography.fontFamily) const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' }) await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1) expect(await resultRow.getByText('goal', { exact: true }).count()).toBe(1) diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index c2a5e129f0..34481c4a17 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -106,7 +106,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy // The rail is an accessible group holding the draft thumbnail (queried via // DOM: jsdom's a11y-visibility computation hides the composer subtree). const rail = await waitFor(() => { - const el = document.querySelector('[role="group"][aria-label="Pending images"]') + const el = document.querySelector('[role="group"][aria-label="Pending attachments"]') if (el === null) throw new Error('attachment rail missing') return el }, { timeout: 5_000 }) @@ -137,23 +137,19 @@ it('accepts pasted images into the composer rail in order and removes them', asy if (remove.length !== 2) throw new Error('remove buttons missing') for (const button of remove) fireEvent.click(button) await waitFor(() => { - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull() }) - // An unsupported file announces a transient toast (the inline strip is - // gone) and the banner dismisses itself after its hold-and-fade lifetime. + // A non-image paste follows the generic-file path and remains in the + // composer as a file card. fireEvent.paste(textarea, { clipboardData: { items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'notes.txt', { type: 'text/plain' }) }], getData: () => '', }, }) - const unsupportedMessage = 'Only PNG, JPG, WebP, and GIF images are supported' - const toast = await screen.findByText(unsupportedMessage) - expect(toast.closest('[role="alert"]')).not.toBeNull() - await waitFor(() => { - expect(screen.queryByText(unsupportedMessage)).toBeNull() - }, { timeout: 6_000 }) + const files = await screen.findByRole('group', { name: 'Pending attachments' }) + expect(files.textContent).toContain('notes.txt') }) it('accepts a whole-page drop under the limits-labeled overlay and refuses an over-limit batch at intake', async () => { @@ -178,15 +174,15 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } fireEvent.dragEnter(document.body, { dataTransfer }) const overlay = await screen.findByRole('status') - expect(overlay.textContent).toContain('Drag images here to add them') + expect(overlay.textContent).toContain('Drag files or images here to add them') await waitFor(() => { - expect(overlay.textContent).toContain('Up to 20 images, 5MB each') + expect(overlay.textContent).toContain('Image limit: up to 20 images, 5MB each') }) // Dropping on the transcript area (not the composer card) lands in the rail. fireEvent.drop(document.body, { dataTransfer }) await waitFor(() => { - const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]') if (rail === null) throw new Error('attachment rail missing after page drop') expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['dropped.png']) }, { timeout: 5_000 }) @@ -206,7 +202,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov const limitMessage = 'A message can include up to 20 images' const banner = await screen.findByText(limitMessage) expect(banner.closest('[role="alert"]')).not.toBeNull() - const rail = document.querySelector('[role="group"][aria-label="Pending images"]') + const rail = document.querySelector('[role="group"][aria-label="Pending attachments"]') expect([...(rail?.querySelectorAll('img') ?? [])]).toHaveLength(1) }) @@ -233,7 +229,7 @@ it('renders a host dimension rejection with the projected 2000px limit', async ( }, }) await waitFor(() => { - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).not.toBeNull() }) fireEvent.keyDown(textarea, { key: 'Enter' }) @@ -245,5 +241,5 @@ it('renders a host dimension rejection with the projected 2000px limit', async ( "text": "Image sides must be at most 2000px; downscale it and try again", } `) - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).not.toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).not.toBeNull() }) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 24c40ea9ba..2e7553f65f 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -1,7 +1,8 @@ // Web e2e scenario: message IconActions + clocks. Cold-seeds a deterministic -// completed-turn-tail fork case (zero model calls) and pins the settled -// conversation aria after the footers are focus-revealed — the surface package -// jsdom tests cannot substitute for (docs/testing.md snapshot rule). +// completed-turn-tail fork case with an unchanged resume header (zero model +// calls) and pins the settled conversation aria after the footers are +// focus-revealed — the surface package jsdom tests cannot substitute for +// (docs/testing.md snapshot rule). import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -70,6 +71,10 @@ function completedTailFixture(raw: string): string { }, } }) + const inheritedHeader = kept.findLast(event => event.type === 'request/header') + if (inheritedHeader?.type !== 'request/header') { + throw new Error('borrowed recording has no request header') + } let seq = (kept.at(-1)?.seq ?? -1) + 1 let time = (kept.at(-1)?.time ?? -1) + 1 const at = (event: Record): { seq: number; time: number } & Record => ({ @@ -109,6 +114,7 @@ function completedTailFixture(raw: string): string { surfaceOp: 'append', }), at({ type: 'step/start', data: { turn: 2, step: 1 } }), + at({ type: 'request/header', data: { header: inheritedHeader.data.header, reason: 'resume' } }), at({ type: 'assistant/message', data: { @@ -149,6 +155,9 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await writeFile(join(sessionCwd, 'b.txt'), 'beta\n') const raw = completedTailFixture(await readFile(SEED, 'utf8')) expect(fixtureUserPrompts(raw), 'adapted seed must carry both prompts').toEqual([PROMPT, SECOND_PROMPT]) + expect(parseSeedFixture(raw).events.flatMap(event => event.type === 'request/header' + ? [event.data.reason] + : []), 'adapted seed must carry an unchanged resume header').toEqual(['initial', 'resume']) await seedSession(scaffold, raw, SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -172,6 +181,10 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await sessionRow.click() await expect.poll(() => page.getByText(MID_TURN_TEXT, { exact: true }).count(), { timeout: 15_000 }).toBe(1) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + await expect.poll( + () => page.getByRole('button', { name: 'System prompt', exact: true }).count(), + { timeout: 10_000 }, + ).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until // hover/focus-within). Branch renders only under assistant answers — user diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index bb9a6e9af3..4eab28afc7 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -208,14 +208,12 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await compareOrRefreshGolden(SEARCH_EXPECTED, snapshot, MODE) await result.click() - // Search navigation addresses the session, not a specific event, and the - // query remains until the user explicitly clears it. - await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL') + // Search navigation returns to the browser with the opened Session row exposed. + await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('') + const selectedRow = page.locator('[role="tree"][aria-label="Sessions"] [role="treeitem"][aria-selected="true"]') + await expect.poll(() => selectedRow.count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1) - await page.getByRole('button', { name: 'Clear search' }).click() - await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('') - await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) }, 90_000) it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index a6217bc5ff..ad59c14dc8 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -192,7 +192,18 @@ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') const REPLAY_PROVIDERS = [{ id: 'deepseek-official', name: 'DeepSeek', - models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }], + models: [ + { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 }, + { + id: 'deepseek-v4-flash-vision-exp', + name: 'DeepSeek-V4-Flash-Vision-Exp', + contextWindow: 1_000_000, + inputModalities: ['text', 'image'] as const, + defaultMaxTokens: 256_000, + reasoningEfforts: ['off', 'low', 'high', 'max'], + defaultReasoningEffort: 'high', + }, + ], }] /** @@ -819,6 +830,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { let expected = await readFile(fixturePath, 'utf8') const fixtureDir = dirname(fixturePath) @@ -997,7 +1012,7 @@ async function assertReplaySession( if (sessionCwd === undefined) throw new Error(`${fixturePath}: replayed session has no cwd`) const actual = rawSessionLog(session) if (mode === 'refresh' && writesCurrentSessionFixtures(manifest, mode)) { - expected = stableSessionFixture(session, expected, sessionCwd) + expected = stableSessionFixture(session, expected, sessionCwd, harnessHome) expectedPath = recordedSessionFixturePath(fixturePath, session.header.version) await writeFile(expectedPath, expected) } @@ -1010,8 +1025,11 @@ async function assertReplaySession( sessionIds: typeof expectedHeader.id === 'string' ? [expectedHeader.id] : [], cwd: typeof expectedHeader.cwd === 'string' ? expectedHeader.cwd : '\0no-cwd\0', } - expect(normalizeSessionSnapshots([normalizeWebSessionVolatiles(actual)], actualContext)[0], `${fixturePath}: persisted replay`) - .toBe(normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext)[0]) + const actualSnapshot = normalizeSessionSnapshots([normalizeWebSessionVolatiles(actual)], actualContext)[0] + ?.split(harnessHome).join('{{harnessHome}}') + const expectedSnapshot = normalizeSessionSnapshots([normalizeWebSessionVolatiles(expected)], expectedContext)[0] + ?.split(harnessHome).join('{{harnessHome}}') + expect(actualSnapshot, `${fixturePath}: persisted replay`).toBe(expectedSnapshot) if (manifest.header?.pin !== true) return const normalizePrompt = (value: string): string => value @@ -1032,7 +1050,7 @@ async function assertReplaySession( /** * Record-mode fixture write-back: harvest the live session, scrub request - * headers to {{system}}/{{tools}}, tokenize the run-local cwd, redact opaque + * headers to {{system}}/{{tools}}, tokenize the run-local cwd and Harness Home, redact opaque * identities with typed relationship-preserving tokens, and write the fixture. * A manifest-retained historical generation makes the write-back a no-op. * @param scaffold - the record-mode scaffold. @@ -1048,7 +1066,12 @@ export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, const target = recordedSessionFixturePath(fixturePath, agent.session.header.version) const existingPath = existsSync(target) ? target : fixturePath const existing = existsSync(existingPath) ? await readFile(existingPath, 'utf8') : '' - await writeFile(target, stableSessionFixture(agent.session, existing, scaffold.workspaceCwd)) + await writeFile(target, stableSessionFixture( + agent.session, + existing, + scaffold.workspaceCwd, + scaffold.harnessHome, + )) } /** @@ -1078,8 +1101,8 @@ export function fixtureIdentity( /** * Realize a recorded seed fixture against one scaffold: substitute the - * `{{sessionId}}`/`{{cwd}}` placeholders and rewrite the recorded cwd to the - * scaffold's workspace. Idempotent, so a caller may realize early (e.g. to + * `{{sessionId}}`/`{{cwd}}`/`{{harnessHome}}` placeholders and rewrite the + * recorded cwd to the scaffold's workspace. Idempotent, so a caller may realize early (e.g. to * price content exactly as the host will fold it) and still pass the result * through {@link seedSession}. * @param scaffold - the booted scaffold whose workspace the seed targets. @@ -1104,6 +1127,7 @@ export function realizeSeedFixture(scaffold: WebScaffold, fixtureText: string, i .replace(/\{\{session:([2-9]\d*)\}\}/g, (_token, ordinal: string) => `${id}-child-${ordinal}`) .replace(/\{\{(message|approval|workflow|command|rpc|retry|id):([1-9]\d*)\}\}/g, (_token, kind: string, ordinal: string) => fixtureIdentity(kind as 'message' | 'approval' | 'workflow' | 'command' | 'rpc' | 'retry' | 'id', Number(ordinal))) + .split('{{harnessHome}}').join(scaffold.harnessHome) .split('{{cwd}}').join(scaffold.workspaceCwd) return result }) @@ -1373,20 +1397,30 @@ function normalizeAria(snapshot: string, workspaceCwd: string, age: boolean): st * @param selector - the region locator selector. * @param workspaceCwd - normalization input. * @param options - `normalizeAge` collapses relative-time buckets to `{{age}}` - * for a region whose rows are dated from live wall-clock state. + * for a region whose rows are dated from live wall-clock state; + * `replacements` tokenizes scenario-owned values before generic normalization. * @returns the stable normalized snapshot. */ export async function captureStableAria( page: Page, selector: string, workspaceCwd: string, - options: { normalizeAge?: boolean } = {}, + options: { + normalizeAge?: boolean + replacements?: readonly (readonly [value: string, token: string])[] + } = {}, ): Promise { const region = page.locator(selector).first() const age = options.normalizeAge === true - let previous = normalizeAria(await region.ariaSnapshot(), workspaceCwd, age) + const normalize = (snapshot: string): string => { + for (const [value, token] of options.replacements ?? []) { + snapshot = snapshot.split(value).join(token) + } + return normalizeAria(snapshot, workspaceCwd, age) + } + let previous = normalize(await region.ariaSnapshot()) await expect.poll(async () => { - const current = normalizeAria(await region.ariaSnapshot(), workspaceCwd, age) + const current = normalize(await region.ariaSnapshot()) const stable = current === previous previous = current return stable diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index 2de45d3f9b..73d928b9da 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -18,10 +18,11 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-invocation-policy', import.meta.url)) const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md') +const FUZZY_MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu-fuzzy.expected.md') const MODE = webSnapshotMode() interface SeedSkill { @@ -111,8 +112,16 @@ describe('web e2e: skill invocation policy through the real host', () => { const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) + + // Discovery needs no prefix: an in-order subsequence of one skill name + // ranks that skill alone, through the ranker the command group uses. + await writeComposerDraft(page, input, '/plcyusr') + await expect.poll(() => menu.getByRole('option').count(), { timeout: 10_000 }).toBe(1) + expect(await menu.getByRole('option', { name: /policy-user-only/ }).count()).toBe(1) + const fuzzySnapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(FUZZY_MENU_EXPECTED, fuzzySnapshot, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['menu.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['menu-fuzzy.expected.md', 'menu.expected.md']) }) }) diff --git a/apps/web/tests/skill-user-invoke.e2e.ts b/apps/web/tests/skill-user-invoke.e2e.ts index e9b6891b7b..5201f23a96 100644 --- a/apps/web/tests/skill-user-invoke.e2e.ts +++ b/apps/web/tests/skill-user-invoke.e2e.ts @@ -130,6 +130,10 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro await settled const process = page.getByRole('button', { name: 'Thought for a while', exact: true }) await process.waitFor({ state: 'visible', timeout: 10_000 }) + // The chip derives from the step's logged injection, so it must survive + // every later Node rebuild of the Turn (process publication, turn close). + expect(await bubble.count()).toBe(1) + expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`) await expandOwningTurnProcess(page, injectionFlow) const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` }) await injectionRow.click() diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index f7ad0b36b5..461c34a9d1 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -27,6 +27,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current streaming-fence-highlight-test/streaming-fence": - text: streaming-fence-highlight-test/streaming-fence diff --git a/apps/web/tests/submission-echo.e2e.ts b/apps/web/tests/submission-echo.e2e.ts index a5d2b5503f..7693f3dc87 100644 --- a/apps/web/tests/submission-echo.e2e.ts +++ b/apps/web/tests/submission-echo.e2e.ts @@ -33,7 +33,7 @@ it('paints the submission echo on the send keystroke and swaps it for the durabl }, }) await waitFor(() => { - if (document.querySelector('[role="group"][aria-label="Pending images"] img') === null) { + if (document.querySelector('[role="group"][aria-label="Pending attachments"] img') === null) { throw new Error('attachment rail missing') } }, { timeout: 5_000 }) @@ -53,7 +53,7 @@ it('paints the submission echo on the send keystroke and swaps it for the durabl expect(echo.querySelector('img')?.getAttribute('src')?.split(':')[0]).toBe('blob') expect(composer.textContent).toBe('') expect(composer.getAttribute('contenteditable')).toBe('true') - expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull() // The fixture's durable user/message (source.rpcId echoes the prompt // requestId) replaces the echo: one bubble, no marker left, and the image diff --git a/apps/web/tests/trajectory-virtualization.e2e.ts b/apps/web/tests/trajectory-virtualization.e2e.ts index 0b7e629a9c..20a8ee340c 100644 --- a/apps/web/tests/trajectory-virtualization.e2e.ts +++ b/apps/web/tests/trajectory-virtualization.e2e.ts @@ -155,10 +155,15 @@ async function rowTop(page: Page, key: string): Promise { async function loadToFirstTurn(page: Page): Promise { const marker = FIXTURE.markers.user(1) + const loadMore = page.locator('[data-history-load] button') for (let attempt = 0; attempt < 12; attempt += 1) { + if (await page.getByText(marker, { exact: false }).count() > 0) return + await expect.poll(() => loadMore.evaluateAll(buttons => + buttons.length === 0 || !(buttons[0] as HTMLButtonElement).disabled, + ), { timeout: 15_000 }).toBe(true) + const before = await logicalRows(page) await scrollToRatio(page, 0) if (await page.getByText(marker, { exact: false }).count() > 0) return - const before = await logicalRows(page) const anchor = await firstVisibleRow(page) await expect.poll(async () => ({ marker: await page.getByText(marker, { exact: false }).count() > 0, diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 23fada7121..17c8670403 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -63,6 +63,7 @@ "tests/composer-draft-scroll.e2e.ts", "tests/cordis-tool-round.e2e.ts", "tests/web-search-round.e2e.ts", + "tests/file-upload-round.e2e.ts", "tests/message-actions.e2e.ts", "tests/message-feedback.e2e.ts", "tests/message-feedback-layout.e2e.ts", @@ -72,6 +73,7 @@ "tests/math-rendering.e2e.ts", "tests/markdown-cjk-strong.e2e.ts", "tests/markdown-inline-code-links.e2e.ts", + "tests/clickable-links-gallery.e2e.ts", "tests/queue-actions.e2e.ts", "tests/queue-image.e2e.ts", "tests/skill-invocation-policy.e2e.ts", diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 795a426436..1b3b1a5d5c 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: 83868afe952c5dbc179114ff52228fc1dc8b8fdb -capability-seams.zh.md: 064797bbc3e5d71c0bdb0b2afe09560577a1b029 +capability-seams.md: ecc88e397f41c4d279effa2df9d1b29108756ea5 +capability-seams.zh.md: 105752ac01007d24788bcfbcb2eb22c07cfdd0f5 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 83868afe95..ecc88e397f 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -14,6 +14,8 @@ flowchart LR pkg_tool_fs["tool-fs"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_deepseek["llm-deepseek"] + pkg_client_file_upload["client-file-upload"] + svc_fileUploads["ctx.fileUploads
Agent-scoped staged file uploads"] pkg_llm["llm"] svc_llm["ctx.llm
LLM adapter registry"] pkg_llm_replay["llm-replay"] @@ -235,6 +237,7 @@ flowchart LR pkg_authorization --> svc_authorization pkg_bash_local --> svc_shell pkg_bash_sandbox --> svc_shell + pkg_client_file_upload --> svc_fileUploads pkg_client_modules --> svc_clientModules pkg_code_runtime --> svc_codeRuntime pkg_code_runtime_worker_thread --> svc_codeRuntime @@ -364,6 +367,7 @@ flowchart LR svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b svc_fileReferences --> pkg_api_session_controller + svc_fileUploads --> pkg_api_session_controller svc_fs --> pkg_tool_fs svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop @@ -466,6 +470,7 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | +| `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Plugins prepare independent top-level fields; the official adapter merges them and commits their delivery state after HTTP acceptance. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 064797bbc3..105752ac01 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -16,6 +16,8 @@ flowchart LR pkg_tool_fs["tool-fs"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_deepseek["llm-deepseek"] + pkg_client_file_upload["client-file-upload"] + svc_fileUploads["ctx.fileUploads
Agent-scoped staged file uploads"] pkg_llm["llm"] svc_llm["ctx.llm
LLM adapter registry"] pkg_llm_replay["llm-replay"] @@ -237,6 +239,7 @@ flowchart LR pkg_authorization --> svc_authorization pkg_bash_local --> svc_shell pkg_bash_sandbox --> svc_shell + pkg_client_file_upload --> svc_fileUploads pkg_client_modules --> svc_clientModules pkg_code_runtime --> svc_codeRuntime pkg_code_runtime_worker_thread --> svc_codeRuntime @@ -366,6 +369,7 @@ flowchart LR svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b svc_fileReferences --> pkg_api_session_controller + svc_fileUploads --> pkg_api_session_controller svc_fs --> pkg_tool_fs svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop @@ -468,6 +472,7 @@ flowchart LR | ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 | | --- | --- | --- | --- | --- | --- | --- | | `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | +| `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | 负责流式接收、持久存储和暂存回执生命周期;Session Controller 将回执绑定到已接受的提交。 | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 插件准备彼此独立的顶层字段;官方适配器会合并这些字段,并在 HTTP 接受后提交其交付状态。 | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index f9c1c71c3b..df25b46feb 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: cd84522a925025686607b3b8f252d0474e357fa7 -config-catalog.zh.md: d23b8471e9085bc1128f1dc32937a9070802506f +config-catalog.md: af72b832723423745817fd340022e65c0c066d36 +config-catalog.zh.md: 6d961f22b3c29fbc4355e99c8de55823e014b74c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cd84522a92..af72b83272 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -203,7 +203,7 @@ Source: [`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/in ## `@deepseek-ai/dsh-api-session-controller` -Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` +Requires: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` ```ts config-catalog /** Session Controller deployment policy. */ @@ -213,7 +213,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:68`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts) @@ -262,7 +262,7 @@ export interface Config { } ``` -Source: [`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts) +Source: [`packages/attachment/attachment-local/src/index.ts:61`](../packages/attachment/attachment-local/src/index.ts) @@ -336,7 +336,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:70`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:71`](../packages/client/connection/src/index.ts) @@ -1842,7 +1842,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session/session-persistence-jsonl/src/index.ts:85`](../packages/session/session-persistence-jsonl/src/index.ts) +Source: [`packages/session/session-persistence-jsonl/src/index.ts:86`](../packages/session/session-persistence-jsonl/src/index.ts) @@ -3325,6 +3325,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) +- `@deepseek-ai/dsh-client-file-upload` — requires `agents` · `attachments` · `commands` · `connection` ([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d23b8471e9..6d961f22b3 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -205,7 +205,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` +需要:`agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` ```ts config-catalog /** Session Controller deployment policy. */ @@ -215,7 +215,7 @@ export interface Config { } ``` -来源:[`packages/api/session-controller/src/index.ts:68`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts) @@ -264,7 +264,7 @@ export interface Config { } ``` -来源:[`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts) +来源:[`packages/attachment/attachment-local/src/index.ts:61`](../packages/attachment/attachment-local/src/index.ts) @@ -338,7 +338,7 @@ export interface ConnectionConfig { } ``` -来源:[`packages/client/connection/src/index.ts:55`](../packages/client/connection/src/index.ts) +来源:[`packages/client/connection/src/index.ts:71`](../packages/client/connection/src/index.ts) @@ -3327,6 +3327,7 @@ export interface Config { - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) +- `@deepseek-ai/dsh-client-file-upload` — 需要 `agents` · `attachments` · `commands` · `connection`([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) - `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)) - `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 971dc86cb3..0406675292 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: 62eba828604ba1e90d9d7ef22743cf10daf84f70 -event-producer-consumer.zh.md: f82c379516d9aec00dade2acdf3f8291a340da44 +event-producer-consumer.md: 1d643f14cabd361e82468cbcec1e2d418e4f079c +event-producer-consumer.zh.md: 80857c699e1a2b2e0956f909c26657754b5d909f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 62eba82860..1d643f14ca 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,14 +22,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`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) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:561`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:588`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:574`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | -| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | +| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | @@ -44,11 +44,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:68`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `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:51`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`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-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`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:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../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), `agent-team`, [`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-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`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), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`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:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../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), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`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-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`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), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`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:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../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 f82c379516..80857c699e 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,11 +24,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`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) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:561`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:588`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:574`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -46,11 +46,11 @@ | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:68`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `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:51`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`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-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`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:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../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), `agent-team`, [`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-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`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), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`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:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../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), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`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-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`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), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`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:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../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 7eb463168f..a84ece4a7f 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: 6a5e4232f5a4da309b48c2e7c00dadf09e8b308a -module-graph.zh.md: 2388a1d9843e5e2fe308e372f9d7db8150b48160 +module-graph.md: be0bc1db1222ba7b95dff735a339bef680b86cf5 +module-graph.zh.md: e1fdaae73ac398076598316efcfa45822e378155 diff --git a/docs/module-graph.md b/docs/module-graph.md index 6a5e4232f5..be0bc1db12 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -132,6 +132,7 @@ flowchart TD end subgraph group_client["packages/client"] pkg_client_connection["client-connection"] + pkg_client_file_upload["client-file-upload"] pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] @@ -407,6 +408,7 @@ flowchart TD pkg_api_remotes --> pkg_scope pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths + pkg_client_file_upload --> pkg_scope pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants pkg_authorization --> pkg_llm @@ -1052,6 +1054,8 @@ flowchart TD pkg_api_session_controller --> pkg_api_gateway pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_client_connection + pkg_api_session_controller --> pkg_client_file_upload + pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm @@ -1261,6 +1265,7 @@ flowchart TD | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | +| [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1391,7 +1396,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`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-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`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-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 2388a1d984..e1fdaae73a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -134,6 +134,7 @@ flowchart TD end subgraph group_client["packages/client"] pkg_client_connection["client-connection"] + pkg_client_file_upload["client-file-upload"] pkg_client_hmr["client-hmr"] pkg_client_locale["client-locale"] pkg_client_modules["client-modules"] @@ -409,6 +410,7 @@ flowchart TD pkg_api_remotes --> pkg_scope pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths + pkg_client_file_upload --> pkg_scope pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants pkg_authorization --> pkg_llm @@ -1054,6 +1056,8 @@ flowchart TD pkg_api_session_controller --> pkg_api_gateway pkg_api_session_controller --> pkg_attachment pkg_api_session_controller --> pkg_client_connection + pkg_api_session_controller --> pkg_client_file_upload + pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm @@ -1263,6 +1267,7 @@ flowchart TD | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | +| [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1393,7 +1398,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`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-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`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-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 1c0965891a..3a281b936a 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: 76d91ea8e8edb625f9733b6ad28d225d2ee059a8 -persistence-catalog.zh.md: 52625069c16094e1b056b9ee9013409fd1690705 +persistence-catalog.md: 0b52801083498542c814d06171942f33d013efe2 +persistence-catalog.zh.md: eda29e00c9dfaff6c5d35afba55f2a8cfb95f075 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 76d91ea8e8..0b52801083 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -268,7 +268,7 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/ } ``` -Source: [`packages/interaction/commands/src/types.ts:104`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:110`](../packages/interaction/commands/src/types.ts) @@ -288,7 +288,7 @@ Source: [`packages/interaction/commands/src/types.ts:104`](../packages/interacti 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -Source: [`packages/interaction/commands/src/types.ts:97`](../packages/interaction/commands/src/types.ts) +Source: [`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts) ### `compaction/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 52625069c1..eda29e00c9 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -270,7 +270,7 @@ export type SessionEvent = { } ``` -来源:[`packages/interaction/commands/src/types.ts:104`](../packages/interaction/commands/src/types.ts) +来源:[`packages/interaction/commands/src/types.ts:110`](../packages/interaction/commands/src/types.ts) @@ -290,7 +290,7 @@ export type SessionEvent = { 'command/run': { commandId: CommandId; name: string; args?: string; source: CommandSource } ``` -来源:[`packages/interaction/commands/src/types.ts:97`](../packages/interaction/commands/src/types.ts) +来源:[`packages/interaction/commands/src/types.ts:103`](../packages/interaction/commands/src/types.ts) ### `compaction/*` diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index 8e69eaae28..3e121e78c9 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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/attachment.md -attachment.md: 15daa2b8d541ba847d48f5c43a06c1c537df6d11 -attachment.zh.md: c74a18f6bec63e117afcdb141512be04f8d75f9a +attachment.md: 53b3e51e5b1c87625256178287061386342bc055 +attachment.zh.md: 549c06cc5abe542e5d6feeba10b24ebf6ba8dfd1 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index 15daa2b8d5..53b3e51e5b 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -1,10 +1,10 @@ -# Durable Image Attachments +# Durable Attachments English | [中文](attachment.zh.md) -The attachment seam separates binary image ownership from the session log. A producer gives validated encoded bytes to [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam); the service publishes an immutable content-addressed reference only after the object is durable. Session events and model-visible `ImageBlock`s contain that reference and metadata, never a browser object URL, host temporary path, provider URL, or base64 payload. +The attachment seam separates binary image and generic-file ownership from the session log. A producer gives bytes to [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam); the service publishes an immutable content-addressed reference only after the object is durable. Session events and model-visible attachment blocks contain that reference and metadata, never a browser object URL, host temporary path, provider URL, or base64 payload. The independent [`ctx.fileUploads`](#ctxfileuploads--fileuploads) service binds browser file transfers and staged receipts to the receiving Agent. -Unsent browser drafts may stay in memory and native clients may stage them in operating-system temporary storage. Once the host accepts a user message, its images move below `/attachments/v1` before the user event is appended. Structured model image output follows the same persist-before-event rule. +Unsent browser drafts may stay in memory and native clients may stage them in operating-system temporary storage. Browser generic files become durable before they receive a staged prompt receipt. Once the host accepts a user message, its images move below `/attachments/v1` before the user event is appended. Structured model image output follows the same persist-before-event rule. Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachment/attachment/src/types.ts) @@ -62,6 +62,38 @@ The reference records intrinsic dimensions and encoded length so clients can lay ## Commit and verified-read payloads +```ts type-equiv +/** + * Browser-submitted prompt content accepted by Host prompt endpoints; the + * accepting Host promotes image parts to durable references through + * `ctx.attachments.admitPromptContent()` before any message is created, so a wire caller can + * never cite an attachment it did not upload. + */ +type PromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageMediaType + readonly data: string + readonly name?: string + } +``` + +```ts type-equiv +/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +type AttachmentAdmissionPart = + | PromptContentPart + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } +``` + +```ts type-equiv +/** Host-admitted prompt content with every attachment represented by its durable reference. */ +type AdmittedPromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } +``` + ```ts type-equiv /** Base64-encoded image upload accompanying one wire request. */ interface EncodedImageAttachment { @@ -125,7 +157,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitEncodedImages()` is the wire entry for base64 uploads and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. +`saveImage()` prepares and atomically commits a provider-independent normalized attachment before returning its `ImageAttachmentRef`. `saveImages()` prepares every validated attachment once before publishing the batch, so validation rejection leaves no partial objects and publication does not repeat decoding or quality selection. `admitPromptContent()` accepts the complete ordered Host prompt after file receipt resolution, replaces base64 image uploads with durable references, and passes durable file references unchanged. `admitEncodedImages()` supports other wire entries and delegates count, aggregate-byte, and ordered batch admission to `saveImages()`. `admitEncodedFile()` gives encoded protocol adapters the same service-owned canonical-base64 admission, and `isAttachmentError()` lets those adapters recognize stable attachment failures without importing implementation helpers. `readImage()` verifies a normalized attachment from an authorized session path. `imageHostPath()` exposes only the provider-owned host object location; it does not decide whether the current tool execution world can read it. `readImageRequest()` derives and caches one deterministic request version under an exact route pixel and byte budget. That version contains encoded bytes and metadata but no execution-world path. New entries are fully decoded before publication, while cache hits use a bounded metadata probe. Callers use `Promise.all` over the singular method when they need an ordered batch. The local implementation lazily encodes preferred candidates, singleflights equal request identities, lets each waiter cancel independently, stops shared work when no waiter remains, and bounds all transforms with its instance-level limiter, which defaults to two simultaneous transformations. The service is retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to one session's deletion. @@ -157,6 +189,30 @@ abstract validateImage(input: SaveImageAttachment): Promise */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise +/** + * Admit one Host prompt and replace each uploaded image with its durable reference. + * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. + * @param content - prompt parts in message order after file receipt resolution. + * @returns admitted prompt parts in the same order as `content`. + * @throws AttachmentError when the image batch is refused. + */ +async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise + +/** + * Decode and durably commit one canonical base64 file upload. + * @param input - canonical base64 bytes and optional display name. + * @returns the durable content-addressed file reference. + * @throws AttachmentError when the encoding or storage operation is refused. + */ +admitEncodedFile(input: EncodedFileAttachment): Promise + +/** + * Identify a failure emitted by this attachment capability by its stable code. + * @param error - value caught from an attachment operation. + * @returns whether the value is an attachment failure. + */ +isAttachmentError(error: unknown): error is AttachmentError + /** * Validate and durably commit one image before its owning session event is appended. * The returned reference describes the persisted normalized image. When @@ -184,6 +240,43 @@ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise + +/** + * Durably commit one file byte-for-byte from bounded chunks. Providers must + * apply backpressure and must not collect the complete file in memory. + * Backends without streamed verbatim storage keep this default rejection. + * @param input - ordered exact bytes, optional cancellation, and display name. + * @returns the durable content-addressed file reference. + */ +saveFileStream(input: SaveFileStreamAttachment): Promise + +/** + * Read and verify one verbatim stored file as bounded chunks. Providers must + * not collect the complete file in memory. Backends without verbatim file + * reads keep this default rejection. + * @param ref - durable reference from the session log. + * @param signal - optional cancellation for backend reads and verification work. + * @returns exact file bytes in order; integrity failures reject the iteration. + */ +async *readFileStream( ref: FileAttachmentRef, signal?: AbortSignal, ): AsyncIterable + +/** + * Locate the verbatim stored file object in the harness host filesystem. + * @param ref - durable file reference. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. + * @throws an AttachmentError when the durable reference is invalid. + */ +fileHostPath(ref: FileAttachmentRef): string | undefined + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. @@ -195,4 +288,64 @@ readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) + + + +### `ctx.fileUploads` — `FileUploads` + +Host service owning upload storage and Agent-scoped staged receipts. + +```ts cordis-catalog +/** + * Register the ordinary-Session resolver used when a raw upload addresses a cold Session. + * @param resolve - resolver that returns the exact live Agent or throws a Remote error. + * @returns disposer removing this resolver. + */ +registerAgentResolver(resolve: AgentResolver): () => void + +/** + * Persist one encoded upload and stage it under the Agent receiver selected by Typert. + * @param agent - receiving Agent resolved from the Remote Agent scope. + * @param request - canonical base64 bytes and optional display name. + * @param signal - caller cancellation before storage begins. + * @returns the staged receipt and durable file reference. + */ +@Remote('upload') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise + +/** + * Persist raw chunks for one Session without aggregating the upload. + * @param request - Session identity, ordered bytes, cancellation, and optional display name. + * @returns the staged receipt and durable file reference. + */ +async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly signal?: AbortSignal readonly name?: string }): Promise + +/** + * Resolve one staged receipt inside its receiving Agent scope. + * @param agent - receiving Agent. + * @param receiptId - opaque receipt minted for one completed upload. + * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + */ +resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined + +/** + * Bind receipts while one prompt enters an Agent inbox. + * Disposal restores every prior binding unless the caller commits successful delivery. + * @param agent - receiving Agent. + * @param receiptIds - distinct staged receipts referenced by the prompt. + * @param requestId - prompt identity later observed in queue or history. + * @returns binding kept after commit until queue or history observation retires its receipts. + */ +bindPrompt( agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string, ): PromptFileBinding + +/** + * Retire every receipt accepted by one removed queue occurrence. + * @param agent - receiving Agent. + * @param requestId - prompt identity carried by the queue occurrence. + */ +retirePrompt(agent: Agent, requestId: string): void +``` + +Types: [Agent](core.md) · [SessionId](core.md) + +Source: [`packages/client/file-upload/src/index.ts`](../../packages/client/file-upload/src/index.ts) diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index c74a18f6be..549c06cc5a 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -1,10 +1,10 @@ -# 持久图片附件 +# 持久附件 [English](attachment.md) | 中文 -附件 seam 将二进制图片的所有权与会话日志分离。生产方把经过校验的编码字节交给 [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam);只有对象完成持久化后,该服务才会发布不可变的内容寻址引用。会话事件和模型可见的 `ImageBlock` 包含该引用及其元数据,绝不包含浏览器对象 URL、宿主临时路径、提供方 URL 或 base64 数据。 +附件 seam 将二进制图片和通用文件的所有权与会话日志分离。生产方把字节交给 [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam);只有对象完成持久化后,该服务才会发布不可变的内容寻址引用。会话事件和模型可见的附件块包含该引用及其元数据,绝不包含浏览器对象 URL、宿主临时路径、提供方 URL 或 base64 数据。独立的 [`ctx.fileUploads`](#ctxfileuploads--fileuploads) 服务把浏览器文件传输与暂存凭证绑定到接收方 Agent。 -未发送的浏览器草稿可以保留在内存中,原生客户端也可以将其暂存于操作系统临时存储。宿主接受用户消息后,会先把消息中的图片移到 `/attachments/v1` 下,再追加用户事件。结构化模型图片输出遵循同样的先持久化、后追加事件规则。 +未发送的浏览器草稿可以保留在内存中,原生客户端也可以将其暂存于操作系统临时存储。浏览器通用文件取得暂存 prompt 凭证前会完成持久化。宿主接受用户消息后,会先把消息中的图片移到 `/attachments/v1` 下,再追加用户事件。结构化模型图片输出遵循同样的先持久化、后追加事件规则。 来源:[`packages/attachment/attachment/src/types.ts`](../../packages/attachment/attachment/src/types.ts) @@ -62,6 +62,38 @@ interface ImageAttachmentLimits { ## 提交与经校验读取的数据 +```ts type-equiv +/** + * Browser-submitted prompt content accepted by Host prompt endpoints; the + * accepting Host promotes image parts to durable references through + * `ctx.attachments.admitPromptContent()` before any message is created, so a wire caller can + * never cite an attachment it did not upload. + */ +type PromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageMediaType + readonly data: string + readonly name?: string + } +``` + +```ts type-equiv +/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +type AttachmentAdmissionPart = + | PromptContentPart + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } +``` + +```ts type-equiv +/** Host-admitted prompt content with every attachment represented by its durable reference. */ +type AdmittedPromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } +``` + ```ts type-equiv /** Base64-encoded image upload accompanying one wire request. */ interface EncodedImageAttachment { @@ -125,7 +157,7 @@ interface RequestImageAttachment { } ``` -`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitEncodedImages()` 是面向 base64 上传的 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 +`saveImage()` 准备并原子提交提供方无关的规范化附件,然后直接返回 `ImageAttachmentRef`。`saveImages()` 在发布批次前为每个成员各准备一次经过验证的附件,因此校验拒绝不会留下部分对象,发布也不会重复解码或选择质量。`admitPromptContent()` 在文件凭证解析后接收完整且有序的 Host prompt,把 base64 图片上传替换为持久引用,并让持久文件引用原样通过。`admitEncodedImages()` 支持其他 wire 入口,把张数、聚合字节和有序批量准入交给 `saveImages()`。`admitEncodedFile()` 让编码协议适配器使用服务拥有的规范 base64 准入,`isAttachmentError()` 让这些适配器无需导入实现辅助函数即可识别稳定的附件错误。`readImage()` 校验来自已授权会话路径的规范化附件。`imageHostPath()` 只公开提供方所持对象的宿主位置,不判断当前工具执行环境能否读取它。`readImageRequest()` 按确切路由的像素和字节预算派生并缓存确定性请求版本。该版本包含编码字节和元数据,不包含执行环境路径。新条目在发布前完整解码,缓存命中只做有界元数据探测。调用方需要有序批次时,对单数方法使用 `Promise.all`。本地实现按需编码首选候选、合并相同请求身份的并发任务、允许每个等待方单独取消、没有等待方时停止共享任务,并通过实例级限流器限制全部变换,默认同时执行两项。该服务不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。 @@ -157,6 +189,30 @@ abstract validateImage(input: SaveImageAttachment): Promise */ async saveImages(inputs: readonly SaveImageAttachment[]): Promise +/** + * Admit one Host prompt and replace each uploaded image with its durable reference. + * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. + * @param content - prompt parts in message order after file receipt resolution. + * @returns admitted prompt parts in the same order as `content`. + * @throws AttachmentError when the image batch is refused. + */ +async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise + +/** + * Decode and durably commit one canonical base64 file upload. + * @param input - canonical base64 bytes and optional display name. + * @returns the durable content-addressed file reference. + * @throws AttachmentError when the encoding or storage operation is refused. + */ +admitEncodedFile(input: EncodedFileAttachment): Promise + +/** + * Identify a failure emitted by this attachment capability by its stable code. + * @param error - value caught from an attachment operation. + * @returns whether the value is an attachment failure. + */ +isAttachmentError(error: unknown): error is AttachmentError + /** * Validate and durably commit one image before its owning session event is appended. * The returned reference describes the persisted normalized image. When @@ -184,6 +240,43 @@ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise + +/** + * Durably commit one file byte-for-byte from bounded chunks. Providers must + * apply backpressure and must not collect the complete file in memory. + * Backends without streamed verbatim storage keep this default rejection. + * @param input - ordered exact bytes, optional cancellation, and display name. + * @returns the durable content-addressed file reference. + */ +saveFileStream(input: SaveFileStreamAttachment): Promise + +/** + * Read and verify one verbatim stored file as bounded chunks. Providers must + * not collect the complete file in memory. Backends without verbatim file + * reads keep this default rejection. + * @param ref - durable reference from the session log. + * @param signal - optional cancellation for backend reads and verification work. + * @returns exact file bytes in order; integrity failures reject the iteration. + */ +async *readFileStream( ref: FileAttachmentRef, signal?: AbortSignal, ): AsyncIterable + +/** + * Locate the verbatim stored file object in the harness host filesystem. + * @param ref - durable file reference. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. + * @throws an AttachmentError when the durable reference is invalid. + */ +fileHostPath(ref: FileAttachmentRef): string | undefined + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. @@ -195,4 +288,64 @@ readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: ``` Source: [`packages/attachment/attachment/src/index.ts`](../../packages/attachment/attachment/src/index.ts) + + + +### `ctx.fileUploads` — `FileUploads` + +Host service owning upload storage and Agent-scoped staged receipts. + +```ts cordis-catalog +/** + * Register the ordinary-Session resolver used when a raw upload addresses a cold Session. + * @param resolve - resolver that returns the exact live Agent or throws a Remote error. + * @returns disposer removing this resolver. + */ +registerAgentResolver(resolve: AgentResolver): () => void + +/** + * Persist one encoded upload and stage it under the Agent receiver selected by Typert. + * @param agent - receiving Agent resolved from the Remote Agent scope. + * @param request - canonical base64 bytes and optional display name. + * @param signal - caller cancellation before storage begins. + * @returns the staged receipt and durable file reference. + */ +@Remote('upload') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise + +/** + * Persist raw chunks for one Session without aggregating the upload. + * @param request - Session identity, ordered bytes, cancellation, and optional display name. + * @returns the staged receipt and durable file reference. + */ +async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly signal?: AbortSignal readonly name?: string }): Promise + +/** + * Resolve one staged receipt inside its receiving Agent scope. + * @param agent - receiving Agent. + * @param receiptId - opaque receipt minted for one completed upload. + * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + */ +resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined + +/** + * Bind receipts while one prompt enters an Agent inbox. + * Disposal restores every prior binding unless the caller commits successful delivery. + * @param agent - receiving Agent. + * @param receiptIds - distinct staged receipts referenced by the prompt. + * @param requestId - prompt identity later observed in queue or history. + * @returns binding kept after commit until queue or history observation retires its receipts. + */ +bindPrompt( agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string, ): PromptFileBinding + +/** + * Retire every receipt accepted by one removed queue occurrence. + * @param agent - receiving Agent. + * @param requestId - prompt identity carried by the queue occurrence. + */ +retirePrompt(agent: Agent, requestId: string): void +``` + +Types: [Agent](core.zh.md) · [SessionId](core.zh.md) + +Source: [`packages/client/file-upload/src/index.ts`](../../packages/client/file-upload/src/index.ts) diff --git a/docs/subsystems/commands.i18n.yaml b/docs/subsystems/commands.i18n.yaml index 50447d69ca..d2a7bfe3b8 100644 --- a/docs/subsystems/commands.i18n.yaml +++ b/docs/subsystems/commands.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/commands.md -commands.md: 46d3b736afd3a72d1119c9744cd592a86b9cbc29 -commands.zh.md: db8d6e722e3a1742887aaa4023ab67b9ed914552 +commands.md: 4705048e06796792d8e013450b72035b1f87315b +commands.zh.md: f3af9f6e81745514efd1e073868292a02f90dca8 diff --git a/docs/subsystems/commands.md b/docs/subsystems/commands.md index 46d3b736af..4705048e06 100644 --- a/docs/subsystems/commands.md +++ b/docs/subsystems/commands.md @@ -8,7 +8,7 @@ Source: [`packages/interaction/commands/src/index.ts`](../../packages/interactio ## Input metadata -The service exposes one optional unstructured-input descriptor: a hint plus an image-acceptance flag. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition. +The service exposes one optional unstructured-input descriptor: a hint plus an attachment-acceptance flag. Command availability follows plugin composition: every adapter consuming the registry sees every effective definition. ```ts type-equiv /** Immutable metadata for a command's optional unstructured input. */ @@ -16,13 +16,13 @@ interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ readonly hint: string /** - * Whether composer image attachments may accompany an invocation. Absent or - * false = the executor rejects an invocation carrying images and capable + * Whether composer attachments may accompany an invocation. Absent or + * false = the executor rejects an invocation carrying attachments and capable * composers refuse the submission before dispatch. A declaring command's * handler receives the admitted durable blocks and owns every further * grammar decision, including rejecting sub-commands that cannot use them. */ - readonly images?: boolean + readonly attachments?: boolean } ``` @@ -64,13 +64,13 @@ interface CommandInvocation { /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string /** - * Durably admitted image blocks accompanying this invocation, in submission - * order; empty unless the definition declares `input.images`. The handler + * Durably admitted image and file blocks accompanying this invocation, in submission + * order; empty unless the definition declares `input.attachments`. The handler * owns their model-visible use — the registry never schedules them itself — * and a handler whose grammar cannot use them in this invocation returns an * error so the dispatching composer retains the originals. */ - readonly attachments: readonly ImageBlock[] + readonly attachments: readonly (ImageBlock | FileBlock)[] /** Cancellation signal owned by the dispatching UI request. */ readonly signal: AbortSignal } @@ -138,6 +138,13 @@ Human-command registry. Plain-context definitions are global; definitions regist */ register(definition: CommandDefinition): () => void +/** + * Register the sole authority that resolves staged file receipts for command submissions. + * @param resolver - Session-aware receipt resolver. + * @returns disposer that removes this exact resolver. + */ +registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void + /** * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. @@ -166,23 +173,25 @@ find(agent: Agent, name: string): CommandDefinition | undefined * handler-failure path is contained so the handler's own error stays the * reported failure. * - * Image admission is enforced here, not in the composer: images sent to a - * command that does not declare `input.images`, an absent attachment store, - * and an exceeded attachment limit each settle as an error result before - * the handler runs, and a rejected batch publishes no durable object. + * Attachment admission is enforced here, not in the composer: attachments sent to a + * command that does not declare `input.attachments`, an absent attachment store, + * and an exceeded image limit each settle as an error result before + * the handler runs. Validation rejection starts no attachment writes; + * a storage failure can leave only unreachable content-addressed objects + * for deferred collection. * * @param agent - exact receiving agent. * @param line - complete slash-command line. - * @param images - base64-encoded composer images accompanying the line, in - * submission order; empty for a plain invocation. + * @param submittedAttachments - encoded images and staged file receipts accompanying the line, + * in submission order; empty for a plain invocation. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. */ -@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise +@Remote async execute( agent: Agent, line: string, submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise ``` -Types: [Agent](core.md) · [EncodedImageAttachment](attachment.md) +Types: [Agent](core.md) Source: [`packages/interaction/commands/src/index.ts`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/commands.zh.md b/docs/subsystems/commands.zh.md index db8d6e722e..f3af9f6e81 100644 --- a/docs/subsystems/commands.zh.md +++ b/docs/subsystems/commands.zh.md @@ -8,7 +8,7 @@ ## 输入元数据 -该服务公开一个可选的非结构化输入描述符:提示文本加图片接受标志。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。 +该服务公开一个可选的非结构化输入描述符:提示文本加附件接受标志。命令的可用性由插件组合决定:每个消费注册表的适配器都会看到全部生效定义。 ```ts type-equiv /** Immutable metadata for a command's optional unstructured input. */ @@ -16,13 +16,13 @@ interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ readonly hint: string /** - * Whether composer image attachments may accompany an invocation. Absent or - * false = the executor rejects an invocation carrying images and capable + * Whether composer attachments may accompany an invocation. Absent or + * false = the executor rejects an invocation carrying attachments and capable * composers refuse the submission before dispatch. A declaring command's * handler receives the admitted durable blocks and owns every further * grammar decision, including rejecting sub-commands that cannot use them. */ - readonly images?: boolean + readonly attachments?: boolean } ``` @@ -64,13 +64,13 @@ interface CommandInvocation { /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string /** - * Durably admitted image blocks accompanying this invocation, in submission - * order; empty unless the definition declares `input.images`. The handler + * Durably admitted image and file blocks accompanying this invocation, in submission + * order; empty unless the definition declares `input.attachments`. The handler * owns their model-visible use — the registry never schedules them itself — * and a handler whose grammar cannot use them in this invocation returns an * error so the dispatching composer retains the originals. */ - readonly attachments: readonly ImageBlock[] + readonly attachments: readonly (ImageBlock | FileBlock)[] /** Cancellation signal owned by the dispatching UI request. */ readonly signal: AbortSignal } @@ -138,6 +138,13 @@ Human-command registry. Plain-context definitions are global; definitions regist */ register(definition: CommandDefinition): () => void +/** + * Register the sole authority that resolves staged file receipts for command submissions. + * @param resolver - Session-aware receipt resolver. + * @returns disposer that removes this exact resolver. + */ +registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void + /** * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. @@ -166,23 +173,25 @@ find(agent: Agent, name: string): CommandDefinition | undefined * handler-failure path is contained so the handler's own error stays the * reported failure. * - * Image admission is enforced here, not in the composer: images sent to a - * command that does not declare `input.images`, an absent attachment store, - * and an exceeded attachment limit each settle as an error result before - * the handler runs, and a rejected batch publishes no durable object. + * Attachment admission is enforced here, not in the composer: attachments sent to a + * command that does not declare `input.attachments`, an absent attachment store, + * and an exceeded image limit each settle as an error result before + * the handler runs. Validation rejection starts no attachment writes; + * a storage failure can leave only unreachable content-addressed objects + * for deferred collection. * * @param agent - exact receiving agent. * @param line - complete slash-command line. - * @param images - base64-encoded composer images accompanying the line, in - * submission order; empty for a plain invocation. + * @param submittedAttachments - encoded images and staged file receipts accompanying the line, + * in submission order; empty for a plain invocation. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. */ -@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise +@Remote async execute( agent: Agent, line: string, submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise ``` -Types: [Agent](core.zh.md) · [EncodedImageAttachment](attachment.zh.md) +Types: [Agent](core.zh.md) Source: [`packages/interaction/commands/src/index.ts`](../../packages/interaction/commands/src/index.ts) diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index c0649d5acb..81f5077280 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: d0d83897db4924de885f012212a200fa7d88b36e -llm-streaming.zh.md: 962cea4bf6ec5c4db84d7613c7a2317693bf5c47 +llm-streaming.md: e88741c6bb63b819a314f74fc425c89b865b5906 +llm-streaming.zh.md: 4bc7fc6124e77c7e3bbbc474bff9bc79fbd8eb27 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index d0d83897db..e88741c6bb 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -23,12 +23,13 @@ interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock 'image': ImageBlock + 'file': FileBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } ``` -The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `ToolCallBlock` (`id: ToolCallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. +The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `FileBlock` (a durable verbatim [file attachment](attachment.md) that request assembly projects to handle text for every route), `ToolCallBlock` (`id: ToolCallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. Image access belongs to request serialization rather than the durable attachment or deterministic request-image version. `resolveImageAttachmentAccess()` combines the attachment provider's optional host object path with a mapping supplied by the consumer for the current tool execution filesystem. The result is available only for that request and does not participate in `variantId`. @@ -961,6 +962,14 @@ providerRetryPolicy(provider: string): ResolvedRetryPolicy */ imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined +/** + * Resolve the exact text one durable file occurrence contributes to every + * provider request in the current execution environment. + * @param ref - durable verbatim file reference from model history. + * @returns the same deterministic handle text used at adapter dispatch. + */ +fileRequestText(ref: FileAttachmentRef): string + /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -1016,6 +1025,8 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` +Types: [FileAttachmentRef](attachment.md) + Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 962cea4bf6..4bc7fc6124 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -23,12 +23,13 @@ interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock 'image': ImageBlock + 'file': FileBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } ``` -各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`ToolCallBlock`(`id: ToolCallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 +各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.zh.md))、`FileBlock`(一个持久的原样[文件附件](attachment.zh.md),请求组装对每条路由都把它投影为 handle 文本)、`ToolCallBlock`(`id: ToolCallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩(compaction)和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 图片访问方式属于请求序列化,不属于持久附件或确定性请求图片版本。`resolveImageAttachmentAccess()` 把附件提供方可选的宿主对象路径,与消费方为当前工具执行文件系统提供的映射组合起来。结果只适用于本次请求,不参与 `variantId`。 @@ -967,6 +968,14 @@ providerRetryPolicy(provider: string): ResolvedRetryPolicy */ imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined +/** + * Resolve the exact text one durable file occurrence contributes to every + * provider request in the current execution environment. + * @param ref - durable verbatim file reference from model history. + * @returns the same deterministic handle text used at adapter dispatch. + */ +fileRequestText(ref: FileAttachmentRef): string + /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -1022,6 +1031,8 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` +Types: [FileAttachmentRef](attachment.zh.md) + Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts) diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 5e526daf66..2d933b93e3 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.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/persistence.md -persistence.md: 579a61400a8a8b7965c58e50ab33ba7b1c385424 -persistence.zh.md: 00823763d9c1d034855df672461f932254a205c7 +persistence.md: 7cce80f43591610c1e0667d480971dbd8f3cd4f7 +persistence.zh.md: 73169ec2f3a58f06a7355922b0e0f7e4e82944c7 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 579a61400a..7cce80f435 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -8,7 +8,7 @@ The seam is a [capability seam](../../.agents/notes/implemented/architecture/202 ## `SessionHandle` — one open channel onto a stored session -Every log read and write flows through a handle, never through id-addressed service methods: the handle is the single door a future cross-process write lease will guard. One handle type serves both accesses — a mutation on a `read` handle is a runtime `SessionReadOnlyError` rather than a typed split — and in-process single-writer ownership makes a second `open(id, 'write')` reject with `SessionAlreadyOwnedError` while an owner is active. +Every log read and write flows through a handle, never through id-addressed service methods: the handle is the single door the cross-process write lease guards. One handle type serves both accesses — a mutation on a `read` handle is a runtime `SessionReadOnlyError` rather than a typed split — and in-process single-writer ownership makes a second `open(id, 'write')` reject with `SessionAlreadyOwnedError` while an owner is active. ```ts type-equiv /** diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 00823763d9..73169ec2f3 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -8,7 +8,7 @@ ## `SessionHandle`——通向已存储会话的一条打开通道 -每一次日志读写都经由句柄流动,绝不经由按 id 寻址的服务方法:句柄是未来跨进程写租约将要把守的那扇唯一的门。一种句柄类型同时服务两种访问——在 `read` 句柄上执行修改是运行时的 `SessionReadOnlyError`,而非类型层面的拆分——而进程内单写者所有权使得在已有活跃持有者时第二次 `open(id, 'write')` 以 `SessionAlreadyOwnedError` 拒绝。 +每一次日志读写都经由句柄流动,绝不经由按 id 寻址的服务方法:句柄是跨进程写租约把守的那扇唯一的门。一种句柄类型同时服务两种访问——在 `read` 句柄上执行修改是运行时的 `SessionReadOnlyError`,而非类型层面的拆分——而进程内单写者所有权使得在已有活跃持有者时第二次 `open(id, 'write')` 以 `SessionAlreadyOwnedError` 拒绝。 ```ts type-equiv /** diff --git a/docs/subsystems/workspace.i18n.yaml b/docs/subsystems/workspace.i18n.yaml index 074fcdd610..ba89231861 100644 --- a/docs/subsystems/workspace.i18n.yaml +++ b/docs/subsystems/workspace.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/workspace.md -workspace.md: 82bbe310d490653bae5d88f41d1fcbe76833b431 -workspace.zh.md: 9bf63b7a90f9136173fbec16b55a22821fa380e5 +workspace.md: 3c53300a5be6678802da950d22854da76fa46bf1 +workspace.zh.md: b651a21f7641619c28bad9d3fdf368c718ca2c29 diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 82bbe310d4..3c53300a5b 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -40,7 +40,7 @@ interface Workspace { */ readonly path: string - /** Display title. Defaults to `basename(path)` at create; duplicates are allowed. */ + /** Display title. Defaults to the final path segment, or a filesystem root's own spelling; duplicates are allowed. */ readonly title: string /** ISO-8601 creation instant, stamped at create and never rewritten. */ @@ -117,7 +117,7 @@ Ownership truth is the record's ordered `sessionIds`, never derived from session ## The registry: `ctx.workspaceRegistry` -`WorkspaceRegistry` ([signatures](#ctxworkspaceregistry--workspaceregistry)) owns registration and resolution. `create(path, title?)` canonicalizes the path, rejects a nonexistent path (the original `ENOENT`) or a non-directory, returns the existing entity unchanged when the canonical path is already owned, and otherwise creates a record with `title ?? basename(path)` prepended to the durable registry order (different canonical paths may share a display title). `get(id)` and the ordered `list()` are synchronous cache reads; `resolveByPath(path)` applies the same realpath canon without creating. `delete(id)` removes only the registration, order entry, and session account — the directory, user files, live sessions, and persisted logs are never touched, so those sessions become Ungrouped ([decision](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)); unknown ids return `false`. Create and delete persist a pending-mutation marker before their two writes (record + order) can diverge; startup resolves exactly the marked mutation — by deleting the marked table row, which completes an interrupted delete and rolls back an interrupted create (the registration is re-creatable, so rollback is the safe direction) — and an unmarked order/table mismatch fails loud as corruption. +`WorkspaceRegistry` ([signatures](#ctxworkspaceregistry--workspaceregistry)) owns registration and resolution. `create(path, title?)` requires a fully qualified path, canonicalizes it, rejects a nonexistent path (the original `ENOENT`) or a non-directory, returns the existing entity unchanged when the canonical path is already owned, and otherwise creates a record with `title ?? defaultWorkspaceTitle(path)` prepended to the durable registry order (different canonical paths may share a display title, and a path with no final segment uses its root spelling). `get(id)` and the ordered `list()` are synchronous cache reads; `resolveByPath(path)` applies the same fully qualified realpath canon without creating. `delete(id)` removes only the registration, order entry, and session account — the directory, user files, live sessions, and persisted logs are never touched, so those sessions become Ungrouped ([decision](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.md)); unknown ids return `false`. Create and delete persist a pending-mutation marker before their two writes (record + order) can diverge; startup resolves exactly the marked mutation — by deleting the marked table row, which completes an interrupted delete and rolls back an interrupted create (the registration is re-creatable, so rollback is the safe direction) — and an unmarked order/table mismatch fails loud as corruption. Sessions get their cwd at create time from whoever creates them, not from this registry — the API gateway resolves a new session's cwd from the chosen workspace's `path` (falling back to an explicit or default cwd), creates the session so the cwd lands in its immutable [`SessionHeader`](persistence.md#sessionheader--metadata-beside-the-log), then calls `attachSession`, which re-validates that stored header cwd against the workspace path. On the first successful start, the registry bootstraps history from persisted headers alone (`id`, `cwd`, `createdAt` — never event bodies), grouping sessions with a valid canonical cwd into per-directory workspaces, newest first; the initialized marker is written last so an interrupted bootstrap resumes safely. The bootstrap is one-time: cwd-less legacy sessions stay Ungrouped, and sessions created afterwards join a workspace only through `attachSession`. @@ -250,13 +250,13 @@ Durable workspace registry. Startup waits for `sessionPersistence`, builds one c ```ts cordis-catalog /** - * Create or reuse a workspace for an existing directory. The path is - * canonicalized through `fs.realpath`; a nonexistent path rejects with the - * original error and a non-directory rejects. Repeated calls for the same - * canonical path return the existing entity without changing its title. + * Create or reuse a workspace for an existing directory. The fully qualified + * path is canonicalized through `fs.realpath`; a relative, nonexistent, or + * non-directory path rejects. Repeated calls for the same canonical path + * return the existing entity without changing its title. * A newly created workspace is prepended to the durable registry order. * Different canonical paths may share a display title. - * @param path - Existing directory to own, in any path spelling. + * @param path - Existing directory to own, in a fully qualified path spelling. * @param title - Display title used only when a new record is created. * @returns the existing or newly durable workspace. */ @@ -309,7 +309,7 @@ archiveSession(sessionId: SessionId): Promise * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned * directory returns `undefined`. - * @param path - Existing directory path in any spelling. + * @param path - Existing directory path in a fully qualified spelling. * @returns the workspace owning the canonical path, when one exists. */ async resolveByPath(path: string): Promise diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 9bf63b7a90..b651a21f76 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -40,7 +40,7 @@ interface Workspace { */ readonly path: string - /** Display title. Defaults to `basename(path)` at create; duplicates are allowed. */ + /** Display title. Defaults to the final path segment, or a filesystem root's own spelling; duplicates are allowed. */ readonly title: string /** ISO-8601 creation instant, stamped at create and never rewritten. */ @@ -117,7 +117,7 @@ interface Workspace { ## 注册表:`ctx.workspaceRegistry` -`WorkspaceRegistry`([签名](#ctxworkspaceregistry--workspaceregistry))拥有注册与解析。`create(path, title?)` 规范化路径,拒绝不存在的路径(原样传出原始 `ENOENT`)或非目录;当规范路径已被拥有时原样返回既有实体;否则创建一条标题为 `title ?? basename(path)` 的记录并前插到持久的注册表顺序中(不同规范路径可以共享同一显示标题)。`get(id)` 与有序的 `list()` 是同步缓存读取;`resolveByPath(path)` 应用同一套 realpath 规范但不创建。`delete(id)` 只移除注册记录、顺序条目和会话账本——目录、用户文件、实时会话和已持久化日志一概不动,因此这些会话变为 Ungrouped([决策](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md));未知 id 返回 `false`。create 与 delete 会在其两次写入(记录 + 顺序)可能分叉之前先持久写入一个待定变更标记;启动时恰好解决被标记的那次变更——通过删除被标记的表行:这会补完被中断的 delete,并回滚被中断的 create(注册可以重建,因此回滚是安全方向)——而没有标记的顺序/表不一致则作为损坏大声失败。 +`WorkspaceRegistry`([签名](#ctxworkspaceregistry--workspaceregistry))拥有注册与解析。`create(path, title?)` 要求完全限定路径并将其规范化,拒绝不存在的路径(原样传出原始 `ENOENT`)或非目录;当规范路径已被拥有时原样返回既有实体;否则创建一条标题为 `title ?? defaultWorkspaceTitle(path)` 的记录并前插到持久的注册表顺序中(不同规范路径可以共享同一显示标题,没有最终路径段时使用根路径拼写)。`get(id)` 与有序的 `list()` 是同步缓存读取;`resolveByPath(path)` 应用同一套完全限定 realpath 规范但不创建。`delete(id)` 只移除注册记录、顺序条目和会话账本——目录、用户文件、实时会话和已持久化日志一概不动,因此这些会话变为 Ungrouped([决策](../../.agents/notes/implemented/feature/2026-07-27-workspace-registration-deletion.zh.md));未知 id 返回 `false`。create 与 delete 会在其两次写入(记录 + 顺序)可能分叉之前先持久写入一个待定变更标记;启动时恰好解决被标记的那次变更——通过删除被标记的表行:这会补完被中断的 delete,并回滚被中断的 create(注册可以重建,因此回滚是安全方向)——而没有标记的顺序/表不一致则作为损坏大声失败。 会话的 cwd 在创建时由创建者赋予,而不是由本注册表赋予——API 网关从所选工作区的 `path` 解析新会话的 cwd(回退到显式或默认 cwd),先创建会话使 cwd 落入其不可变的 [`SessionHeader`](persistence.zh.md#sessionheader--metadata-beside-the-log),再调用 `attachSession`,后者会把已存储的 header cwd 与工作区路径重新校验一遍。首次成功启动时,注册表仅凭已持久化的 header(`id`、`cwd`、`createdAt`——绝不读事件正文)引导历史:把规范 cwd 有效的会话按目录分组为工作区,最新的排在最前;「已初始化」标记最后写入,因此被中断的引导可以安全续跑。引导只发生这一次:没有 cwd 的历史遗留会话保持 Ungrouped,此后创建的会话只能通过 `attachSession` 加入工作区。 @@ -250,13 +250,13 @@ Durable workspace registry. Startup waits for `sessionPersistence`, builds one c ```ts cordis-catalog /** - * Create or reuse a workspace for an existing directory. The path is - * canonicalized through `fs.realpath`; a nonexistent path rejects with the - * original error and a non-directory rejects. Repeated calls for the same - * canonical path return the existing entity without changing its title. + * Create or reuse a workspace for an existing directory. The fully qualified + * path is canonicalized through `fs.realpath`; a relative, nonexistent, or + * non-directory path rejects. Repeated calls for the same canonical path + * return the existing entity without changing its title. * A newly created workspace is prepended to the durable registry order. * Different canonical paths may share a display title. - * @param path - Existing directory to own, in any path spelling. + * @param path - Existing directory to own, in a fully qualified path spelling. * @param title - Display title used only when a new record is created. * @returns the existing or newly durable workspace. */ @@ -309,7 +309,7 @@ archiveSession(sessionId: SessionId): Promise * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned * directory returns `undefined`. - * @param path - Existing directory path in any spelling. + * @param path - Existing directory path in a fully qualified spelling. * @returns the workspace owning the canonical path, when one exists. */ async resolveByPath(path: string): Promise diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index c9e35b8d5f..6dd20572ee 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.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/web-styling.md -web-styling.md: dd057a3121422e4decfac06b9a3cf57e52254011 -web-styling.zh.md: 5ec0b65390b29e915fe3da633bb7c56ef92e17d6 +web-styling.md: 250936b82f06b47d00c0b6646cbea5bdb41f42ea +web-styling.zh.md: 9845b4f5d8341624d294a008f505c0fb1ec9757a diff --git a/docs/web-styling.md b/docs/web-styling.md index dd057a3121..250936b82f 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -22,6 +22,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi - Rounded corners inherit the global superellipse smoothing from ui-theme's `corner-shape.css` on supporting engines. Pair `corner-shape: round` with every full-round `border-radius` (`50%`, `100%`, or a pill radius) so circles and capsules keep circular arcs; the ui-theme corner-shape spec enforces the pairing. - Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders. - Flat borders and separators that use a neutral `--dsw-alias-border-*` token draw at `0.5px` — buttons, inputs, cards, row dividers, and separators drawn as filled boxes (menu separators, the conversation header seam, markdown `hr`, vertical rails) share the hairline weight, which Chromium paints as one device pixel. Dashed affordances and state-colored borders keep 1px; spinner ring tracks keep their width through the spec's explicit allowlist. The ui-theme elevation spec rejects wider neutral solid borders. +- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Text-leading anchors also lead with the ui-primitives `LinkIcon` category glyph riding `currentColor`; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance ([clickable-link Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md)). ## Changing the system diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index 5ec0b65390..9845b4f5d8 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -22,6 +22,7 @@ - 支持的引擎上,圆角继承 ui-theme `corner-shape.css` 的全局超级椭圆平滑。每个正圆 `border-radius`(`50%`、`100%` 或胶囊半径)必须配对 `corner-shape: round`,使圆形与胶囊保持圆弧;ui-theme 的 corner-shape spec 强制这一配对。 - 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。 - 使用中性 `--dsw-alias-border-*` token 的平面边框与分割线一律 `0.5px`——按钮、输入框、卡片、行分割线,以及以填充盒绘制的分隔线(菜单分隔、对话标题栏接缝、markdown `hr`、竖向轨道线)共用发丝线粗细,Chromium 将其绘制为一个设备像素。dashed 记号与状态色 border 保持 1px;spinner 圆环经 spec 的显式豁免保留原宽度。更宽的中性 solid border 会被 ui-theme elevation spec 拒绝。 +- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。带文字的锚点另以 ui-primitives 的 `LinkIcon` 分类图形(随 `currentColor`)作前置;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能([可点击链接 Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md))。 ## 变更系统 diff --git a/package.json b/package.json index 4116523e99..4ce55b55bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "license": "MIT", "private": true, "type": "module", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index aff89e6399..7edd811ab4 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 3fa0c1d902..42ac1c2155 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index ad2b50137d..660b9775f5 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, @@ -78,8 +78,10 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^" + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "zod": "^4.4.3" } } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index e7fc7757e9..cc917458dc 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -9,6 +9,7 @@ import llmRemote from '@deepseek-ai/dsh-llm/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' +import fileUploadsRemote from '@deepseek-ai/dsh-client-file-upload/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' @@ -24,6 +25,7 @@ export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-llm/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-client-file-upload/remote' export type {} from '@deepseek-ai/dsh-session-reference/remote' export type {} from '@deepseek-ai/dsh-subagent/remote' export type * from '@deepseek-ai/dsh-subagent/client' @@ -145,7 +147,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { try { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, - pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, + pluginInventoryRemote, messageFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, subagentsRemote, sessionRemote, workspaceRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 352178c781..62a740558b 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -48,6 +48,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const script = ` import { createServer } from 'node:http' import * as cordis from '@deepseek-ai/cordis' + import * as zod from 'zod' const urls = ${JSON.stringify(urls)} const { Context } = cordis @@ -157,6 +158,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id) return handoff.factory(specifier => { if (specifier === '@deepseek-ai/cordis') return cordis + if (specifier === 'zod') return zod throw new Error('unexpected Client external ' + specifier) }) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 56f664d499..2be76de65a 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -20,6 +20,9 @@ { "path": "../../client/connection/tsconfig.client.json" }, + { + "path": "../../client/file-upload/tsconfig.client.json" + }, { "path": "../../credentials/credentials" diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 06be2276ec..41b4271974 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: 1dbbeb34b845273d34b3323b6a4134da277126a0 -README.zh.md: 99c29a9043623b3d84fd12aeff42ec1781db699c +README.md: ec8b70b1724afc462d2d0b5d267603bcab4aceb6 +README.zh.md: 6fc0c92300262bfdf0fc52deaec1d66313d91ebe diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 1dbbeb34b8..ec8b70b172 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, and the adapter for Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session. +`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, and Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session. ## Table of Contents @@ -25,11 +25,11 @@ English | [中文](README.zh.md) History pages and follow opening snapshots carry one `{ type: 'event', event: SessionWireEvent }` record per durable Session event. The Client retains each accepted record as one durable `SessionEventLikeEntry`; Assistant token boundaries remain inside the compact stream on `assistant/message` or `assistant/attempt`. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. -Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. +Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. -The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. -The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. +The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. ----- @@ -59,6 +59,7 @@ No direct effect; model requests remain owned by the Agent and LLM packages. - Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart. - A failed follow resumption remains visible to the caller instead of retrying indefinitely. +- The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero. - File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 99c29a9043..6fc0c92300 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill 发现,以及面向 Agent 的文件引用 adapter。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。 +`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill 发现和面向 Agent 的文件引用。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。 ## 目录 @@ -25,11 +25,11 @@ kind: "package-reference" 历史页与 follow opening snapshot 为每个持久 Session event 携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑 stream 内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 -每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 +每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 -Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 +Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`,即轮次跳转加载器,按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 -Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 +Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 ----- @@ -59,6 +59,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 - Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。 - follow 恢复失败会对调用方可见,而不会无限重试。 +- 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第一个字节重新传输整个文件。 - 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index e60bfd4517..93846093fe 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, @@ -81,6 +81,8 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -119,6 +121,8 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 43c0f2dfd6..35f0ffd2bf 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -7,22 +7,25 @@ * must stub); implementation-internal entry points (history staging, wire-frame * dispatch) stay on the class, invisible out here. */ -import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' -import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts' +import type { PendingSubmissionAttachment, SessionSnapshot } from './snapshot.ts' /** * Why a local submission echo left the snapshot: `observed` when its durable * `user/message` event or host queue occurrence arrived (with the admitted - * image references in prompt order), `failed` when the prompt was rejected, + * attachment references in prompt order), `failed` when the prompt was rejected, * threw, or was aborted before acceptance. */ export type PendingSubmissionRetirement = - | { readonly reason: 'observed'; readonly attachments: readonly ImageAttachmentRef[] } + | { + readonly reason: 'observed' + readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[] + } | { readonly reason: 'failed' } /** Input registering one local submission echo ahead of its prompt call. */ @@ -31,8 +34,8 @@ export interface BeginSubmissionInput { readonly mode: 'queue' | 'steer' /** Prompt text exactly as the upcoming prompt will send it. */ readonly text: string - /** Ordered image previews matching the upcoming prompt's image parts. */ - readonly images: readonly PendingSubmissionImage[] + /** Ordered image previews and durable file metadata matching the upcoming prompt attachments. */ + readonly attachments: readonly PendingSubmissionAttachment[] /** Settlement callback fired exactly once when the echo retires. */ readonly onRetire?: (retirement: PendingSubmissionRetirement) => void } diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index ff08a2fd06..aba257f893 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -1,5 +1,6 @@ /** Session-owned observable state excluding Conversation target data. */ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' @@ -30,6 +31,23 @@ export interface PendingSubmissionImage { readonly height?: number } +/** Image branch of a local submission echo attachment. */ +export interface PendingSubmissionImageAttachment { + readonly type: 'image' + readonly value: PendingSubmissionImage +} + +/** File branch of a local submission echo attachment. */ +export interface PendingSubmissionFileAttachment { + readonly type: 'file' + readonly value: FileAttachmentRef +} + +/** One attachment displayed by a local submission echo, in prompt order. */ +export type PendingSubmissionAttachment = + | PendingSubmissionImageAttachment + | PendingSubmissionFileAttachment + /** Client surface selected when a local submission begins. */ export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering' @@ -48,8 +66,8 @@ export interface PendingSubmission { readonly time: number /** Prompt text exactly as it will be sent (one text block). */ readonly text: string - /** Ordered image previews matching the prompt's image parts. */ - readonly images: readonly PendingSubmissionImage[] + /** Ordered image previews and durable file metadata matching the prompt attachments. */ + readonly attachments: readonly PendingSubmissionAttachment[] } /** History-open lifecycle of a Session event window. */ diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index c7055f1a93..af5cdbb3e1 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -2,6 +2,8 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent/types' +import type {} from '@deepseek-ai/dsh-client-connection/client' +import type {} from '@deepseek-ai/dsh-client-file-upload/client' import { createSessionControlStream } from './transport.ts' import { ClientSessions } from './sessions/service.ts' import type { SessionRemotes } from './sessions/remotes.ts' @@ -62,7 +64,10 @@ export type { export type { OpenState, PendingSubmission, + PendingSubmissionAttachment, + PendingSubmissionFileAttachment, PendingSubmissionImage, + PendingSubmissionImageAttachment, PendingSubmissionPlacement, PromptError, QueuedMessage, @@ -78,6 +83,8 @@ declare module '@deepseek-ai/cordis' { /** Required Remote and Context projection services. */ export const inject = [ + 'connection', + 'fileUpload', 'typert', 'remote', 'remote.commands', diff --git a/packages/api/session-controller/src/client/sessions/queue-mirror.ts b/packages/api/session-controller/src/client/sessions/queue-mirror.ts index 985ad8a841..f0ed979c2b 100644 --- a/packages/api/session-controller/src/client/sessions/queue-mirror.ts +++ b/packages/api/session-controller/src/client/sessions/queue-mirror.ts @@ -5,11 +5,11 @@ import type { QueuedMessage } from '../contract/snapshot.ts' const QUEUE_PREVIEW_CHARS = 200 -// Image blocks are excluded: queue presentation renders them as thumbnails -// from `content`, so the text preview covers only what has no visual form. +// Attachment blocks are excluded: queue presentation renders them from +// `content`, so the text preview covers only what has no visual form. function previewOf(content: readonly ContentBlock[]): string { const flat = content - .filter(block => block.type !== 'image') + .filter(block => block.type !== 'image' && block.type !== 'file') .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) .join(' ').replace(/\s+/g, ' ').trim() const chars = Array.from(flat) diff --git a/packages/api/session-controller/src/client/sessions/remotes.ts b/packages/api/session-controller/src/client/sessions/remotes.ts index bec8709b90..c2e585514b 100644 --- a/packages/api/session-controller/src/client/sessions/remotes.ts +++ b/packages/api/session-controller/src/client/sessions/remotes.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-api-session-controller/client/sessions/remotes */ -import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type { CommandSubmitAttachment } from '@deepseek-ai/dsh-commands/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, SubagentPromptRequest, @@ -19,7 +19,7 @@ export interface SessionCommandsRemote { execute( agentId: SessionId, line: string, - images: readonly EncodedImageAttachment[], + attachments: readonly CommandSubmitAttachment[], signal?: AbortSignal, ): Promise> } diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index ecc0bdbf67..10f9702aab 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' -import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import { SessionLogOffset, SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' @@ -30,6 +30,7 @@ import type { } from '../contract/events.ts' import { Notifier } from './notifier.ts' import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionRemotes } from './remotes.ts' import { ProjectionValueStore } from './projection-store.ts' @@ -210,7 +211,7 @@ export class Session implements SessionFace { : 'transcript', time: Date.now(), text: input.text, - images: input.images, + attachments: input.attachments, }] this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false }) // The blank → engaging edge flips here, ahead of prompt(): the composer @@ -222,7 +223,7 @@ export class Session implements SessionFace { /** * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. - * @param content - text plus browser-owned temporary image uploads. + * @param content - text, browser-owned temporary image uploads, and staged-file receipts. * @param mode - queue appends after the current turn; steer interrupts it. * @param signal - optional caller cancellation for the complete admission round-trip. * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. @@ -252,13 +253,25 @@ export class Session implements SessionFace { content, clientTimeZone, }, signal) + } else if (content.some(part => part.type === 'file')) { + result = { + ok: false, + error: new RemoteError( + 'subagent/attachment-invalid', + 'subagent continuation does not accept files', + { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, + ), + } } else { + // The preceding branch rejects file parts before the narrower subagent + // wire type is used; this array is not filtered or reordered. + const routedContent = content as Exclude[] const routed = await this.remote.subagents.prompt({ requestId: randomUUID() as SessionRequestId, parentSessionId: this.address.parentSessionId, childSessionId: this.address.childSessionId, mode: 'continuable', - content, + content: routedContent, clientTimeZone: resolvedClientTimeZone(), }, signal) result = routed.ok ? { ok: true, value: { accepted: true } } : routed @@ -717,7 +730,7 @@ export class Session implements SessionFace { const data = event.data as { readonly source?: unknown; readonly content?: unknown } | undefined const source = data?.source as { readonly kind?: unknown; readonly rpcId?: unknown } | undefined if (source?.kind !== 'user' || typeof source.rpcId !== 'string') return - this.scheduleObservedRetirement(source.rpcId as SessionRequestId, imageRefsIn(data?.content)) + this.scheduleObservedRetirement(source.rpcId as SessionRequestId, attachmentRefsIn(data?.content)) } /** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */ @@ -725,7 +738,7 @@ export class Session implements SessionFace { if (this.submissionSettlements.size === 0) return for (const item of items) { if (item.rpcId !== undefined) { - this.scheduleObservedRetirement(item.rpcId, imageRefsIn(item.message.content)) + this.scheduleObservedRetirement(item.rpcId, attachmentRefsIn(item.message.content)) } } } @@ -738,7 +751,7 @@ export class Session implements SessionFace { */ private scheduleObservedRetirement( requestId: SessionRequestId, - attachments: readonly ImageAttachmentRef[], + attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[], ): void { const settlement = this.submissionSettlements.get(requestId) if (settlement === undefined || settlement.retiring) return @@ -816,15 +829,16 @@ function scheduleFrame(fn: () => void): void { else setTimeout(fn, 0) } -/** Image attachment references in one structurally-read content block list, in block order. */ -function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] { +/** Attachment references in one structurally-read content block list, in block order. */ +function attachmentRefsIn(content: unknown): readonly (ImageAttachmentRef | FileAttachmentRef)[] { if (!Array.isArray(content)) return [] - const refs: ImageAttachmentRef[] = [] + const refs: Array = [] for (const block of content) { if (typeof block !== 'object' || block === null) continue const candidate = block as { readonly type?: unknown; readonly attachment?: unknown } - if (candidate.type === 'image' && typeof candidate.attachment === 'object' && candidate.attachment !== null) { - refs.push(candidate.attachment as ImageAttachmentRef) + if ((candidate.type === 'image' || candidate.type === 'file') + && typeof candidate.attachment === 'object' && candidate.attachment !== null) { + refs.push(candidate.attachment as ImageAttachmentRef | FileAttachmentRef) } } return refs diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index d9fc23e1f1..8afa4b13d0 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -4,8 +4,12 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' -import { AttachmentError, admitPromptContent } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import type { + AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef, +} from '@deepseek-ai/dsh-attachment' +import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types' +import type {} from '@deepseek-ai/dsh-client-file-upload' import { ReasoningEffortId, createUserMessage, expandAssistantStream, freezeMessage, } from '@deepseek-ai/dsh-llm' @@ -44,6 +48,7 @@ import type { SessionSelectModelValue, SessionUpdateQueueRequest, SessionUpdateQueueValue, + SessionRequestId, } from './types.ts' interface SessionReadState { @@ -297,6 +302,7 @@ export class SessionCommandController { ) } const agent = await this.resolveAgent(request.sessionId) + if (hasPromptRequest(agent, request.requestId)) return { accepted: true } const selection = this.agents.selectionFor(agent).current if (!routeServed(this.ctx, selection.provider)) { throw new RemoteError( @@ -324,10 +330,23 @@ export class SessionCommandController { ) } } - const content = await admitPromptContent(this.ctx.attachments, request.content) + const admission = resolvePromptFileReceipts( + request.content, + receiptId => this.ctx.fileUploads.resolve(agent, receiptId), + ) + const content = await this.ctx.attachments.admitPromptContent(admission.content) const message: UserMessage = createUserMessage({ content, source }) + if (this.ctx.agents.get(agent.id) !== agent) { + throw new RemoteError( + 'session/not-found', + `session "${agent.id}" was disposed during prompt admission`, + { sessionId: agent.id }, + ) + } + using binding = this.ctx.fileUploads.bindPrompt(agent, admission.receiptIds, request.requestId) if (request.mode === 'steer') agent.steer(message) else agent.followup(message) + binding.commit() } catch (error) { if (remoteErrorOf(error) !== undefined) throw error if (error instanceof AttachmentError) { @@ -421,6 +440,12 @@ export class SessionCommandController { })) } else { agent.inbox.remove(request.itemId) + if (request.action.kind === 'remove') { + const source = message.source + if (source.kind === 'user' && 'rpcId' in source) { + this.ctx.fileUploads.retirePrompt(agent, source.rpcId) + } + } if (request.action.kind === 'steer') agent.steer(message) } return { accepted: true } @@ -497,6 +522,39 @@ export class SessionCommandController { } } +function resolvePromptFileReceipts( + content: SessionPromptRequest['content'], + stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined, +): { readonly content: AttachmentAdmissionPart[]; readonly receiptIds: readonly FileUploadReceiptId[] } { + const receiptIds = new Set() + const resolved = content.map((part): AttachmentAdmissionPart => { + if (part.type !== 'file') return part + const attachment = stagedFile(part.receiptId) + if (attachment === undefined) { + throw new RemoteError( + 'session/attachment-invalid', + 'File was not uploaded for this session.', + { reason: 'FILE_NOT_STAGED' }, + ) + } + receiptIds.add(part.receiptId) + return { type: 'file', attachment } + }) + return { content: resolved, receiptIds: [...receiptIds] } +} + +function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean { + const matches = (message: UserMessage): boolean => { + const source = message.source + return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId + } + if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true + return agent.session.snapshotEvents().some((event) => { + if (event.type !== 'user/message') return false + const source = event.data.source + return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId + }) +} function imageBlockIn( content: unknown, match: (ref: ImageAttachmentRef) => boolean, diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index cd24711a83..c058f25a08 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' +import type {} from '@deepseek-ai/dsh-client-file-upload' import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' @@ -84,6 +85,7 @@ export class SessionController extends TypertRemoteService { 'agentDefaultModel', 'agents', 'attachments', + 'fileUploads', 'llm', 'sessions', 'sessionProjections', @@ -115,6 +117,11 @@ export class SessionController extends TypertRemoteService { installModelSelectionProjection(ctx) this.agents = new ApiSessionAgentController(ctx) this.commands = new SessionCommandController(ctx, this.agents, process.cwd()) + ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => { + const result = await this.agents.resolveAgent(sessionId) + if ('error' in result) throw result.error + return result.agent + }), 'session-controller: file-upload Agent resolver') this.controlState = new SessionControlController(ctx) // Registered before history so reverse-order teardown closes every // follower before waiting for already-admitted promotions. diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c932ba5a37..f421660088 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -67,7 +67,11 @@ export interface SessionProjectionBaseline { export type SessionProjectionValues = Partial & Readonly> -/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */ +/** + * Browser-submitted prompt content; the Host promotes image bytes to durable + * references. File parts carry the opaque receipt returned by a preceding + * `uploadFile` call on the same Session. + */ export type PromptContentPart = | { readonly type: 'text'; readonly text: string } | { @@ -76,6 +80,7 @@ export type PromptContentPart = readonly data: string readonly name?: string } + | { readonly type: 'file'; readonly receiptId: Branded<'file-upload-receipt-id'> } /** Complete model selection for one Session. */ export interface ModelSelection { diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index ebc50aa2fe..d5b569aa49 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -22,8 +22,12 @@ import { installSessionReadTestServices, testSessionPersistence } from './test-r const roots: Context[] = [] +/** Session cwd roots created per test, removed after their context settles. */ +const tempDirs: string[] = [] + afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentController }> { @@ -297,6 +301,7 @@ describe('ApiSession create or adoption', () => { it('shares one in-flight creation between concurrent callers', async () => { const { ctx, agents } = await harness() const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-concurrent-')) + tempDirs.push(cwd) const meta = header('concurrent-create', cwd) const created = unpublishedAgent(ctx, meta) let release!: () => void @@ -317,6 +322,7 @@ describe('ApiSession create or adoption', () => { it('accepts a raced ordinary creation and rejects a raced attached child', async () => { const ordinary = await harness() const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-create-')) + tempDirs.push(cwd) const ordinaryMeta = header('create-race', cwd) const winner = agent(ordinary.ctx, ordinaryMeta) vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => { @@ -328,6 +334,7 @@ describe('ApiSession create or adoption', () => { const child = await harness() const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-child-')) + tempDirs.push(childCwd) const childId = SessionId('create-child-race') vi.spyOn(child.ctx.agents, 'create').mockImplementation(async () => { child.ctx.sessions.create(childId, { @@ -342,6 +349,7 @@ describe('ApiSession create or adoption', () => { it('validates ownership and cwd on the Agent returned by creation', async () => { const child = await harness() const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-returned-child-')) + tempDirs.push(childCwd) const childMeta = { ...header('returned-child', childCwd), parentSession: SessionId('parent'), @@ -357,6 +365,7 @@ describe('ApiSession create or adoption', () => { const wrong = await harness() const requestedCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-wrong-cwd-')) + tempDirs.push(requestedCwd) const wrongAgent = unpublishedAgent(wrong.ctx, header('wrong-returned-cwd', '/other')) vi.spyOn(wrong.ctx.agents, 'create').mockResolvedValue({ agent: wrongAgent, @@ -437,6 +446,7 @@ describe('ApiSession create or adoption', () => { it('surfaces directory creation failure and rejects setup without a scoped Agent', async () => { const { agents } = await harness() const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-')) + tempDirs.push(parent) const file = join(parent, 'file') writeFileSync(file, 'not a directory') await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false)) diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 07f88b1a4f..e1c28d8bce 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -65,6 +65,11 @@ async function mount(initialGeneration?: ConnectionGeneration): Promise { registerGenerationSource: () => () => {}, start: () => ({ stop: () => {} }), } + ctx.reflect.provide('connection', connection) + ctx.reflect.provide('fileUpload', { + available: true, + post: () => Promise.reject(new Error('unexpected file upload')), + }) ctx.reflect.provide('remote', { ...remote, $stream: (options: RemoteStreamOptions) => ( diff --git a/packages/api/session-controller/tests/client-contract.client.spec.ts b/packages/api/session-controller/tests/client-contract.client.spec.ts index ab73b2c11e..b2abdc2dc2 100644 --- a/packages/api/session-controller/tests/client-contract.client.spec.ts +++ b/packages/api/session-controller/tests/client-contract.client.spec.ts @@ -73,8 +73,9 @@ describe('Client Session contracts', () => { expectTypeOf().toEqualTypeOf() }) - it('keeps its catalog-visible prompt parts identical to attachment intake', () => { - expectTypeOf().toEqualTypeOf() + it('keeps its text and image prompt parts identical to attachment intake', () => { + expectTypeOf>() + .toEqualTypeOf() }) it('publishes exact replace, prepend, and append event-window changes', () => { diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 8591a2a0cb..8fdf62c451 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -112,6 +112,23 @@ describe('Session queue commands', () => { })).toEqual({ accepted: true }) expect(steer).toHaveBeenCalledWith(steered) + const queuedFile = createUserMessage({ + content: [{ + type: 'file', + attachment: { attachmentId: AttachmentId('file-queued'), name: 'queued.txt', bytes: 6 }, + }], + source: { kind: 'user', rpcId: 'file-rpc' as never }, + }) + inbox.append('next-turn', queuedFile) + expect(controller.updateQueue({ + sessionId: agent.id, itemId: queuedFile.id, action: { kind: 'steer' }, + })).toEqual({ accepted: true }) + expect(steer).toHaveBeenLastCalledWith(queuedFile) + expect(queuedFile).toMatchObject({ + source: { kind: 'user', rpcId: 'file-rpc' }, + content: [{ type: 'file', attachment: { name: 'queued.txt', bytes: 6 } }], + }) + await expectFailure(Promise.resolve().then(() => controller.cancel({ sessionId: SessionId('missing'), })), 'session/not-found') diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts new file mode 100644 index 0000000000..4e7565c7ff --- /dev/null +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -0,0 +1,471 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { + FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, +} from '@deepseek-ai/dsh-attachment' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { UserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import CommandRuntime from '@deepseek-ai/dsh-commands' +import { createScope } from '@deepseek-ai/dsh-scope' +import FileUploads from '@deepseek-ai/dsh-client-file-upload' +import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types' +import { describe, expect, it, vi } from 'vitest' +import type { ApiSessionAgentController } from '../src/agent.ts' +import { SessionCommandController } from '../src/commands.ts' +import type { SessionRequestId } from '../src/types.ts' + +const SESSION = SessionId('upload-session') + +async function uploadHarness(origin?: 'subagent'): Promise<{ + ctx: Context + controller: SessionCommandController + uploads: FileUploads + agent: Agent + followup: ReturnType + saveFile: ReturnType + saveFileStream: ReturnType + saveImages: ReturnType + disposeAgent: () => void + uploadRoute: (request: Request) => Promise +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(CommandRuntime) + const session = ctx.sessions.create(SESSION, { + meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) }, + }) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const followup = vi.fn() + const agent = { + id: session.id, + session, + inbox, + status: 'idle', + ctx: undefined, + steer: vi.fn(), + followup, + cancel: vi.fn(), + } as unknown as Agent + ;(agent as { ctx: Context }).ctx = createScope(ctx, agent).ctx + const disposeAgent = ctx.agents.register(agent) + const saveFile = vi.fn((input: SaveFileAttachment): Promise => Promise.resolve({ + attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`), + name: input.name ?? 'file', + bytes: input.data.byteLength, + })) + const saveFileStream = vi.fn(async (input: SaveFileStreamAttachment): Promise => { + let bytes = 0 + for await (const chunk of input.data) bytes += chunk.byteLength + return { + attachmentId: AttachmentId(`sha256:${'ef'.repeat(32)}`), + name: input.name ?? 'file', + bytes, + } + }) + const saveImages = vi.fn((): Promise => + Promise.reject(new Error('fixture did not expect image persistence'))) + ctx.provide('attachments', Object.setPrototypeOf( + { saveFile, saveFileStream, saveImages }, + AttachmentStore.prototype, + ) as never) + let uploadRoute: ((request: Request) => Promise) | undefined + ctx.provide('connection', { + fetch: { + register: (route: { readonly fetch: (request: Request) => Promise }) => { + uploadRoute = route.fetch + return () => {} + }, + }, + } as never) + ctx.provide('llm', { + listProviders: () => [{ id: 'fixture', name: 'Fixture' }], + resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }), + } as never) + const selection: ModelSelectionRef = { + current: { provider: 'fixture', model: 'fixture-model' }, + assembled: undefined, + } + const agents = { + resolveAgent: () => Promise.resolve({ agent }), + selectionFor: () => selection, + serializeImageAdmission: (_agent: Agent, operation: () => Promise) => operation(), + } as unknown as ApiSessionAgentController + const uploads = new FileUploads(ctx) + if (uploadRoute === undefined) throw new Error('file upload route was not registered') + return { + ctx, + controller: new SessionCommandController(ctx, agents, '/workspace'), + uploads, + agent, + followup, + saveFile, + saveFileStream, + saveImages, + disposeAgent, + uploadRoute, + } +} + +function promptRequest(content: Parameters[0]['content']) { + return { + requestId: 'req-1' as SessionRequestId, + sessionId: SESSION, + mode: 'queue' as const, + content, + } +} + +describe('Session file uploads', () => { + it('registers an HTTP route bound to the upload service', async () => { + const { uploadRoute } = await uploadHarness() + await expect(uploadRoute(new Request('http://host/upload'))) + .resolves.toMatchObject({ status: 405 }) + }) + + it('stages one verbatim upload and preserves its order with an admitted image', async () => { + const { ctx, controller, uploads, agent, followup, saveFile, saveImages } = await uploadHarness() + const receipt = await uploads.upload(agent, { data: 'AAAA', name: 'notes.pdf' }, new AbortController().signal) + expect(saveFile).toHaveBeenCalledTimes(1) + expect(receipt.file.name).toBe('notes.pdf') + expect(receipt.file.bytes).toBe(3) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + expect(uploads.resolve(agent, 'missing' as FileUploadReceiptId)).toBeUndefined() + const commandHandler = vi.fn((_invocation: unknown) => ({ kind: 'success' as const })) + ctx.commands.register({ + name: 'files', description: 'Use staged files', input: { hint: '', attachments: true }, + handler: commandHandler, + }) + await ctx.commands.execute( + agent, + '/files inspect', + [{ type: 'file', receiptId: receipt.receiptId }], + new AbortController().signal, + ) + expect(commandHandler.mock.calls[0]?.[0]).toMatchObject({ + attachments: [{ type: 'file', attachment: receipt.file }], + }) + const image: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, + } + saveImages.mockResolvedValueOnce([image]) + await controller.prompt(promptRequest([ + { type: 'file', receiptId: receipt.receiptId }, + { type: 'image', mediaType: 'image/png', data: 'AAAA' }, + { type: 'text', text: 'read it' }, + ])) + expect(followup).toHaveBeenCalledTimes(1) + const message = followup.mock.calls[0]?.[0] as UserMessage + expect(message.content).toEqual([ + { type: 'file', attachment: receipt.file }, + { type: 'image', attachment: image }, + { type: 'text', text: 'read it' }, + ]) + }) + + it('stages a bounded byte stream and forwards cancellation to storage', async () => { + const { controller, uploads, followup, saveFileStream } = await uploadHarness() + const abort = new AbortController() + const receipt = await uploads.uploadStream({ + sessionId: SESSION, + data: (async function* (): AsyncIterable { + yield Uint8Array.of(1, 2) + yield Uint8Array.of(3, 4, 5) + })(), + signal: abort.signal, + name: 'huge.bin', + }) + expect(saveFileStream).toHaveBeenCalledWith(expect.objectContaining({ + signal: abort.signal, + name: 'huge.bin', + })) + expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 }) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([ + { type: 'file', attachment: receipt.file }, + ]) + }) + + it('keeps the stream name optional and maps storage failures through the same error vocabulary', async () => { + const { uploads, saveFileStream } = await uploadHarness() + const stream = (async function* (): AsyncIterable {})() + await expect(uploads.uploadStream({ sessionId: SESSION, data: stream })) + .resolves.toMatchObject({ file: { name: 'file', bytes: 0 } }) + expect(saveFileStream).toHaveBeenLastCalledWith({ data: stream }) + saveFileStream.mockRejectedValueOnce('disk offline') + await expect(uploads.uploadStream({ + sessionId: SESSION, + data: (async function* (): AsyncIterable { yield Uint8Array.of(1) })(), + })) + .rejects.toMatchObject({ + code: 'gateway/internal', message: 'failed to store file upload: disk offline', + }) + }) + + it('rejects a prompt citing a file that was never staged for the session', async () => { + const { controller, followup, saveImages } = await uploadHarness() + await expect(controller.prompt(promptRequest([ + { type: 'image', mediaType: 'image/png', data: 'AAAA' }, + { type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId }, + ]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } }) + expect(followup).not.toHaveBeenCalled() + expect(saveImages).not.toHaveBeenCalled() + }) + + it('publishes no receipt when its exact Agent is disposed during storage', async () => { + const { uploads, agent, saveFile, disposeAgent } = await uploadHarness() + const saved = Promise.withResolvers() + saveFile.mockReturnValueOnce(saved.promise) + const uploading = uploads.upload(agent, { data: 'AAAA', name: 'late.bin' }, new AbortController().signal) + await vi.waitFor(() => { expect(saveFile).toHaveBeenCalledOnce() }) + disposeAgent() + saved.resolve({ + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), name: 'late.bin', bytes: 3, + }) + await expect(uploading).rejects.toMatchObject({ code: 'session/not-found' }) + }) + + it('resolves a cold ordinary Agent and releases the resolver registration', async () => { + const { ctx, uploads, agent, disposeAgent } = await uploadHarness() + disposeAgent() + const resolveAgent = vi.fn(async () => { + ctx.agents.register(agent) + return agent + }) + const disposeResolver = uploads.registerAgentResolver(resolveAgent) + expect(() => { uploads.registerAgentResolver(resolveAgent) }).toThrow('already registered') + await expect(uploads.uploadStream({ + sessionId: SESSION, + data: (async function* (): AsyncIterable { yield Uint8Array.of(1) })(), + })).resolves.toMatchObject({ file: { bytes: 1 } }) + expect(resolveAgent).toHaveBeenCalledWith(SESSION) + disposeResolver() + const replacement = vi.fn(async () => agent) + const disposeReplacement = uploads.registerAgentResolver(replacement) + disposeResolver() + expect(() => { uploads.registerAgentResolver(replacement) }).toThrow('already registered') + expect(disposeReplacement).toBeTypeOf('function') + disposeReplacement() + }) + + it('rejects a cold upload when no Agent resolver is registered', async () => { + const { uploads, disposeAgent } = await uploadHarness() + disposeAgent() + await expect(uploads.uploadStream({ + sessionId: SESSION, + data: (async function* (): AsyncIterable {})(), + })).rejects.toMatchObject({ code: 'session/not-found' }) + }) + + it('rejects subagent uploads and access outside the receiving Agent scope', async () => { + const child = await uploadHarness('subagent') + await expect(child.uploads.upload( + child.agent, + { data: 'AAAA' }, + new AbortController().signal, + )).rejects.toMatchObject({ + code: 'subagent/attachment-invalid', + details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, + }) + expect(child.saveFile).not.toHaveBeenCalled() + + const ordinary = await uploadHarness() + const receipt = await ordinary.uploads.upload( + ordinary.agent, + { data: 'AAAA' }, + new AbortController().signal, + ) + const foreignScope = { ...ordinary.agent, ctx: ordinary.ctx } as Agent + expect(() => ordinary.uploads.resolve(foreignScope, receipt.receiptId)) + .toThrow('operation requires the Agent\'s own scope') + }) + + it('retires accepted receipts after their rpcId becomes observable', async () => { + const { controller, uploads, agent } = await uploadHarness() + uploads.retirePrompt(agent, 'not-staged') + const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + uploads.retirePrompt(agent, 'other-request') + expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + uploads.retirePrompt(agent, 'req-1') + expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() + }) + + it('retires observed prompt receipts and drops staged state with the Session', async () => { + const first = await uploadHarness() + const observed = await first.uploads.upload( + first.agent, + { data: 'AAAA' }, + new AbortController().signal, + ) + await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }])) + first.ctx.emit('session/event', first.agent.session, { + type: 'user/message', + data: createUserMessage({ + content: [{ type: 'text', text: 'extension event' }], + source: { kind: 'user', rpcId: 1 } as never, + }), + } as never) + expect(first.uploads.resolve(first.agent, observed.receiptId)).toEqual(observed.file) + first.agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'observed' }], + source: { kind: 'user', rpcId: 'req-1' as SessionRequestId }, + }), { surfaceOp: 'append' }) + expect(first.uploads.resolve(first.agent, observed.receiptId)).toBeUndefined() + + const second = await uploadHarness() + const abandoned = await second.uploads.upload( + second.agent, + { data: 'AAAA' }, + new AbortController().signal, + ) + second.ctx.emit('session/disposed', second.agent.session) + expect(second.uploads.resolve(second.agent, abandoned.receiptId)).toBeUndefined() + }) + + it('deduplicates a retried rpcId already present in the Agent inbox', async () => { + const { controller, agent, followup } = await uploadHarness() + const request = promptRequest([{ type: 'text', text: 'once' }]) + await controller.prompt(request) + agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage) + await expect(controller.prompt(request)).resolves.toEqual({ accepted: true }) + expect(followup).toHaveBeenCalledOnce() + }) + + it('deduplicates a retried rpcId already present in the durable log', async () => { + const { controller, agent, followup } = await uploadHarness() + const request = promptRequest([{ type: 'text', text: 'once' }]) + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'unidentified' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + agent.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'accepted' }], + source: { kind: 'user', rpcId: request.requestId }, + }), { surfaceOp: 'append' }) + + await expect(controller.prompt(request)).resolves.toEqual({ accepted: true }) + expect(followup).not.toHaveBeenCalled() + }) + + it('rejects when the Agent disappears during prompt admission', async () => { + const { controller, saveImages, disposeAgent } = await uploadHarness() + const admitted = Promise.withResolvers() + saveImages.mockReturnValueOnce(admitted.promise) + const prompting = controller.prompt(promptRequest([ + { type: 'image', mediaType: 'image/png', data: 'AAAA' }, + ])) + await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) + disposeAgent() + admitted.resolve([{ + attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1, + }]) + await expect(prompting).rejects.toMatchObject({ code: 'session/not-found' }) + }) + + it('rejects when a previously bound receipt retires during image admission', async () => { + const { controller, uploads, agent, saveImages } = await uploadHarness() + const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + const admitted = Promise.withResolvers() + saveImages.mockReturnValueOnce(admitted.promise) + const prompting = controller.prompt({ + ...promptRequest([ + { type: 'file', receiptId: receipt.receiptId }, + { type: 'image', mediaType: 'image/png', data: 'AAAA' }, + ]), + requestId: 'req-2' as SessionRequestId, + }) + await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) + uploads.retirePrompt(agent, 'req-1') + admitted.resolve([{ + attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1, + }]) + await expect(prompting).rejects.toMatchObject({ + code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' }, + }) + }) + + it('keeps the prior receipt binding when a later prompt attempt fails', async () => { + const { controller, uploads, agent, followup } = await uploadHarness() + const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + followup.mockImplementationOnce(() => { throw new Error('busy') }) + await expect(controller.prompt({ + ...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), + requestId: 'req-2' as SessionRequestId, + })).rejects.toMatchObject({ code: 'session/agent-busy' }) + uploads.retirePrompt(agent, 'req-1') + expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() + }) + + it('restores an unbound receipt after prompt delivery fails', async () => { + const { controller, uploads, agent, followup } = await uploadHarness() + const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) + followup.mockImplementationOnce(() => { throw new Error('busy') }) + await expect(controller.prompt(promptRequest([ + { type: 'file', receiptId: receipt.receiptId }, + ]))).rejects.toMatchObject({ code: 'session/agent-busy' }) + uploads.retirePrompt(agent, 'req-1') + expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) + }) + + it('retires a prompt-bound receipt when its queued occurrence is removed', async () => { + const { controller, uploads, agent, followup } = await uploadHarness() + const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + const queued = followup.mock.calls[0]?.[0] as UserMessage + agent.inbox.append('next-turn', queued) + expect(controller.updateQueue({ + sessionId: SESSION, + itemId: queued.id, + action: { kind: 'remove' }, + })).toEqual({ accepted: true }) + expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() + }) + + it('keeps separate names for identical bytes uploaded more than once', async () => { + const { controller, uploads, agent, followup } = await uploadHarness() + const first = await uploads.upload(agent, { data: 'AAAA', name: 'first.txt' }, new AbortController().signal) + const second = await uploads.upload(agent, { data: 'AAAA', name: 'second.txt' }, new AbortController().signal) + expect(first.file.attachmentId).toBe(second.file.attachmentId) + expect(first.receiptId).not.toBe(second.receiptId) + + await controller.prompt(promptRequest([ + { type: 'file', receiptId: first.receiptId }, + { type: 'file', receiptId: second.receiptId }, + ])) + + const message = followup.mock.calls[0]?.[0] as UserMessage + expect(message.content).toEqual([ + { type: 'file', attachment: first.file }, + { type: 'file', attachment: second.file }, + ]) + }) + + it('maps a non-canonical payload to the wire attachment error', async () => { + const { uploads, agent, saveFile } = await uploadHarness() + await expect(uploads.upload(agent, { data: 'not base64!!' }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'INVALID_FILE_BASE64' } }) + expect(saveFile).not.toHaveBeenCalled() + }) + + it('maps an unexpected storage failure to the internal wire error', async () => { + const { uploads, agent, saveFile } = await uploadHarness() + saveFile.mockRejectedValueOnce(new Error('disk unavailable')) + await expect(uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)) + .rejects.toMatchObject({ + code: 'gateway/internal', + message: 'failed to store file upload: Error: disk unavailable', + }) + }) +}) diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 354c2aff60..8f303fa6d0 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -42,6 +42,16 @@ describe('SessionController facade', () => { list: () => Promise.resolve([header]), inspect, }) as never) + let uploadResolver: ((sessionId: SessionId) => Promise) | undefined + ctx.provide('fileUploads', { + registerAgentResolver: (resolve: (sessionId: SessionId) => Promise) => { + uploadResolver = resolve + return () => {} + }, + resolve: () => undefined, + bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }), + retirePrompt: () => {}, + } as never) const controller = createSessionTestController(ctx, defaults) const status = vi.fn() const failure = vi.fn() @@ -65,6 +75,17 @@ describe('SessionController facade', () => { ctx, } as Agent ctx.agents.register(agent) + const resolveUploadAgent = (id: SessionId): Promise => { + if (uploadResolver === undefined) throw new Error('file upload resolver was not registered') + return uploadResolver(id) + } + await expect(resolveUploadAgent(sessionId)).resolves.toBe(agent) + const activationError = new RemoteError('session/not-found', 'missing upload session', { sessionId }) + vi.spyOn( + (controller as unknown as { agents: ApiSessionAgentController }).agents, + 'resolveAgent', + ).mockResolvedValueOnce({ error: activationError }) + await expect(resolveUploadAgent(sessionId)).rejects.toBe(activationError) const consumeSelection = vi.spyOn( (controller as unknown as { agents: ApiSessionAgentController }).agents, 'consumeSelection', @@ -83,6 +104,10 @@ describe('SessionController facade', () => { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'browser prompt' }], + source: { kind: 'user', rpcId: 'controller-rpc' as never }, + }), { surfaceOp: 'append' }) expect(status).toHaveBeenCalledWith(sessionId, true) expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure')) expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number)) @@ -108,7 +133,7 @@ describe('SessionController facade', () => { }, abort.signal)[Symbol.asyncIterator]() await expect(iterator.next()).resolves.toMatchObject({ done: false, - value: { type: 'snapshot', cursor: 1 }, + value: { type: 'snapshot', cursor: 2 }, }) abort.abort() await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) diff --git a/packages/api/session-controller/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/queue-store.client.spec.ts index 722a06b090..573e4cfa7f 100644 --- a/packages/api/session-controller/tests/queue-store.client.spec.ts +++ b/packages/api/session-controller/tests/queue-store.client.spec.ts @@ -73,21 +73,29 @@ describe('Session queue snapshot intake', () => { ]) }) - it('marks mixed-content messages non-editable and keeps image blocks out of the text preview', () => { + it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', () => { const session = makeSession() session.handleControlFrame(queueFrame([{ id: 'q-image', body: '', - content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], + content: [ + { type: 'text', text: 'hi' }, + { type: 'image', data: 'x' } as never, + { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } } as never, + ], }])) const queue = session.getSnapshot().queue expect(typeof queue[0]?.messageId).toBe('string') expect(queue).toMatchObject([ { id: 'q-image', placement: 'queued', - content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }], - // Image blocks render as thumbnails from `content`, so the preview - // carries only the text; non-image foreign blocks keep their marker. + content: [ + { type: 'text', text: 'hi' }, + { type: 'image', data: 'x' }, + { type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } }, + ], + // Attachment blocks render from `content`, so the preview carries + // only text; other foreign blocks keep their marker. preview: 'hi', text: null, }, ]) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index 2872d3b6eb..deb58794ca 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -8,14 +8,14 @@ import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek- import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' import { SessionPersistenceRevision, @@ -42,6 +42,10 @@ function promptRequest( } } +function inboxFor(session: Session): Inbox { + return new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) +} + function header(id: string, createdAt: number, extra: Partial = {}): SessionHeader { return { version: SESSION_FORMAT_VERSION, id: sid(id), createdAt, isSeeded: false, cwd: '/proj', ...extra } } @@ -540,7 +544,9 @@ describe('subagent ownership fence', () => { inheritedEventCount: SessionLogOffset(1), }) const followup = vi.fn() - const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent + const agent = { + id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup, + } as unknown as Agent ctx.agents.register(agent) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) @@ -559,7 +565,9 @@ describe('subagent ownership fence', () => { await ctx.plugin(AgentRegistry) const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } }) const followup = vi.fn() - const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent + const agent = { + id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup, + } as unknown as Agent ctx.agents.register(agent) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), @@ -679,6 +687,7 @@ describe('sessions.prompt synchronous rejection', () => { ctx.agents.register({ id: session.id, session, + inbox: inboxFor(session), status: 'idle', ctx, followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index cd26dfb39d..9fe2bd28bb 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -665,7 +665,7 @@ describe('Web session model selection', () => { const savedRef = { attachmentId: 'saved-image', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1, } - ctx.provide('attachments', { + ctx.provide('attachments', Object.setPrototypeOf({ saveImages: () => { if (saveMode === 'error') return Promise.reject(new Error('image store offline')) if (saveMode === 'remote') { @@ -673,7 +673,7 @@ describe('Web session model selection', () => { } return Promise.resolve([savedRef]) }, - } as never) + }, AttachmentStore.prototype) as never) const followup = vi.fn() Object.assign(agent, { followup }) const remote = createSessionTestRemote(ctx, { diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index c0bad5dabd..46cfe1c049 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { SessionSeq, type SessionEvent, type SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Session } from '../src/client/sessions/session.ts' @@ -31,8 +31,20 @@ function imageRef(id: string): ImageAttachmentRef { } as unknown as ImageAttachmentRef } +function fileRef(id: string, name = 'notes.txt'): FileAttachmentRef { + return { attachmentId: id, name, bytes: 3 } as unknown as FileAttachmentRef +} + +type AttachmentRef = ImageAttachmentRef | FileAttachmentRef + +function attachmentBlock(attachment: AttachmentRef) { + return 'mediaType' in attachment + ? { type: 'image' as const, attachment } + : { type: 'file' as const, attachment } +} + /** A durable browser-prompt user/message whose source echoes `rpcId`. */ -function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionEvent { +function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionEvent { return { seq, time: 1_700_000_000_000 + seq, @@ -40,7 +52,7 @@ function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly Im surfaceOp: 'append', data: createUserMessage({ content: [ - ...refs.map(attachment => ({ type: 'image' as const, attachment })), + ...refs.map(attachmentBlock), { type: 'text' as const, text: '发送' }, ], source: { kind: 'user', rpcId }, @@ -48,14 +60,14 @@ function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly Im } as unknown as SessionEvent } -function queuedItem(rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionQueuedItem { +function queuedItem(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionQueuedItem { return { id: 'm-queued' as SessionQueuedItem['id'], placement: 'queued', rpcId, message: { id: 'm-queued' as SessionQueuedItem['id'], - content: refs.map(attachment => ({ type: 'image', attachment })) as unknown as SessionQueuedItem['message']['content'], + content: refs.map(attachmentBlock) as unknown as SessionQueuedItem['message']['content'], }, } } @@ -72,23 +84,27 @@ describe('beginSubmission', () => { const handle = session.beginSubmission({ mode: 'queue', text: '你好', - images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + attachments: [{ + type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }, + }], }) expect(session.getSnapshot().promptAttempted).toBe(true) expect(session.getSnapshot().pendingSubmissions).toMatchObject([{ requestId: handle.requestId, placement: 'transcript', text: '你好', - images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + attachments: [{ + type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }, + }], }]) }) it('derives and captures the echo placement from running state and delivery mode', () => { const { session } = makeSession() - session.beginSubmission({ mode: 'queue', text: '空闲', images: [] }) + session.beginSubmission({ mode: 'queue', text: '空闲', attachments: [] }) session.handleRunning(true) - session.beginSubmission({ mode: 'queue', text: '排队', images: [] }) - session.beginSubmission({ mode: 'steer', text: '纠偏', images: [] }) + session.beginSubmission({ mode: 'queue', text: '排队', attachments: [] }) + session.beginSubmission({ mode: 'steer', text: '纠偏', attachments: [] }) session.handleRunning(false) expect(session.getSnapshot().pendingSubmissions.map(({ text, placement }) => ({ text, placement }))).toEqual([ { text: '空闲', placement: 'transcript' }, @@ -103,7 +119,7 @@ describe('beginSubmission', () => { const handle = session.beginSubmission({ mode: 'queue', text: '放弃', - images: [], + attachments: [], onRetire: retirement => retirements.push(retirement), }) handle.abandon() @@ -121,7 +137,7 @@ describe('prompt-coupled retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '失败的', - images: [], + attachments: [], onRetire: retirement => retirements.push(retirement), }) const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId) @@ -133,7 +149,7 @@ describe('prompt-coupled retirement', () => { it('sends the echo identity as the prompt requestId', async () => { const { api, session } = makeSession() - const handle = session.beginSubmission({ mode: 'queue', text: '带 id', images: [] }) + const handle = session.beginSubmission({ mode: 'queue', text: '带 id', attachments: [] }) await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId) expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }]) }) @@ -141,7 +157,7 @@ describe('prompt-coupled retirement', () => { it('an unidentified prompt failure leaves registered echoes alone', async () => { const { api, session } = makeSession() api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' }))) - session.beginSubmission({ mode: 'queue', text: '还在', images: [] }) + session.beginSubmission({ mode: 'queue', text: '还在', attachments: [] }) await session.prompt([{ type: 'text', text: '另一个' }], 'queue') expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) }) @@ -156,7 +172,7 @@ describe('observed retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '发送', - images: [{ previewUrl: 'blob:p1' }], + attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }], onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('att-1')] @@ -176,7 +192,7 @@ describe('observed retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '排队', - images: [{ previewUrl: 'blob:p1' }], + attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }], onRetire: retirement => retirements.push(retirement), }) const refs = [imageRef('att-q')] @@ -188,9 +204,31 @@ describe('observed retirement', () => { expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }]) }) + it('retires a mixed echo with durable references in original selection order', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: PendingSubmissionRetirement[] = [] + const file = fileRef('file-1') + const handle = session.beginSubmission({ + mode: 'queue', + text: 'mixed', + attachments: [ + { type: 'image', value: { previewUrl: 'blob:first' } }, + { type: 'file', value: file }, + { type: 'image', value: { previewUrl: 'blob:last' } }, + ], + onRetire: retirement => retirements.push(retirement), + }) + const refs = [imageRef('image-1'), file, imageRef('image-2')] + await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId, refs) as never }) + await settleFrames() + expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) + }) + it('a full-window install (reconnect resync) retires echoes observed in the window', async () => { const { api, session } = makeSession() - const handle = session.beginSubmission({ mode: 'queue', text: '重连', images: [] }) + const handle = session.beginSubmission({ mode: 'queue', text: '重连', attachments: [] }) api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(SessionSeq(12), handle.requestId)]))) await session.open() await settleFrames() @@ -205,7 +243,7 @@ describe('observed retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '先观察', - images: [], + attachments: [], onRetire: retirement => retirements.push(retirement), }) await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never }) @@ -222,7 +260,7 @@ describe('observed retirement', () => { const handle = session.beginSubmission({ mode: 'queue', text: '同一请求', - images: [], + attachments: [], onRetire: retirement => retirements.push(retirement), }) session.handleControlFrame({ @@ -245,7 +283,7 @@ describe('observed retirement', () => { const { api, session } = makeSession() api.onHistory = () => Promise.resolve(ok(historyValue([]))) await session.open() - const handle = session.beginSubmission({ mode: 'queue', text: '帧', images: [] }) + const handle = session.beginSubmission({ mode: 'queue', text: '帧', attachments: [] }) await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never }) expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) expect(frames).toHaveLength(1) @@ -263,13 +301,13 @@ describe('disposal', () => { const observed = session.beginSubmission({ mode: 'queue', text: '已观察', - images: [], + attachments: [], onRetire: retirement => retirements.push({ text: '已观察', retirement }), }) session.beginSubmission({ mode: 'queue', text: '未settle', - images: [], + attachments: [], onRetire: retirement => retirements.push({ text: '未settle', retirement }), }) await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), observed.requestId) as never }) diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index a30c72c86b..0b6e872653 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -1,6 +1,6 @@ /** Session creation and adoption rules for Agent preset identity. */ -import { mkdtempSync, realpathSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -10,9 +10,17 @@ import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { createSessionTestRemote } from './test-remote.ts' +/** Booted contexts and their temp roots, torn down after each test. */ +const contexts: Context[] = [] +const tempDirs: string[] = [] +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + function stubAgent(session: Session): Agent { return { id: session.id, session, status: 'idle' } as unknown as Agent } @@ -42,7 +50,9 @@ function roster(ids: readonly string[]): unknown { async function harness(presets?: readonly string[]) { const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-'))) + tempDirs.push(cwd) const ctx = new Context() + contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) if (presets !== undefined) { diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 72e5031e3f..20893c01e2 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -15,7 +15,6 @@ const PARENT = 'fk-parent' as SessionId afterEach(() => { vi.unstubAllGlobals() }) - function makeSession( api = new FakeApiClient(), options: SessionOptions = {}, @@ -479,6 +478,29 @@ describe('prompt and cancel errors', () => { }) }) + it('rejects staged files instead of dropping them from subagent continuations', async () => { + const api = new FakeApiClient() + const session = new Session(SID, fakeRemote(api), { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }) + await session.open() + + const prompted = await session.prompt([ + { type: 'file', receiptId: 'receipt' as never }, + { type: 'text', text: '继续' }, + ], 'queue') + + expect(prompted).toMatchObject({ + ok: false, + error: { + code: 'subagent/attachment-invalid', + details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' }, + }, + }) + expect(api.callsOf('subagents.prompt')).toEqual([]) + }) + it('sends a one-shot address to the Host under the continuable marker', async () => { const api = new FakeApiClient() api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError( diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 00dda55bc9..8bdbd9ed43 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -3,6 +3,11 @@ import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Context } from '@deepseek-ai/cordis' import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' +import type { + AdmittedPromptContentPart, + AttachmentAdmissionPart, + ImageAttachmentLimits, +} from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { SessionPersistenceNotFoundError, @@ -91,6 +96,15 @@ export interface TestSessionRemoteDefaults { const installed = new WeakMap() +const TEST_IMAGE_LIMITS: ImageAttachmentLimits = Object.freeze({ + maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 20, + maxMessageImageBytes: 100 * 1024 * 1024, + maxImagePixels: 40_000_000, + maxImageDimension: 2000, + mediaTypes: Object.freeze(['image/png'] as const), +}) + /** Compact header-and-events point read a persistence double declares per session. */ interface TestSessionInspection { readonly meta: SessionHeader @@ -233,6 +247,29 @@ function installControllers( }, } as never) } + if (ctx.get('attachments') === undefined) { + ctx.provide('attachments', { + imageLimits: TEST_IMAGE_LIMITS, + admitPromptContent: async ( + content: readonly AttachmentAdmissionPart[], + ): Promise => { + const admitted: AdmittedPromptContentPart[] = [] + for (const part of content) { + if (part.type === 'image') throw new Error('test did not configure image persistence') + admitted.push(part) + } + return admitted + }, + } as never) + } + if (ctx.get('fileUploads') === undefined) { + ctx.provide('fileUploads', { + registerAgentResolver: () => () => {}, + resolve: () => undefined, + bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }), + retirePrompt: () => {}, + } as never) + } installSessionReadTestServices(ctx) const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) let controller: SessionController diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index 030ed712d1..3bb07893e2 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -15,6 +15,7 @@ { "path": "../gateway/tsconfig.client.json" }, { "path": "../../attachment/attachment" }, { "path": "../../client/connection/tsconfig.client.json" }, + { "path": "../../client/file-upload/tsconfig.client.json" }, { "path": "../../client/store" }, { "path": "../../context/file-reference" }, { "path": "../../core/session" }, diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 28dd075fd1..ad7751119f 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -29,6 +29,7 @@ { "path": "../../core/session" }, { "path": "../../context/file-reference" }, { "path": "../../attachment/attachment" }, + { "path": "../../client/file-upload/tsconfig.host.json" }, { "path": "../../interaction/permission-presets" }, { "path": "../../jobs/jobs" }, { "path": "../../llm/llm" }, diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index 074a5017c7..788955ed0d 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index c81537e894..c4a3703704 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index dab997847b..3bcd3b0d04 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -22,8 +22,12 @@ declare module '@deepseek-ai/dsh-typert-protocol' { const roots: Context[] = [] +/** Workspace roots created per test, removed after their context settles. */ +const tempDirs: string[] = [] + afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) interface Deferred { @@ -39,6 +43,7 @@ function deferred(): Deferred { async function harness() { const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-workspace-controller-'))) + tempDirs.push(root) const ctx = new Context() roots.push(ctx) await ctx.plugin(SessionStore) diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index a019d28997..9c5f3a1abb 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/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/attachment/attachment-local/README.md -README.md: 5e9bda9aa13939c3945d005ea474c3caf8daa661 -README.zh.md: 88dcbff120c4c61ea776e43fc906923f039ee09b +README.md: 364153b7b56daa725003178b6cfad90e3f94bc04 +README.zh.md: 6ca5c6df8289c9e16bfe608b5b9ae200adf18a6b diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 5e9bda9aa1..364153b7b5 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package provides the local storage and image-processing backend for attachments: source images are validated, oriented, stripped of metadata and color profiles, normalized to 8-bit sRGB/sRGBA, and saved below `DSH_HOME`; route-specific request versions are derived and cached separately. It is what the shipped `dsh` composition uses, so durable image attachments work without configuration. Identical normalized images are stored only once, concurrent reads of one request variant share work, and stored images stay readable after later admission-limit changes. Storage is local to this machine — other hosts cannot read these images — and objects are never deleted automatically. +This package provides the local storage and image-processing backend for attachments: source images are validated, oriented, stripped of metadata and color profiles, normalized to 8-bit sRGB/sRGBA, and saved below `DSH_HOME`; route-specific request versions are derived and cached separately, and generic files are saved byte-for-byte with no admission limits. Streamed file writes and reads use bounded chunks; writes hash into a private staging object before atomic publication, and reads verify the recorded byte length and digest without a whole-file memory copy. It is what the shipped `dsh` composition uses, so durable attachments work without configuration. Identical bytes occupy one canonical object even when uploads use different display names; each model-facing name is a hard link to that object. Concurrent reads of one request variant share work, and stored images stay readable after later admission-limit changes. Storage is local to this machine; other hosts cannot read these objects, and objects are never deleted automatically. ## Table of Contents @@ -25,7 +25,7 @@ This package provides the local storage and image-processing backend for attachm ## Use this package -In the default composition, attach images to a prompt or command and they are stored on this machine automatically. If you compose your own setup, mounting this one plugin gives you durable image attachments. +In the default composition, images and generic files attached to prompts or commands are stored on this machine automatically. If you compose your own setup, mounting this plugin provides durable attachments. ### Minimal configuration @@ -87,12 +87,15 @@ Admission accepts up to 20 images and 200 MiB of source bytes per message; one s Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales without enlargement to a route pixel budget, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, budgets, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history. +Generic-file bytes have one canonical object at `/attachments/v1/file-objects//`. Each reference path at `/attachments/v1/files///` is a read-only hard link, so different names for equal bytes do not duplicate disk content. `readFileStream` reads the reference path in bounded chunks and verifies the complete digest and recorded byte count before a consumer can finish successfully. A missing, changed, or truncated object fails its consumer instead of producing a complete export with different bytes. + ### Source map | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: `LocalAttachmentStore`, `Config` schema, defaults | | [`src/store.ts`](src/store.ts) | Content-addressed write and verified read: staging, hard-link publish, fsync chain, digest verification | +| [`src/file-store.ts`](src/file-store.ts) | Verbatim streamed file writes, verified streamed reads, and safe stored filenames | | [`src/normalization.ts`](src/normalization.ts) + [`src/encoding.ts`](src/encoding.ts) | Provider-independent normalization and bounded format/quality candidates | | [`src/request-image.ts`](src/request-image.ts) | Route-specific request transforms, cache identity, and singleflight | | [`src/image.ts`](src/image.ts) | Full raster decode and metadata verification | @@ -117,7 +120,7 @@ For the full service contract and payload types, read the subsystem reference; f ## Model Experience -Indirectly, through request descriptors. A mapped execution filesystem lets the model see each image's identity, dimensions, media type, read-only process path, writable-copy extension, and normalization warning alongside the request bytes. +Indirectly, through request descriptors. A mapped execution filesystem lets the model see each image's identity, dimensions, media type, read-only process path, writable-copy extension, and normalization warning alongside the request bytes. Generic files project as text handles naming their identity and read-only process path; when no mapping exists, the handle states that the execution environment cannot read the file. #### KV Cache effect diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 88dcbff120..6ca5c6df82 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包提供附件的本地存储与图片处理后端:源图经过校验、方向修正、元数据与色彩配置移除,并规范化为 8-bit sRGB/sRGBA 后保存在 `DSH_HOME` 下;路由专用请求版本另行派生并缓存。随附的 `dsh` 组合使用的就是它,因此持久图片附件无需配置即可工作。相同规范化图片只存一份,同一请求变体的并发读取共享工作,即使后来收紧准入限制,已存图片仍然可读。存储仅限本机——其他主机无法读取这些图片——对象也永远不会自动删除。 +本包提供附件的本地存储与图片处理后端:源图经过校验、方向修正、元数据与色彩配置移除,并规范化为 8-bit sRGB/sRGBA 后保存在 `DSH_HOME` 下;路由专用请求版本另行派生并缓存,通用文件则不设准入限制,按字节原样保存。流式文件写入与读取都使用有界分块;写入会在私有暂存对象中计算摘要后原子发布,读取会校验记录的字节长度与摘要,两者都不产生整文件内存副本。随附的 `dsh` 组合使用的就是它,因此持久附件无需配置即可工作。即使使用不同显示名称上传,相同字节也只占用一个规范对象;每条模型可见路径都是指向该对象的硬链接。同一请求变体的并发读取共享工作,即使后来收紧准入限制,已存图片仍然可读。存储仅限本机,其他主机无法读取这些对象,对象也永远不会自动删除。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -在默认组合中,把图片附加到提示词或命令,它们会自动保存到本机。自行组合时,挂载这一个插件即可获得持久图片附件。 +在默认组合中,把图片或通用文件附加到提示词或命令,它们会自动保存到本机。自行组合时,挂载这个插件即可获得持久附件。 ### 最小配置 @@ -87,12 +87,15 @@ kind: "package-reference" 请求版本位于 `/attachments/v1/request-images/`。`readImageRequest` 在不放大的前提下缩放到路由像素预算,再通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、预算与固定编码参数;缓存字节会先探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。 +通用文件字节的唯一规范对象位于 `/attachments/v1/file-objects//`。每条引用路径 `/attachments/v1/files///` 都是只读硬链接,所以名称不同但字节相同的文件不会重复占用磁盘。`readFileStream` 以有界分块读取引用路径,并在消费方成功结束前校验完整摘要与记录的字节数。对象缺失、被改写或截断时,消费方会失败,不会得到字节已经变化的完整导出。 + ### 源码地图 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:`LocalAttachmentStore`、`Config` schema、默认值 | | [`src/store.ts`](src/store.ts) | 内容寻址写入与校验读取:暂存、硬链接发布、fsync 链、摘要校验 | +| [`src/file-store.ts`](src/file-store.ts) | 原样文件的流式写入、校验式流式读取与安全存储文件名 | | [`src/normalization.ts`](src/normalization.ts) + [`src/encoding.ts`](src/encoding.ts) | 提供方无关的规范化与有界格式/质量候选 | | [`src/request-image.ts`](src/request-image.ts) | 路由专用请求变换、缓存身份与 singleflight | | [`src/image.ts`](src/image.ts) | 完整光栅解码与元数据校验 | @@ -117,7 +120,7 @@ kind: "package-reference" ## 模型体验 -本包通过请求描述符间接影响模型。执行文件系统可以映射宿主对象时,模型会随请求字节看到每张图片的身份、尺寸、媒体类型、只读进程路径、可写副本扩展名与规范化警告。 +本包通过请求描述符间接影响模型。执行文件系统可以映射宿主对象时,模型会随请求字节看到每张图片的身份、尺寸、媒体类型、只读进程路径、可写副本扩展名与规范化警告。通用文件会投影为指出文件身份与只读进程路径的文本句柄;无法映射时,句柄会说明当前执行环境不能读取该文件。 #### KV Cache 影响 diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 7a9fb7d4d0..cc3054e20e 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/src/compression-limiter.ts b/packages/attachment/attachment-local/src/compression-limiter.ts index 3935f262a1..26cc1c6ecb 100644 --- a/packages/attachment/attachment-local/src/compression-limiter.ts +++ b/packages/attachment/attachment-local/src/compression-limiter.ts @@ -1,5 +1,16 @@ /** Instance-owned concurrency bound for native image transformations. */ +/** + * Preserve Error rejections and normalize non-Error native binding values. + * @param reason - rejection reason returned by a compression task. + * @returns an Error suitable for promise rejection. + */ +export function compressionFailure(reason: unknown): Error { + return reason instanceof Error + ? reason + : new Error('Image compression task rejected with a non-Error value.', { cause: reason }) +} + /** FIFO limiter for asynchronous compression work. */ export class CompressionLimiter { private active = 0 @@ -30,9 +41,7 @@ export class CompressionLimiter { }, (error: unknown) => { release() - reject(error instanceof Error - ? error - : new Error('Image compression task rejected with a non-Error value.', { cause: error })) + reject(compressionFailure(error)) }, ) } diff --git a/packages/attachment/attachment-local/src/file-store.ts b/packages/attachment/attachment-local/src/file-store.ts new file mode 100644 index 0000000000..cf4c56edb1 --- /dev/null +++ b/packages/attachment/attachment-local/src/file-store.ts @@ -0,0 +1,181 @@ +/** Verbatim content-addressed local file storage. @module @deepseek-ai/dsh-attachment-local/file-store */ + +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { join } from 'node:path' +import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { + FileAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, +} from '@deepseek-ai/dsh-attachment' +import { + publishImmutableAlias, publishImmutableObject, publishImmutableObjectStream, +} from './store.ts' + +const FILE_ID_PATTERN = /^sha256:([a-f0-9]{64})$/ +const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/iu + +function isWindowsDeviceName(name: string): boolean { + const dot = name.indexOf('.') + const stem = (dot < 0 ? name : name.slice(0, dot)).replace(/[. ]+$/u, '') + return WINDOWS_DEVICE_NAME.test(stem) +} + +function utf8Prefix(value: string, maxBytes: number): string { + let bytes = 0 + let prefix = '' + for (const character of Buffer.from(value).toString('utf8')) { + const characterBytes = Buffer.byteLength(character) + if (bytes + characterBytes > maxBytes) break + prefix += character + bytes += characterBytes + } + return prefix +} + +/** + * Sanitize one caller display name into a safe stored leaf name. Both + * separator styles are stripped by hand: a POSIX host treats `\` as an + * ordinary character, so path.basename would keep a Windows client's full + * local path and leak it into the reference and the session log. Characters + * Windows refuses in file names become `_` so one reference stays valid on + * every supported host. + * @param value - caller-declared display name, possibly a full client path. + * @returns a non-empty leaf name safe to store on every supported filesystem. + */ +export function fileLeafName(value: string | undefined): string { + if (value === undefined) return 'file' + const leaf = value.slice(Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + 1) + let clean = leaf + .replace(/[\u0000-\u001f\u007f]/g, '') + .replace(/[<>:"|?*]/g, '_') + .trim() + .replace(/[. ]+$/u, '') + if (isWindowsDeviceName(clean)) clean = `_${clean}` + clean = utf8Prefix(clean, 255).replace(/[. ]+$/u, '') + return clean === '' || clean === '.' || clean === '..' ? 'file' : clean +} + +function ensureFileReference(ref: FileAttachmentRef): string { + const match = FILE_ID_PATTERN.exec(String(ref.attachmentId)) + if (match?.[1] === undefined || ref.name !== fileLeafName(ref.name)) { + throw new AttachmentError('File attachment reference is invalid.', 'INVALID_ATTACHMENT_REF') + } + return match[1] +} + +/** + * Derive the absolute immutable-object path for one stored file. The digest + * names a directory so the sanitized display name stays the stored leaf name, + * giving models and users a path that ends in the real filename. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param ref - durable file reference from the session log or an upload receipt. + * @returns provider-local path without reading the object. + * @throws an AttachmentError when the reference digest or name is invalid. + */ +export function storedFilePath(root: string, ref: FileAttachmentRef): string { + const sha256 = ensureFileReference(ref) + return join(root, 'files', sha256.slice(0, 2), sha256, ref.name) +} + +/** Canonical object path shared by every display name for one digest. */ +function storedFileObjectPath(root: string, sha256: string): string { + return join(root, 'file-objects', sha256.slice(0, 2), sha256) +} + +/** + * Commit one file byte-for-byte below a versioned attachment root. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param input - exact bytes and optional display name. + * @returns the durable content-addressed file reference. + */ +export async function saveFileVerbatim( + root: string, + input: SaveFileAttachment, +): Promise { + const sha256 = createHash('sha256').update(input.data).digest('hex') + const ref: FileAttachmentRef = { + attachmentId: AttachmentId(`sha256:${sha256}`), + name: fileLeafName(input.name), + bytes: input.data.byteLength, + } + const objectPath = storedFileObjectPath(root, sha256) + await publishImmutableObject(root, objectPath, input.data, sha256) + await publishImmutableAlias(root, objectPath, storedFilePath(root, ref), sha256) + return ref +} + +/** + * Commit one file byte-for-byte from bounded chunks below a versioned attachment root. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param input - ordered exact bytes, optional cancellation, and display name. + * @returns the durable content-addressed file reference. + */ +export async function saveFileStreamVerbatim( + root: string, + input: SaveFileStreamAttachment, +): Promise { + const name = fileLeafName(input.name) + const stored = await publishImmutableObjectStream( + root, + input.data, + sha256 => storedFileObjectPath(root, sha256), + input.signal, + ) + const ref: FileAttachmentRef = { + attachmentId: AttachmentId(`sha256:${stored.sha256}`), + name, + bytes: stored.bytes, + } + input.signal?.throwIfAborted() + await publishImmutableAlias( + root, + storedFileObjectPath(root, stored.sha256), + storedFilePath(root, ref), + stored.sha256, + ) + input.signal?.throwIfAborted() + return ref +} + +/** + * Read one stored file in bounded chunks and verify its byte count and digest. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param ref - durable file reference from the session log. + * @param signal - optional cancellation for filesystem reads. + * @returns exact stored bytes in order; integrity failures reject after the final chunk. + */ +export async function* readFileStreamVerbatim( + root: string, + ref: FileAttachmentRef, + signal?: AbortSignal, +): AsyncIterable { + signal?.throwIfAborted() + const sha256 = ensureFileReference(ref) + const stream = createReadStream(storedFilePath(root, ref), { + highWaterMark: 1 << 16, + ...(signal === undefined ? {} : { signal }), + }) + const hash = createHash('sha256') + let bytes = 0 + try { + for await (const chunk of stream) { + signal?.throwIfAborted() + const data = chunk as Buffer + hash.update(data) + bytes += data.byteLength + yield data + } + } catch (error) { + signal?.throwIfAborted() + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + throw new AttachmentError('File attachment object is missing.', 'ATTACHMENT_NOT_FOUND') + } + throw new AttachmentError('Unable to read file attachment.', 'ATTACHMENT_READ_FAILED', { cause: error }) + } finally { + stream.destroy() + } + signal?.throwIfAborted() + if (bytes !== ref.bytes || hash.digest('hex') !== sha256) { + throw new AttachmentError('Stored file attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') + } +} diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 16b963f225..7415bf940f 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -5,17 +5,23 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { + FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, + SaveFileAttachment, + SaveFileStreamAttachment, SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type { NormalizationPolicy } from './normalization.ts' -import { CompressionLimiter } from './compression-limiter.ts' +import { CompressionLimiter, compressionFailure } from './compression-limiter.ts' import { commitPreparedImageFile, normalizedImagePath, prepareImageFile, readImageFile, validateImageFile } from './store.ts' +import { + readFileStreamVerbatim, saveFileStreamVerbatim, saveFileVerbatim, storedFilePath, +} from './file-store.ts' import { readRequestImageFile, requestImageVariantId } from './request-image.ts' export { canPassThroughNormalization, normalizeImage } from './normalization.ts' @@ -124,9 +130,7 @@ class SharedRequest { }, (error: unknown) => { signal.removeEventListener('abort', abort) release(false) - // CompressionLimiter normalizes task rejections before this handler. - // oxlint-disable-next-line typescript/prefer-promise-reject-errors - reject(error) + reject(compressionFailure(error)) }) }) } @@ -222,6 +226,22 @@ export class LocalAttachmentStore extends AttachmentStore { return normalizedImagePath(this.root, ref) } + override async saveFile(input: SaveFileAttachment): Promise { + return saveFileVerbatim(this.root, input) + } + + override async saveFileStream(input: SaveFileStreamAttachment): Promise { + return saveFileStreamVerbatim(this.root, input) + } + + override readFileStream(ref: FileAttachmentRef, signal?: AbortSignal): AsyncIterable { + return readFileStreamVerbatim(this.root, ref, signal) + } + + override fileHostPath(ref: FileAttachmentRef): string { + return storedFilePath(this.root, ref) + } + override async readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index e5a979aec1..7fab679e34 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -1,7 +1,7 @@ /** Content-addressed, owner-private local attachment storage. */ import { createHash, randomUUID } from 'node:crypto' -import { constants } from 'node:fs' +import { constants, createReadStream } from 'node:fs' import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises' import { dirname, join, parse, resolve } from 'node:path' import { @@ -197,60 +197,210 @@ export async function commitPreparedImageFile( if (digest(normalized) !== sha256 || normalized.byteLength !== prepared.ref.bytes) { throw new AttachmentError('Prepared attachment bytes do not match their reference.', 'ATTACHMENT_CORRUPT') } - const bucket = join(root, 'objects', sha256.slice(0, 2)) + await publishImmutableObject(root, normalizedImagePath(root, prepared.ref), normalized, sha256) + return prepared.ref +} + +/** + * Publish one immutable content-addressed object below a versioned attachment + * root: staged write, fsync, hard-link into place, digest-verified EEXIST + * deduplication, read-only mode, and durable directory entries from the + * target's parent up to (excluding) `root`. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param target - absolute final object path below `root`. + * @param data - exact object bytes whose digest is `sha256`. + * @param sha256 - hex digest the stored bytes must match on deduplication. + */ +export async function publishImmutableObject( + root: string, + target: string, + data: Uint8Array, + sha256: string, +): Promise { + const staged = await stageImmutableObject(root, (function* (): Iterable { + yield data + })()) + if (staged.sha256 !== sha256) { + await removeTemporary(staged.path) + throw new AttachmentError('Attachment bytes do not match their publication digest.', 'ATTACHMENT_CORRUPT') + } + await publishStagedObject(root, target, staged) +} + +/** Digest and byte count produced while streaming one immutable object to disk. */ +export interface StreamedImmutableObject { + readonly sha256: string + readonly bytes: number +} + +/** + * Stream one immutable object from bounded chunks into a staging file, then + * publish it at a digest-derived target without collecting the complete object in memory. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param data - exact object bytes in order. + * @param targetFor - derive the final absolute target from the completed digest and byte count. + * @param signal - optional cancellation for source reads and storage writes. + * @returns digest and exact byte count of the published object. + */ +export async function publishImmutableObjectStream( + root: string, + data: AsyncIterable, + targetFor: (sha256: string, bytes: number) => string, + signal?: AbortSignal, +): Promise { + const staged = await stageImmutableObject(root, data, signal) + let target: string + try { + target = targetFor(staged.sha256, staged.bytes) + } catch (error) { + /* v8 ignore start -- The local target callback constructs a validated reference from this function's digest. */ + await removeTemporary(staged.path) + throw error + /* v8 ignore stop */ + } + await publishStagedObject(root, target, staged) + return { sha256: staged.sha256, bytes: staged.bytes } +} + +/** + * Publish another durable hard-link name for an existing immutable object. + * @param root - absolute versioned attachment root. + * @param source - existing content-addressed object below `root`. + * @param target - new alias below `root`. + * @param sha256 - expected object digest for an existing-target race. + */ +export async function publishImmutableAlias( + root: string, + source: string, + target: string, + sha256: string, +): Promise { + const parent = dirname(target) + try { + const boundary = await ensureDurableHome(dirname(dirname(resolve(root)))) + await ensureDurableDirectory(parent, boundary) + try { + await link(source, target) + } catch (error) { + /* v8 ignore next -- Private same-filesystem directories make EEXIST the only recoverable link race. */ + if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error + if (await digestFile(target) !== sha256) { + throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') + } + } + await chmod(target, 0o400) + const stop = resolve(root) + for (let level = parent; level !== stop; level = dirname(level)) { + await syncDirectory(level) + /* v8 ignore next -- filesystem-root guard: targets sit below root, so the walk reaches `stop` first. */ + if (dirname(level) === level) break + } + } catch (error) { + if (error instanceof AttachmentError) throw error + throw new AttachmentError('Unable to persist attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) + } +} + +interface StagedImmutableObject extends StreamedImmutableObject { + readonly path: string + readonly boundary: string +} + +async function stageImmutableObject( + root: string, + data: AsyncIterable | Iterable, + signal?: AbortSignal, +): Promise { const staging = join(root, 'tmp') // Establish DSH_HOME itself against the filesystem root once per process. // Every process performs that proof independently, so observing a directory // another process created can never be mistaken for durable publication. const boundary = await ensureDurableHome(dirname(dirname(resolve(root)))) - await ensureDurableDirectory(bucket, boundary) await ensureDurableDirectory(staging, boundary) const temporary = join(staging, randomUUID()) - const target = normalizedImagePath(root, prepared.ref) let handle try { handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) - await handle.writeFile(normalized) + const hash = createHash('sha256') + let bytes = 0 + for await (const chunk of data) { + signal?.throwIfAborted() + await handle.writeFile(chunk) + hash.update(chunk) + bytes += chunk.byteLength + } + signal?.throwIfAborted() await handle.sync() + signal?.throwIfAborted() await handle.close() handle = undefined - try { - await link(temporary, target) - } catch (error) { - /* v8 ignore next -- Private same-filesystem directories make EEXIST the only recoverable link race. */ - if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error - const existing = new Uint8Array(await readFile(target)) - if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') - } - // Windows shares the read-only attribute across hard links and refuses to - // unlink either name once it is set, so discard the staging name first. - await unlink(temporary) - // The target remains the sole link for a new object; this also restores - // read-only mode when the deduplication path observes an existing object. - await chmod(target, 0o400) - // Persist the target entry and close a concurrent bucket-creation window - // before the reference can reach a session checkpoint. The dedup path - // repeats both syncs because it may observe another writer's link before - // that writer reaches its own durability boundary. - await syncDirectory(bucket) - await syncDirectory(join(root, 'objects')) + return { path: temporary, boundary, sha256: hash.digest('hex'), bytes } } catch (error) { - /* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */ + /* v8 ignore next -- A descriptor remains open only when write, sync, or close fails. */ if (handle !== undefined) await handle.close().catch( /* v8 ignore next -- Close failure is superseded by the storage operation that entered cleanup. */ () => {}, ) - await unlink(temporary).catch( - /* v8 ignore next -- The callback requires a second independent staging-unlink failure. */ - (cleanupError: unknown) => { - /* v8 ignore next -- Cleanup is best-effort only for a staging file already removed by a failed operation. */ - if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError - }, - ) - if (error instanceof AttachmentError) throw error - throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) + await removeTemporary(temporary) + if (error instanceof AttachmentError || signal?.aborted === true) throw error + throw new AttachmentError('Unable to persist attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) } - return prepared.ref +} + +async function publishStagedObject( + root: string, + target: string, + staged: StagedImmutableObject, +): Promise { + const parent = dirname(target) + try { + await ensureDurableDirectory(parent, staged.boundary) + try { + await link(staged.path, target) + } catch (error) { + /* v8 ignore next -- Private same-filesystem directories make EEXIST the only recoverable link race. */ + if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error + if (await digestFile(target) !== staged.sha256) { + throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') + } + } + // Windows shares the read-only attribute across hard links and refuses to + // unlink either name once it is set, so discard the staging name first. + await unlink(staged.path) + // The target remains the sole link for a new object; this also restores + // read-only mode when the deduplication path observes an existing object. + await chmod(target, 0o400) + // Persist the target entry and close every concurrent parent-creation + // window before the reference can reach a session checkpoint. The dedup + // path repeats these syncs because it may observe another writer's link + // before that writer reaches its own durability boundary. + const stop = resolve(root) + for (let level = parent; level !== stop; level = dirname(level)) { + await syncDirectory(level) + /* v8 ignore next -- filesystem-root guard: targets sit below root, so the walk reaches `stop` first. */ + if (dirname(level) === level) break + } + } catch (error) { + await removeTemporary(staged.path) + if (error instanceof AttachmentError) throw error + throw new AttachmentError('Unable to persist attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) + } +} + +async function digestFile(path: string): Promise { + const hash = createHash('sha256') + for await (const chunk of createReadStream(path) as AsyncIterable) hash.update(chunk) + return hash.digest('hex') +} + +async function removeTemporary(path: string): Promise { + await unlink(path).catch( + /* v8 ignore next -- Cleanup can observe a staging name already removed after successful linking. */ + (cleanupError: unknown) => { + /* v8 ignore next -- Any cleanup failure except an absent staging name must remain visible. */ + if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError + }, + ) } /** diff --git a/packages/attachment/attachment-local/tests/file-store.spec.ts b/packages/attachment/attachment-local/tests/file-store.spec.ts new file mode 100644 index 0000000000..dde6ec2d06 --- /dev/null +++ b/packages/attachment/attachment-local/tests/file-store.spec.ts @@ -0,0 +1,278 @@ +import { createHash } from 'node:crypto' +import { chmod, mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { afterEach, describe, expect, it } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { + fileLeafName, readFileStreamVerbatim, saveFileStreamVerbatim, saveFileVerbatim, storedFilePath, +} from '../src/file-store.ts' +import { publishImmutableAlias } from '../src/store.ts' + +const roots: string[] = [] + +async function makeRoot(): Promise { + const root = join(await mkdtemp(join(tmpdir(), 'dsh-file-store-')), 'attachments', 'v1') + roots.push(root) + return root +} + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(join(root, '..', '..'), { recursive: true, force: true, maxRetries: 3 }) + } +}) + +function sha256(data: Uint8Array): string { + return createHash('sha256').update(data).digest('hex') +} + +async function readStream(stream: AsyncIterable): Promise { + const chunks: Uint8Array[] = [] + for await (const chunk of stream) chunks.push(chunk) + return new Uint8Array(Buffer.concat(chunks)) +} + +describe('fileLeafName', () => { + it('keeps ordinary names and strips client paths of both separator styles', () => { + expect(fileLeafName('notes.pdf')).toBe('notes.pdf') + expect(fileLeafName('/home/user/data.csv')).toBe('data.csv') + expect(fileLeafName('C:\\Users\\me\\report.docx')).toBe('report.docx') + }) + + it('removes control characters, rewrites Windows-invalid characters, and bounds UTF-8 length', () => { + expect(fileLeafName('a\u0000b\u001f.txt')).toBe('ab.txt') + expect(fileLeafName('ac:d"e|f?g*h.txt')).toBe('a_b_c_d_e_f_g_h.txt') + expect(fileLeafName(`${'x'.repeat(300)}.bin`).length).toBe(255) + const multibyte = fileLeafName(`${'文'.repeat(100)}.txt`) + expect(Buffer.byteLength(multibyte)).toBeLessThanOrEqual(255) + expect(multibyte.endsWith('\ufffd')).toBe(false) + expect(fileLeafName(`safe-${'x'.repeat(248)}\ud83d\ude00`)).not.toMatch(/\ud83d$/u) + }) + + it('removes Windows trailing characters and protects reserved device names', () => { + expect(fileLeafName('report. ')).toBe('report') + expect(fileLeafName('CON')).toBe('_CON') + expect(fileLeafName('com1.txt')).toBe('_com1.txt') + expect(fileLeafName('con .txt')).toBe('_con .txt') + expect(fileLeafName('com10.txt')).toBe('com10.txt') + }) + + it('falls back to a stable name for absent, empty, and dot-only inputs', () => { + expect(fileLeafName(undefined)).toBe('file') + expect(fileLeafName('')).toBe('file') + expect(fileLeafName(' ')).toBe('file') + expect(fileLeafName('.')).toBe('file') + expect(fileLeafName('..')).toBe('file') + }) +}) + +describe('saveFileVerbatim', () => { + it('stores the exact bytes read-only at a digest-and-name path', async () => { + const root = await makeRoot() + const data = Uint8Array.from([0, 1, 2, 250, 251, 252]) + const ref = await saveFileVerbatim(root, { data, name: 'blob.bin' }) + expect(ref).toEqual({ + attachmentId: AttachmentId(`sha256:${sha256(data)}`), + name: 'blob.bin', + bytes: data.byteLength, + }) + const path = storedFilePath(root, ref) + expect(path.endsWith(join(sha256(data), 'blob.bin'))).toBe(true) + expect(new Uint8Array(await readFile(path))).toEqual(data) + if (process.platform !== 'win32') { + expect((await stat(path)).mode & 0o777).toBe(0o400) + } + }) + + it('accepts a zero-byte file', async () => { + const root = await makeRoot() + const ref = await saveFileVerbatim(root, { data: new Uint8Array(0), name: 'empty.txt' }) + expect(ref.bytes).toBe(0) + expect((await readFile(storedFilePath(root, ref))).byteLength).toBe(0) + }) + + it('stores sanitized Windows-reserved and multibyte names', async () => { + const root = await makeRoot() + const reserved = await saveFileVerbatim(root, { data: new Uint8Array(0), name: 'NUL.txt' }) + const multibyte = await saveFileVerbatim(root, { data: Uint8Array.of(1), name: '文'.repeat(100) }) + expect(reserved.name).toBe('_NUL.txt') + expect(Buffer.byteLength(multibyte.name)).toBeLessThanOrEqual(255) + await expect(readFile(storedFilePath(root, reserved))).resolves.toHaveLength(0) + await expect(readFile(storedFilePath(root, multibyte))).resolves.toEqual(Buffer.from([1])) + }) + + it('deduplicates identical bytes and stores distinct names beside one digest', async () => { + const root = await makeRoot() + const data = Uint8Array.from([7, 7, 7]) + const first = await saveFileVerbatim(root, { data, name: 'a.txt' }) + const again = await saveFileVerbatim(root, { data, name: 'a.txt' }) + expect(again).toEqual(first) + const renamed = await saveFileVerbatim(root, { data, name: 'b.txt' }) + expect(renamed.attachmentId).toBe(first.attachmentId) + expect((await stat(storedFilePath(root, first))).ino) + .toBe((await stat(storedFilePath(root, renamed))).ino) + const digestDir = join(root, 'files', sha256(data).slice(0, 2), sha256(data)) + expect((await readdir(digestDir)).sort()).toEqual(['a.txt', 'b.txt']) + }) + + it('refuses a stored object whose bytes no longer match the digest', async () => { + const root = await makeRoot() + const data = Uint8Array.from([1, 2, 3]) + const ref = await saveFileVerbatim(root, { data, name: 'c.txt' }) + const path = storedFilePath(root, ref) + await chmod(path, 0o600) + await writeFile(path, Uint8Array.from([9, 9, 9])) + await expect(saveFileVerbatim(root, { data, name: 'c.txt' })) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) + + it('rejects a conflicting display-name alias and wraps alias publication failures', async () => { + const root = await makeRoot() + const data = Uint8Array.of(1, 2, 3) + const ref = await saveFileVerbatim(root, { data, name: 'alias.bin' }) + const alias = storedFilePath(root, ref) + await unlink(alias) + await writeFile(alias, Uint8Array.of(9, 9, 9)) + await expect(saveFileVerbatim(root, { data, name: 'alias.bin' })) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + + await expect(publishImmutableAlias( + root, + join(root, 'missing-object'), + join(root, 'files', 'ff', 'missing', 'alias.bin'), + 'f'.repeat(64), + )).rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' }) + }) +}) + +describe('saveFileStreamVerbatim', () => { + it('stores ordered chunks without materializing one aggregate byte array', async () => { + const root = await makeRoot() + const ref = await saveFileStreamVerbatim(root, { + data: (async function* (): AsyncIterable { + yield Uint8Array.of(0, 1) + yield Uint8Array.of(2, 250) + yield Uint8Array.of(251, 252) + })(), + name: 'large.bin', + }) + const expected = Uint8Array.of(0, 1, 2, 250, 251, 252) + expect(ref).toEqual({ + attachmentId: AttachmentId(`sha256:${sha256(expected)}`), + name: 'large.bin', + bytes: expected.byteLength, + }) + expect(new Uint8Array(await readFile(storedFilePath(root, ref)))).toEqual(expected) + }) + + it('removes its staging file when cancellation interrupts the source', async () => { + const root = await makeRoot() + const abort = new AbortController() + const reason = new Error('upload cancelled') + await expect(saveFileStreamVerbatim(root, { + data: (async function* (): AsyncIterable { + yield Uint8Array.of(1, 2) + abort.abort(reason) + yield Uint8Array.of(3, 4) + })(), + signal: abort.signal, + name: 'cancelled.bin', + })).rejects.toBe(reason) + expect(await readdir(join(root, 'tmp'))).toEqual([]) + }) + + it('wraps source failures and removes the staging file', async () => { + const root = await makeRoot() + await expect(saveFileStreamVerbatim(root, { + data: (async function* (): AsyncIterable { + yield Uint8Array.of(1, 2) + throw new Error('source failed') + })(), + name: 'failed.bin', + })).rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' }) + expect(await readdir(join(root, 'tmp'))).toEqual([]) + }) +}) + +describe('readFileStreamVerbatim', () => { + it('returns exact bounded chunks and accepts an empty file', async () => { + const root = await makeRoot() + const data = Uint8Array.from({ length: (1 << 16) + 3 }, (_, index) => index % 251) + const ref = await saveFileVerbatim(root, { data, name: 'large.bin' }) + await expect(readStream(readFileStreamVerbatim(root, ref))).resolves.toEqual(data) + const empty = await saveFileVerbatim(root, { data: new Uint8Array(), name: 'empty.bin' }) + await expect(readStream(readFileStreamVerbatim(root, empty))).resolves.toEqual(new Uint8Array()) + }) + + it('rejects invalid, missing, and unreadable references with storage codes', async () => { + const root = await makeRoot() + const ref: FileAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + name: 'missing.bin', + bytes: 1, + } + await expect(readStream(readFileStreamVerbatim(root, { ...ref, name: '../escape' }))) + .rejects.toMatchObject({ code: 'INVALID_ATTACHMENT_REF' }) + await expect(readStream(readFileStreamVerbatim(root, ref))) + .rejects.toMatchObject({ code: 'ATTACHMENT_NOT_FOUND' }) + + const saved = await saveFileVerbatim(root, { data: Uint8Array.of(1), name: 'unreadable.bin' }) + const path = storedFilePath(root, saved) + await unlink(path) + await mkdir(path) + await expect(readStream(readFileStreamVerbatim(root, saved))) + .rejects.toMatchObject({ code: 'ATTACHMENT_READ_FAILED' }) + }) + + it('detects changed bytes and recorded lengths', async () => { + const root = await makeRoot() + const ref = await saveFileVerbatim(root, { data: Uint8Array.of(1, 2, 3), name: 'data.bin' }) + const path = storedFilePath(root, ref) + await chmod(path, 0o600) + await writeFile(path, Uint8Array.of(3, 2, 1)) + await expect(readStream(readFileStreamVerbatim(root, ref))) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + await writeFile(path, Uint8Array.of(1, 2, 3)) + await expect(readStream(readFileStreamVerbatim(root, { ...ref, bytes: 4 }))) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) + + it('preserves caller cancellation before and during a read', async () => { + const root = await makeRoot() + const ref = await saveFileVerbatim(root, { + data: Uint8Array.from({ length: 1 << 17 }, () => 7), + name: 'cancel.bin', + }) + const before = new AbortController() + const beforeReason = new Error('cancelled before read') + before.abort(beforeReason) + await expect(readStream(readFileStreamVerbatim(root, ref, before.signal))).rejects.toBe(beforeReason) + + const during = new AbortController() + const stream = readFileStreamVerbatim(root, ref, during.signal)[Symbol.asyncIterator]() + await expect(stream.next()).resolves.toMatchObject({ done: false }) + const duringReason = new Error('cancelled during read') + during.abort(duringReason) + await expect(stream.next()).rejects.toBe(duringReason) + }) +}) + +describe('storedFilePath', () => { + it('rejects malformed digests and unsanitized names before deriving a path', () => { + const good: FileAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + name: 'ok.txt', + bytes: 1, + } + expect(() => storedFilePath('/root', { ...good, attachmentId: AttachmentId('sha256:short') })) + .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' }) as Error) + expect(() => storedFilePath('/root', { ...good, name: '../escape.txt' })) + .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' }) as Error) + expect(() => storedFilePath('/root', { ...good, name: 'nested/name.txt' })) + .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' }) as Error) + expect(storedFilePath('/root', good).endsWith(join('a'.repeat(64), 'ok.txt'))).toBe(true) + }) +}) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 4d9d9f350b..5711c6d35d 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -87,6 +87,21 @@ describe('local attachment service', () => { await expect(readFile(hostPath)).resolves.toEqual(Buffer.from(data)) const request = await service.readImageRequest(ref, { maxPixels: 1, maxBytes: 1024 }) expect(request).not.toHaveProperty('access') + + const fileData = Uint8Array.of(0, 1, 2, 255) + const fileRef = await service.saveFile({ data: fileData, name: 'notes.bin' }) + const filePath = service.fileHostPath(fileRef) + expect(filePath).toContain(join('files', String(fileRef.attachmentId).slice(7, 9))) + await expect(readFile(filePath)).resolves.toEqual(Buffer.from(fileData)) + + const streamRef = await service.saveFileStream({ + data: (async function* (): AsyncIterable { yield fileData })(), + name: 'stream.bin', + }) + await expect(readFile(service.fileHostPath(streamRef))).resolves.toEqual(Buffer.from(fileData)) + const streamed: Uint8Array[] = [] + for await (const chunk of service.readFileStream(streamRef)) streamed.push(chunk) + expect(Buffer.concat(streamed)).toEqual(Buffer.from(fileData)) } finally { await rm(dshHome, { recursive: true, force: true }) } diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 3ff58eb2da..82c2b9f3ba 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { constants } from 'node:fs' -import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { chmod, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, parse, resolve } from 'node:path' import { mkdtemp, rm } from 'node:fs/promises' @@ -8,7 +8,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import sharp from 'sharp' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import type { NormalizationPolicy } from '../src/normalization.ts' -import { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile } from '../src/store.ts' +import { + commitPreparedImageFile, + prepareImageFile, + publishImmutableObject, + readImageFile, + saveImageFile, +} from '../src/store.ts' const fsControl = vi.hoisted(() => ({ readSignals: [] as AbortSignal[], @@ -89,12 +95,13 @@ describe('local attachment store', () => { // Later directory creation can then stop at that process-proven boundary. expect(fsControl.syncedDirectories).toEqual([ ...parentChainToRoot(base), - // bucket chain: every parent entry between the bucket and the boundary. - objects, + // Staging precedes publication because the streamed digest selects the + // target bucket only after every byte has been written. storageRoot, join(storageRoot, '..'), base, - // staging chain re-walks the shared ancestors after creating tmp. + // bucket chain: every parent entry between the bucket and the boundary. + objects, storageRoot, join(storageRoot, '..'), base, @@ -141,6 +148,14 @@ describe('local attachment store', () => { await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) }) + it('rejects publication when the supplied digest does not match the staged bytes', async () => { + const storageRoot = await root() + const target = join(storageRoot, 'objects', '00', 'mismatch') + await expect(publishImmutableObject(storageRoot, target, Uint8Array.of(1), '0'.repeat(64))) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + expect(await readdir(join(storageRoot, 'tmp'))).toEqual([]) + }) + it.skipIf(process.platform !== 'win32')('publishes a new object on Windows', async () => { const storageRoot = await root() diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 83ba7dbc72..d92fc02572 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/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/attachment/attachment/README.md -README.md: 9561ab4265eb0404f6f60c93c34dab73eed5ffe1 -README.zh.md: 31fcb15c04fadb44c44342e3d1d721d22645732f +README.md: a812182aeff6d09506a1ea2d4fa8d9a44a175936 +README.zh.md: c487f8204c86d8f0bbdfd85280e8fbab6ea14dec diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 9561ab4265..a812182aef 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -1,5 +1,5 @@ --- -description: "Durable image attachments for users and maintainers attaching, reusing, or debugging images in prompts and commands." +description: "Durable image and file attachments for users and maintainers attaching, reusing, or debugging uploads in prompts and commands." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -You can attach images to prompts and commands, and the harness keeps provider-independent normalized versions durably: each source image is admitted and normalized before your message is processed, reappears in conversation history, and is projected to the selected model route in later turns of the same session. The shipped `dsh` composition enables this with no setup. Attached images survive restarts, while browser paths, provider URLs, local storage paths, and base64 never enter durable session events. Only raster formats (PNG, JPEG, WebP, GIF) are accepted, and unsent composer drafts stay in the browser until you submit. Stored images are never deleted automatically, and non-image files, audio, and video are not supported yet. +You can attach images and generic files to prompts, and the harness keeps them durably: each source image is admitted and normalized before your message is processed, while any other file is stored byte-for-byte with no format or size limits, and both reappear in conversation history across restarts of the same session. The shipped `dsh` composition enables this with no setup. Browser paths, provider URLs, local storage paths, and base64 never enter durable session events. Images accept raster formats (PNG, JPEG, WebP, GIF) under deployment limits; files accept anything, and the model reads a stored file on demand from its saved read-only path instead of receiving its bytes. Stored objects are never deleted automatically, and audio and video have no dedicated handling yet. ## Table of Contents @@ -35,9 +35,13 @@ Attach one or more images to a user prompt in the client UI. Each source is chec - name: '@deepseek-ai/dsh-attachment-local' ``` -### Pass images to commands +### Attach any other file to a prompt -Commands that accept image input receive attached images the same way. If a command does not accept images, the harness refuses with an error message instead of silently dropping them. +Any non-image file attaches to a prompt as a generic file: the exact bytes are saved read-only under the harness home, the message records the file name, byte size, and content digest, and the model receives one line naming the saved path so it can read the content with its file tools only when needed. There is no file-type whitelist and no size limit; what you attach is stored verbatim. + +### Pass attachments to commands + +Commands declaring attachment input receive images and generic files in selection order. Commands that do not accept attachments return an error and retain the composer's draft and cards. ### Reuse images across the session @@ -63,11 +67,12 @@ This section explains the design decisions behind the seam and the service opera - **Immutable and retention-neutral.** Objects are immutable once published; resumed and forked sessions may share them, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. - **Verify on read.** Reads check bytes and metadata against the logged reference before returning them, and request projections fully decode cached bytes, so a missing, corrupted, or swapped object fails closed. - **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions with explicit pixel and byte budgets, while execution filesystems may map the immutable host object to a model-readable process path. -- **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers route on `code`, never on the prototype chain. +- **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers use `isAttachmentError` and route on `code`, never on the prototype chain. +- **Files are verbatim, images are normalized.** `saveFile` commits an existing byte array, `saveFileStream` commits bounded chunks with backpressure and cancellation, `readFileStream` verifies and returns bounded chunks, and `fileHostPath` locates the stored object for read-on-demand projection; neither file write path applies admission limits. The image path keeps its separate normalization, limits, and request-version pipeline. The `FileBlock` content block in `dsh-llm` carries a `FileAttachmentRef`, and request assembly projects it to deterministic handle text for every route. ### Service operations -The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). +The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. Host prompt consumers pass ordered text, encoded images, and already resolved file references to `ctx.attachments.admitPromptContent()`; the method persists images and passes file references unchanged. Encoded protocol adapters call `ctx.attachments.admitEncodedFile()`, which checks canonical base64 before delegating to `saveFile`; adapters recognize attachment failures through `ctx.attachments.isAttachmentError()`. Generic-file callers choose `saveFile` for existing bytes or `saveFileStream` for a bounded asynchronous byte source; both return the same durable reference, while `readFileStream` verifies its digest and length during a bounded read. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads, streamed writes, and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). ### Source map @@ -75,7 +80,7 @@ The service family runs one admission-and-storage flow: every entry point enforc |---|---| | [`src/index.ts`](src/index.ts) | Plugin entry: abstract `AttachmentStore` service and re-exports | | [`src/types.ts`](src/types.ts) | Durable vocabulary: references, limits, upload and store payloads | -| [`src/admission.ts`](src/admission.ts) | Browser prompt admission: canonical-base64 enforcement, `saveImages` delegation, and durable prompt-part projection | +| [`src/admission.ts`](src/admission.ts) | Canonical-base64 enforcement and store delegation for encoded image and file uploads | | [`src/error.ts`](src/error.ts) | `AttachmentError` class and the `isImageAdmissionError` runtime subset | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` branded opaque identifier | | — | No runtime invariant companion is published; this stateless seam owns types while implementations enforce immutable-store checks. | @@ -98,7 +103,7 @@ For the full service contract and payload types, read the subsystem reference; f ## Model Experience -Indirectly, through the provider adapter, which resolves each durable reference into an exact request version and sends its stable attachment id and actual dimensions beside the image. When the execution filesystem maps the stored object, the descriptor also includes a read-only process path and a matching extension for a writable copy. +Indirectly, through the provider adapter, which resolves each durable image reference into an exact request version and sends its stable attachment id and actual dimensions beside the image. When the execution filesystem maps the stored object, the descriptor also includes a read-only process path and a matching extension for a writable copy. A generic file never reaches the provider as bytes: every route receives one deterministic handle line naming the file, its byte size, its digest prefix, and the saved read-only path to read with file tools. #### KV Cache effect @@ -111,8 +116,8 @@ Adding an image changes the provider request and therefore invalidates the affec These limits describe what image attachments can and cannot do; they are current package constraints, not a task backlog. -- **Raster images only** — PNG, JPEG, WebP, and GIF are accepted; generic files, audio, and video are not supported yet. -- **Images are never deleted** — stored images are retained indefinitely; nothing removes them automatically. +- **Raster image limits apply to images only** — PNG, JPEG, WebP, and GIF are accepted as images under deployment limits; every other file is stored verbatim with no type or size limit, and audio and video have no dedicated handling yet. +- **Attachments are never deleted** — stored images and files are retained indefinitely; nothing removes them automatically. - **Unsent drafts are not saved** — a composer draft stays in the browser until you submit the message. @@ -127,8 +132,8 @@ This Dev Note is working context for maintainers: undecided directions and open Resumed and forked sessions may share immutable objects, so any retention policy needs a reference model that accounts for session lineage before objects can be collected. No decision is recorded yet; the local backend currently retains everything. -#### Future: non-image attachments and assistant-side output +#### Future: audio, video, and assistant-side output -Generic files, audio, and video would need separate lifecycle and provider contracts, and the role-neutral `ImageBlock` leaves assistant-side image output as forward compatibility — current production adapters declare text-only output, so only user content carries images. Both directions are undecided. +Audio and video would need dedicated lifecycle and provider contracts beyond the verbatim file path, and the role-neutral `ImageBlock` leaves assistant-side image output as forward compatibility — current production adapters declare text-only output, so only user content carries images. Both directions are undecided. diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 31fcb15c04..c487f8204c 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -1,5 +1,5 @@ --- -description: "持久图片附件,供用户与维护者在提示词与命令中附加、复用或排查图片。" +description: "持久图片与文件附件,供用户与维护者在提示词与命令中附加、复用或排查上传内容。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -你可以把图片附加到提示词和命令中,harness 会持久保存提供方无关的规范化版本:每张源图都会在你的消息被处理前准入并规范化,重新出现在对话历史中,并在同一会话的后续轮次投影到所选模型路由。随附的 `dsh` 组合无需任何配置即可支持这一点。已附加的图片在重启后依然存在,而浏览器路径、提供方 URL、本地存储路径与 base64 绝不会进入持久会话事件。只接受光栅格式(PNG、JPEG、WebP、GIF),未发送的输入区草稿在提交前仍留在浏览器中。已存储的图片永远不会被自动删除,通用文件、音频和视频暂不支持。 +你可以把图片和通用文件附加到提示词中,harness 会持久保存它们:每张源图都会在你的消息被处理前准入并规范化,而其他任何文件都按字节原样保存、不设格式与大小限制,两者都会在同一会话重启后重新出现在对话历史中。随附的 `dsh` 组合无需任何配置即可支持这一点。浏览器路径、提供方 URL、本地存储路径与 base64 绝不会进入持久会话事件。图片接受部署限额内的光栅格式(PNG、JPEG、WebP、GIF);文件接受任何内容,模型不接收文件字节,而是在需要时从保存的只读路径按需读取。已存储对象永远不会被自动删除,音频和视频暂无专门处理。 ## 目录 @@ -35,9 +35,13 @@ kind: "package-reference" - name: '@deepseek-ai/dsh-attachment-local' ``` -### 把图片传给命令 +### 在提示词中附加任意其他文件 -接受图片输入的命令以相同方式接收附加图片。如果某个命令不接受图片,harness 会以错误消息拒绝,而不是静默丢弃。 +任何非图片文件都以通用文件的形式附加到提示词:确切字节被只读保存在 harness 主目录下,消息记录文件名、字节数与内容摘要,模型收到一行指出保存路径的文本,只在需要时用文件工具读取内容。没有文件类型白名单,也没有大小上限;你附加什么就原样存什么。 + +### 把附件传给命令 + +声明接受附件的命令会按选择顺序接收图片与通用文件。不接受附件的命令会返回错误,并保留 composer 的草稿与附件卡。 ### 在整个会话中复用图片 @@ -63,11 +67,12 @@ kind: "package-reference" - **不可变且保留策略中立。** 对象一经发布即不可变;恢复和 fork 后的会话可能共享它们,因此引用感知的垃圾回收被推迟,而不是与任何单个会话的删除绑定。 - **读取时校验。** 读取在返回前把字节和元数据与记录的引用比对,请求投影还会完整解码缓存字节,因此缺失、损坏或被替换的对象都会失败关闭。 - **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器以显式像素与字节预算把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 -- **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方按 `code` 路由,绝不依赖原型链。 +- **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方用 `isAttachmentError` 识别错误并按 `code` 路由,绝不依赖原型链。 +- **文件原样,图片规范化。**`saveFile` 提交已有字节数组,`saveFileStream` 以背压和取消语义提交有界分块,`readFileStream` 校验并返回有界分块,`fileHostPath` 定位存储对象供按需读取投影;两种文件写入路径都不设准入限制。图片路径保留其独立的规范化、限额与请求版本流水线。`dsh-llm` 中的 `FileBlock` 内容块承载 `FileAttachmentRef`,请求组装把它对每条路由都投影成确定性 handle 文本。 ### 服务操作 -服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 +服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。Host prompt 消费方把有序文本、编码图片和已经解析的文件引用交给 `ctx.attachments.admitPromptContent()`;该方法持久化图片,并让文件引用原样通过。编码协议适配器调用 `ctx.attachments.admitEncodedFile()`,由该方法检查规范 base64 后委托给 `saveFile`;适配器通过 `ctx.attachments.isAttachmentError()` 识别附件错误。通用文件调用方可以用 `saveFile` 提交已有字节,或用 `saveFileStream` 提交有界异步字节源;两者返回相同的持久引用,`readFileStream` 则在有界读取过程中校验摘要与长度。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取、流式写入和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 ### 源码地图 @@ -75,7 +80,7 @@ kind: "package-reference" |---|---| | [`src/index.ts`](src/index.ts) | 插件入口:抽象 `AttachmentStore` 服务与再导出 | | [`src/types.ts`](src/types.ts) | 持久词汇:引用、限额、上传与存储载荷 | -| [`src/admission.ts`](src/admission.ts) | 浏览器 prompt 准入:强制规范 base64、委托 `saveImages` 并投影持久 prompt part | +| [`src/admission.ts`](src/admission.ts) | 对编码图片和文件上传强制执行规范 base64 并委托存储 | | [`src/error.ts`](src/error.ts) | `AttachmentError` 类与 `isImageAdmissionError` 运行时子集 | | [`src/brand.ts`](src/brand.ts) | `AttachmentId` 带类型标记的不透明标识符 | | — | 不发布运行时不变式伴生入口;实现负责强制不可变存储检查。 | @@ -98,7 +103,7 @@ kind: "package-reference" ## 模型体验 -该包通过提供方适配器间接影响模型;适配器会把每个持久引用解析为确切请求版本,并在图片旁发送稳定附件 id 与实际尺寸。执行文件系统可以映射已存对象时,描述符还会包含只读进程路径,以及可写副本使用的匹配扩展名。 +该包通过提供方适配器间接影响模型;适配器会把每个持久图片引用解析为确切请求版本,并在图片旁发送稳定附件 id 与实际尺寸。执行文件系统可以映射已存对象时,描述符还会包含只读进程路径,以及可写副本使用的匹配扩展名。通用文件的字节永远不会到达提供方:每条路由都收到一行确定性 handle 文本,指出文件名、字节数、摘要前缀,以及供文件工具读取的只读保存路径。 #### KV Cache 影响 @@ -109,10 +114,10 @@ kind: "package-reference" -这些限制描述了图片附件能做什么、不能做什么;它们是当前包约束,而非任务积压。 +这些限制描述了附件能做什么、不能做什么;它们是当前包约束,而非任务积压。 -- **仅支持光栅图片**——接受 PNG、JPEG、WebP 与 GIF;通用文件、音频和视频暂不支持。 -- **图片永远不会被删除**——已存储的图片无限期保留;没有任何机制自动移除它们。 +- **光栅图片限制只作用于图片**——图片接受部署限额内的 PNG、JPEG、WebP 与 GIF;其他任何文件都原样保存、不设类型与大小限制,音频和视频暂无专门处理。 +- **附件永远不会被删除**——已存储的图片与文件无限期保留;没有任何机制自动移除它们。 - **未发送的草稿不会保存**——输入区草稿在提交消息前一直留在浏览器中。 @@ -127,8 +132,8 @@ kind: "package-reference" 恢复和 fork 后的会话可能共享不可变对象,因此任何保留策略都需要一个能考虑会话血缘的引用模型,之后才能回收对象。目前尚未记录任何决定;本地后端当前保留一切。 -#### 未来:非图片附件与助手侧输出 +#### 未来:音频、视频与助手侧输出 -通用文件、音频与视频需要单独的生命周期与提供方契约;角色无关的 `ImageBlock` 也把助手侧图片输出留作前瞻兼容——当前生产适配器声明只输出文本,因此只有用户内容携带图片。两个方向都尚未决定。 +音频与视频需要原样文件路径之外的专门生命周期与提供方契约;角色无关的 `ImageBlock` 也把助手侧图片输出留作前瞻兼容——当前生产适配器声明只输出文本,因此只有用户内容携带图片。两个方向都尚未决定。 diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 087ac8c2d0..bd49889f69 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/src/admission.ts b/packages/attachment/attachment/src/admission.ts index 155b3644d8..8a5b5ab41b 100644 --- a/packages/attachment/attachment/src/admission.ts +++ b/packages/attachment/attachment/src/admission.ts @@ -4,22 +4,29 @@ import { Buffer } from 'node:buffer' import { AttachmentError } from './error.ts' import type { AttachmentStore } from './index.ts' import type { - AdmittedPromptContentPart, + EncodedFileAttachment, EncodedImageAttachment, + FileAttachmentRef, ImageAttachmentRef, - PromptContentPart, SaveImageAttachment, } from './types.ts' /** Decode one upload payload while rejecting non-canonical base64 forms. */ -function decodeBase64(data: string): Uint8Array { +function decodeCanonicalBase64(data: string, empty: 'reject' | 'accept', code: 'INVALID_IMAGE_BASE64' | 'INVALID_FILE_BASE64'): Uint8Array { const decoded = Buffer.from(data, 'base64') - if (data.length === 0 || decoded.toString('base64') !== data) { - throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') + if ((data.length === 0 && empty === 'reject') || decoded.toString('base64') !== data) { + throw new AttachmentError( + code === 'INVALID_IMAGE_BASE64' ? 'Image upload is not canonical base64.' : 'File upload is not canonical base64.', + code, + ) } return new Uint8Array(decoded) } +function decodeBase64(data: string): Uint8Array { + return decodeCanonicalBase64(data, 'reject', 'INVALID_IMAGE_BASE64') +} + /** Store input for one decoded upload. */ function saveInput(image: EncodedImageAttachment): SaveImageAttachment { return { @@ -47,24 +54,21 @@ export async function admitEncodedImages( } /** - * Admit one browser prompt and replace each uploaded image with its durable reference. - * Text-only prompts do not access the attachment store. - * @param attachments - the deployment attachment store owning batch policy. - * @param content - browser prompt parts in message order. - * @returns admitted prompt parts in the same order as `content`. - * @throws AttachmentError when the image batch is refused. + * Admit one wire file upload: enforce canonical base64 (an empty file is a + * valid zero-byte payload), then delegate verbatim commit to + * {@link AttachmentStore.saveFile}. The shared entry for every RPC endpoint + * accepting browser file uploads. + * @param attachments - the deployment attachment store. + * @param file - base64-encoded upload and optional display name. + * @returns the durable content-addressed file reference. + * @throws AttachmentError on a non-canonical payload or a storage failure. */ -export async function admitPromptContent( +export async function admitEncodedFile( attachments: AttachmentStore, - content: readonly PromptContentPart[], -): Promise { - if (content.every(part => part.type === 'text')) { - return content.map(part => ({ type: 'text', text: part.text })) - } - const refs = await admitEncodedImages(attachments, content.filter(part => part.type === 'image')) - let next = 0 - return content.map(part => part.type === 'text' - ? { type: 'text', text: part.text } - // admitEncodedImages returns one reference per image part in order. - : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) + file: EncodedFileAttachment, +): Promise { + return attachments.saveFile({ + data: decodeCanonicalBase64(file.data, 'accept', 'INVALID_FILE_BASE64'), + ...file.name === undefined ? {} : { name: file.name }, + }) } diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts index c19229872b..0aed793f35 100644 --- a/packages/attachment/attachment/src/error.ts +++ b/packages/attachment/attachment/src/error.ts @@ -15,18 +15,24 @@ const IMAGE_ADMISSION_ERROR_CODES = [ /** Caller-correctable attachment failure codes raised while admitting image input. */ export type ImageAdmissionErrorCode = typeof IMAGE_ADMISSION_ERROR_CODES[number] +const ATTACHMENT_ERROR_CODES = [ + ...IMAGE_ADMISSION_ERROR_CODES, + 'INVALID_FILE_BASE64', + 'INVALID_ATTACHMENT_REF', + 'ATTACHMENT_CORRUPT', + 'ATTACHMENT_WRITE_FAILED', + 'ATTACHMENT_NOT_FOUND', + 'ATTACHMENT_READ_FAILED', + 'ATTACHMENT_PROJECTION_UNSUPPORTED', + 'ATTACHMENT_FILES_UNSUPPORTED', +] as const + /** Stable attachment failure codes used for protocol error routing. */ -export type AttachmentErrorCode = - | ImageAdmissionErrorCode - | 'INVALID_ATTACHMENT_REF' - | 'ATTACHMENT_CORRUPT' - | 'ATTACHMENT_WRITE_FAILED' - | 'ATTACHMENT_NOT_FOUND' - | 'ATTACHMENT_READ_FAILED' - | 'ATTACHMENT_PROJECTION_UNSUPPORTED' +export type AttachmentErrorCode = typeof ATTACHMENT_ERROR_CODES[number] /** Runtime membership for structurally compatible errors crossing package boundaries. */ const IMAGE_ADMISSION_ERROR_CODE_SET: ReadonlySet = new Set(IMAGE_ADMISSION_ERROR_CODES) +const ATTACHMENT_ERROR_CODE_SET: ReadonlySet = new Set(ATTACHMENT_ERROR_CODES) /** * Stable failures suitable for host RPC error mapping. @@ -53,6 +59,18 @@ export class AttachmentError extends Error { } } +/** + * Identify attachment failures by their stable code across duplicate package installations. + * @param error - failure raised while validating, persisting, or reading an attachment. + * @returns whether the failure carries a recognized attachment error code. + */ +export function isAttachmentError(error: unknown): error is AttachmentError { + return error instanceof Error + && 'code' in error + && typeof error.code === 'string' + && ATTACHMENT_ERROR_CODE_SET.has(error.code) +} + /** * Distinguish caller-correctable image admission failures from storage faults. * @param error - failure raised while validating or persisting an image batch. diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 9dcddeae65..5df42a8bda 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -1,31 +1,43 @@ /** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */ import { Context, Service } from '@deepseek-ai/cordis' -import { AttachmentError } from './error.ts' +import { admitEncodedFile as admitFileInput, admitEncodedImages } from './admission.ts' +import { AttachmentError, isAttachmentError as matchesAttachmentError } from './error.ts' import type { + AdmittedPromptContentPart, + AttachmentAdmissionPart, + EncodedFileAttachment, + FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, RequestImageAttachment, + SaveFileAttachment, + SaveFileStreamAttachment, SaveImageAttachment, StoredImageAttachment, } from './types.ts' export { AttachmentId, ImageVariantId } from './brand.ts' -export { AttachmentError, isImageAdmissionError } from './error.ts' +export { AttachmentError, isAttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' -export { admitEncodedImages, admitPromptContent } from './admission.ts' +export { admitEncodedFile, admitEncodedImages } from './admission.ts' export { requestImageDimensions } from './request-projection.ts' export type { AttachmentId as AttachmentIdType, AdmittedPromptContentPart, + AttachmentAdmissionPart, + EncodedFileAttachment, EncodedImageAttachment, + FileAttachmentRef, ImageAttachmentLimits, ImageAttachmentRef, ImageRequestPolicy, ImageMediaType, PromptContentPart, RequestImageAttachment, + SaveFileAttachment, + SaveFileStreamAttachment, SaveImageAttachment, StoredImageAttachment, } from './types.ts' @@ -91,6 +103,49 @@ export abstract class AttachmentStore extends Service { return refs } + /** + * Admit one Host prompt and replace each uploaded image with its durable reference. + * Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation. + * @param content - prompt parts in message order after file receipt resolution. + * @returns admitted prompt parts in the same order as `content`. + * @throws AttachmentError when the image batch is refused. + */ + async admitPromptContent( + content: readonly AttachmentAdmissionPart[], + ): Promise { + if (content.every(part => part.type !== 'image')) { + return content.map(part => part.type === 'text' + ? { type: 'text', text: part.text } + : { type: 'file', attachment: part.attachment }) + } + const refs = await admitEncodedImages(this, content.filter(part => part.type === 'image')) + let next = 0 + return content.map((part) => { + if (part.type === 'text') return { type: 'text', text: part.text } + if (part.type === 'file') return { type: 'file', attachment: part.attachment } + return { type: 'image', attachment: refs[next++] as ImageAttachmentRef } + }) + } + + /** + * Decode and durably commit one canonical base64 file upload. + * @param input - canonical base64 bytes and optional display name. + * @returns the durable content-addressed file reference. + * @throws AttachmentError when the encoding or storage operation is refused. + */ + admitEncodedFile(input: EncodedFileAttachment): Promise { + return admitFileInput(this, input) + } + + /** + * Identify a failure emitted by this attachment capability by its stable code. + * @param error - value caught from an attachment operation. + * @returns whether the value is an attachment failure. + */ + isAttachmentError(error: unknown): error is AttachmentError { + return matchesAttachmentError(error) + } + /** * Validate and durably commit one image before its owning session event is appended. * The returned reference describes the persisted normalized image. When @@ -121,6 +176,68 @@ export abstract class AttachmentStore extends Service { return undefined } + /** + * Durably commit one file byte-for-byte before its owning session event is + * appended. Files carry no admission limits: any byte content and length is + * accepted, and the stored object is the exact submitted bytes. Backends + * without verbatim file storage keep this default rejection. + * @param input - exact bytes and optional display name. + * @returns the durable content-addressed file reference. + */ + saveFile(input: SaveFileAttachment): Promise { + void input + return Promise.reject(new AttachmentError( + 'The mounted attachment provider cannot store verbatim files.', + 'ATTACHMENT_FILES_UNSUPPORTED', + )) + } + + /** + * Durably commit one file byte-for-byte from bounded chunks. Providers must + * apply backpressure and must not collect the complete file in memory. + * Backends without streamed verbatim storage keep this default rejection. + * @param input - ordered exact bytes, optional cancellation, and display name. + * @returns the durable content-addressed file reference. + */ + saveFileStream(input: SaveFileStreamAttachment): Promise { + void input + return Promise.reject(new AttachmentError( + 'The mounted attachment provider cannot stream verbatim files.', + 'ATTACHMENT_FILES_UNSUPPORTED', + )) + } + + /** + * Read and verify one verbatim stored file as bounded chunks. Providers must + * not collect the complete file in memory. Backends without verbatim file + * reads keep this default rejection. + * @param ref - durable reference from the session log. + * @param signal - optional cancellation for backend reads and verification work. + * @returns exact file bytes in order; integrity failures reject the iteration. + */ + async *readFileStream( + ref: FileAttachmentRef, + signal?: AbortSignal, + ): AsyncIterable { + signal?.throwIfAborted() + void ref + await Promise.reject(new AttachmentError( + 'The mounted attachment provider cannot read verbatim files.', + 'ATTACHMENT_FILES_UNSUPPORTED', + )) + } + + /** + * Locate the verbatim stored file object in the harness host filesystem. + * @param ref - durable file reference. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. + * @throws an AttachmentError when the durable reference is invalid. + */ + fileHostPath(ref: FileAttachmentRef): string | undefined { + void ref + return undefined + } + /** * Generate or read one deterministic model-request version from the stored normalized image. * @param ref - durable provider-independent normalized attachment reference. diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 7a55c6a68f..a69b2363dc 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -31,6 +31,45 @@ export interface ImageAttachmentRef { } } +/** + * Durable, serializable reference to one verbatim stored file. Files are + * stored byte-for-byte with no normalization; `attachmentId` is the sha256 + * digest of exactly those bytes. + */ +export interface FileAttachmentRef { + /** Opaque content-addressed storage identifier; never a filesystem path or bearer URL. */ + attachmentId: AttachmentId + /** Sanitized display filename, also the stored object's leaf name. */ + name: string + /** Exact byte length. */ + bytes: number +} + +/** Base64-encoded file upload accompanying one wire request. */ +export interface EncodedFileAttachment { + /** Canonical base64 encoding of the file bytes. */ + data: string + /** Optional display name; it is never interpreted as a path. */ + name?: string +} + +/** Request to durably commit one file verbatim. */ +export interface SaveFileAttachment { + data: Uint8Array + /** Optional browser/provider display name; it is never interpreted as a path. */ + name?: string +} + +/** Request to durably commit one file from bounded byte chunks. */ +export interface SaveFileStreamAttachment { + /** Exact file bytes in order; providers must not retain the complete sequence in memory. */ + data: AsyncIterable + /** Optional cancellation for source reads and storage writes. */ + signal?: AbortSignal + /** Optional browser/provider display name; it is never interpreted as a path. */ + name?: string +} + /** Deployment-resolved limits used by upload admission and request buffering. */ export interface ImageAttachmentLimits { maxImageBytes: number @@ -55,7 +94,7 @@ export interface EncodedImageAttachment { /** * Browser-submitted prompt content accepted by Host prompt endpoints; the * accepting Host promotes image parts to durable references through - * `admitPromptContent` before any message is created, so a wire caller can + * `ctx.attachments.admitPromptContent()` before any message is created, so a wire caller can * never cite an attachment it did not upload. */ export type PromptContentPart = @@ -67,10 +106,16 @@ export type PromptContentPart = readonly name?: string } -/** Host-admitted prompt content with each uploaded image replaced by its durable reference. */ +/** Host prompt content whose file receipts are resolved and whose image bytes await admission. */ +export type AttachmentAdmissionPart = + | PromptContentPart + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + +/** Host-admitted prompt content with every attachment represented by its durable reference. */ export type AdmittedPromptContentPart = | { readonly type: 'text'; readonly text: string } | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } /** Request to validate and durably commit one image. */ export interface SaveImageAttachment { diff --git a/packages/attachment/attachment/tests/admission.spec.ts b/packages/attachment/attachment/tests/admission.spec.ts index ba24d8fa20..71c56c0fed 100644 --- a/packages/attachment/attachment/tests/admission.spec.ts +++ b/packages/attachment/attachment/tests/admission.spec.ts @@ -1,13 +1,19 @@ import { describe, expect, it, vi } from 'vitest' -import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import { admitEncodedImages, admitPromptContent } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment/types' +import AttachmentStore, { admitEncodedFile, admitEncodedImages } from '@deepseek-ai/dsh-attachment' +import type { + FileAttachmentRef, ImageAttachmentRef, SaveImageAttachment, +} from '@deepseek-ai/dsh-attachment/types' const PNG = 'AAAA' // canonical base64, 3 bytes +const FILE_REF: FileAttachmentRef = { + attachmentId: 'file-1' as FileAttachmentRef['attachmentId'], + name: 'notes.md', + bytes: 12, +} /** Delegation double: records the exact saveImages batch and answers ordered refs. */ function storeOf() { - const store = { + const mocks = { saveImages: vi.fn((inputs: readonly SaveImageAttachment[]) => Promise.resolve(inputs.map((input, index): ImageAttachmentRef => ({ attachmentId: `att-${index + 1}` as ImageAttachmentRef['attachmentId'], mediaType: input.mediaType, @@ -17,7 +23,8 @@ function storeOf() { ...input.name === undefined ? {} : { name: input.name }, })))), } - return { store: store as unknown as AttachmentStore, mocks: store } + const store = Object.setPrototypeOf(mocks, AttachmentStore.prototype) as AttachmentStore + return { store, mocks } } describe('admitEncodedImages', () => { @@ -65,22 +72,68 @@ describe('admitEncodedImages', () => { }) }) -describe('admitPromptContent', () => { - it('converts text-only prompts without touching the attachment store', async () => { - const store = { saveImages: () => { throw new Error('text-only prompts must not reach the store') } } - await expect(admitPromptContent(store as unknown as AttachmentStore, [ - { type: 'text', text: 'hello' }, - ])).resolves.toEqual([{ type: 'text', text: 'hello' }]) +describe('admitEncodedFile', () => { + /** Delegation double: records the exact saveFile input and answers a fixed ref. */ + function fileStoreOf() { + const store = { + saveFile: vi.fn((input: { data: Uint8Array; name?: string }) => Promise.resolve({ + attachmentId: 'file-1' as never, + name: input.name ?? 'file', + bytes: input.data.byteLength, + })), + } + return { store: store as unknown as AttachmentStore, mocks: store } + } + + it('decodes canonical base64 and delegates verbatim commit to saveFile', async () => { + const { store, mocks } = fileStoreOf() + const ref = await admitEncodedFile(store, { data: 'AAAA', name: 'blob.bin' }) + expect(mocks.saveFile).toHaveBeenCalledTimes(1) + const input = mocks.saveFile.mock.calls[0]?.[0] as { data: Uint8Array; name?: string } + expect([input.name, input.data.byteLength]).toEqual(['blob.bin', 3]) + expect(ref.bytes).toBe(3) }) - it('replaces image parts with admitted references in part order', async () => { + it('accepts an empty payload as a zero-byte file and omits an absent name', async () => { + const { store, mocks } = fileStoreOf() + const ref = await admitEncodedFile(store, { data: '' }) + const input = mocks.saveFile.mock.calls[0]?.[0] as object + expect('name' in input).toBe(false) + expect(ref.bytes).toBe(0) + }) + + it('rejects non-canonical base64 without touching the store', async () => { + const { store, mocks } = fileStoreOf() + await expect(admitEncodedFile(store, { data: 'not base64!!' })) + .rejects.toMatchObject({ code: 'INVALID_FILE_BASE64' }) + expect(mocks.saveFile).not.toHaveBeenCalled() + }) +}) + +describe('AttachmentStore.admitPromptContent', () => { + it('passes through text and durable files without touching image storage', async () => { + const store = Object.setPrototypeOf({ + saveImages: () => { throw new Error('prompts without image uploads must not reach the store') }, + }, AttachmentStore.prototype) as AttachmentStore + await expect(store.admitPromptContent([ + { type: 'text', text: 'hello' }, + { type: 'file', attachment: FILE_REF }, + ])).resolves.toEqual([ + { type: 'text', text: 'hello' }, + { type: 'file', attachment: FILE_REF }, + ]) + }) + + it('replaces images and passes through files in part order', async () => { const { store } = storeOf() - await expect(admitPromptContent(store, [ + await expect(store.admitPromptContent([ { type: 'image', mediaType: 'image/png', data: 'AQ==' }, + { type: 'file', attachment: FILE_REF }, { type: 'text', text: 'between' }, { type: 'image', mediaType: 'image/png', data: 'Ag==' }, ])).resolves.toEqual([ { type: 'image', attachment: { attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'file', attachment: FILE_REF }, { type: 'text', text: 'between' }, { type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, ]) diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 8675d45404..923c1a4360 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -4,11 +4,13 @@ import AttachmentStore, { AttachmentError, AttachmentId, ImageVariantId, + isAttachmentError, isImageAdmissionError, type ImageAttachmentRef, type ImageMediaType, type ImageRequestPolicy, type RequestImageAttachment, + type SaveFileAttachment, type SaveImageAttachment, type StoredImageAttachment, } from '../src/index.ts' @@ -88,6 +90,19 @@ class UnsupportedProjectionStore extends AttachmentStore { } } +class RecordingFileStore extends RecordingStore { + fileInput: SaveFileAttachment | undefined + + override saveFile(input: SaveFileAttachment) { + this.fileInput = input + return Promise.resolve({ + attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`), + name: input.name ?? 'unnamed', + bytes: input.data.byteLength, + }) + } +} + function image(value: number, mediaType: ImageMediaType = 'image/png'): SaveImageAttachment { return { data: Uint8Array.of(value), mediaType, name: `${value}.png` } } @@ -147,10 +162,47 @@ describe('AttachmentStore.readImageRequest', () => { expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason) }) - it('exposes no provider-owned host path by default', async () => { + it('rejects generic-file storage and exposes no provider-owned host path by default', async () => { const store = new RecordingStore(new Context()) const ref = await store.saveImage(image(1)) expect(store.imageHostPath(ref)).toBeUndefined() + await expect(store.saveFile({ data: Uint8Array.of(1), name: 'notes.txt' })) + .rejects.toMatchObject({ code: 'ATTACHMENT_FILES_UNSUPPORTED' }) + await expect(store.saveFileStream({ + data: (async function* (): AsyncIterable { yield Uint8Array.of(1) })(), + name: 'notes.txt', + })).rejects.toMatchObject({ code: 'ATTACHMENT_FILES_UNSUPPORTED' }) + const fileRef = { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + name: 'notes.txt', + bytes: 1, + } + expect(store.fileHostPath(fileRef)).toBeUndefined() + const read = async (signal?: AbortSignal): Promise => { + for await (const chunk of store.readFileStream(fileRef, signal)) { + void chunk + throw new Error('unsupported store yielded a chunk') + } + } + await expect(read()).rejects.toMatchObject({ code: 'ATTACHMENT_FILES_UNSUPPORTED' }) + const controller = new AbortController() + const reason = new Error('cancel unsupported file read') + controller.abort(reason) + await expect(read(controller.signal)).rejects.toBe(reason) + }) +}) + +describe('AttachmentStore file admission', () => { + it('decodes encoded files through the service and exposes attachment errors', async () => { + const store = new RecordingFileStore(new Context()) + + await expect(store.admitEncodedFile({ data: 'AQID', name: 'notes.bin' })).resolves.toMatchObject({ + name: 'notes.bin', + bytes: 3, + }) + expect(store.fileInput).toEqual({ data: Uint8Array.of(1, 2, 3), name: 'notes.bin' }) + expect(store.isAttachmentError(new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED'))).toBe(true) + expect(store.isAttachmentError(new Error('unknown failure'))).toBe(false) }) }) @@ -165,3 +217,14 @@ describe('isImageAdmissionError', () => { expect(isImageAdmissionError(new Error('unknown failure'))).toBe(false) }) }) + +describe('isAttachmentError', () => { + it('recognizes attachment failures from another package installation by code', () => { + expect(isAttachmentError(new AttachmentError('bad base64', 'INVALID_FILE_BASE64'))).toBe(true) + expect(isAttachmentError(Object.assign(new Error('foreign storage error'), { + code: 'ATTACHMENT_WRITE_FAILED', + }))).toBe(true) + expect(isAttachmentError(Object.assign(new Error('other failure'), { code: 'OTHER' }))).toBe(false) + expect(isAttachmentError({ code: 'ATTACHMENT_WRITE_FAILED' })).toBe(false) + }) +}) diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index ee235d5ae3..ad104c9920 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 644acd4892..2a172984d3 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -1,8 +1,8 @@ -import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { @@ -13,7 +13,16 @@ import { const NAME = 'dsh-test-bin' -const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-')) +const tempRoots: string[] = [] +afterAll(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +const tmp = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-app-boot-')) + tempRoots.push(dir) + return dir +} describe('resolveConfigPath', () => { it('resolves relative to the given cwd outside replay mode', () => { diff --git a/packages/boot/app-boot/tests/config-dump.spec.ts b/packages/boot/app-boot/tests/config-dump.spec.ts index ef2c9fc68e..6703df20e0 100644 --- a/packages/boot/app-boot/tests/config-dump.spec.ts +++ b/packages/boot/app-boot/tests/config-dump.spec.ts @@ -7,18 +7,27 @@ * shared overlay whose row exists only on another surface. */ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import * as yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { loadOverlayPatches, renderConfigDump } from '../src/index.ts' const NAME = 'dsh-test-bin' -const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-config-dump-')) +const tempRoots: string[] = [] +afterAll(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +const tmp = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-config-dump-')) + tempRoots.push(dir) + return dir +} function writeBase(dir: string): string { const base = join(dir, 'base.yml') diff --git a/packages/boot/app-boot/tests/config-reload.spec.ts b/packages/boot/app-boot/tests/config-reload.spec.ts index ca718a65b3..386b537ce4 100644 --- a/packages/boot/app-boot/tests/config-reload.spec.ts +++ b/packages/boot/app-boot/tests/config-reload.spec.ts @@ -4,10 +4,10 @@ * previous generation has been retained or restored. */ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Include } from '@deepseek-ai/cordis-plugin-include' import { boot } from '../src/index.ts' @@ -16,6 +16,11 @@ const NAME = 'dsh-test-bin' const NOOP_PLUGIN = 'export const name = "noop"\nexport function apply() {}\n' +const tempRoots: string[] = [] +afterAll(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + interface TreeFixture { ctx: Context dir: string @@ -24,6 +29,7 @@ interface TreeFixture { async function bootTree(configBody: string, files: Record = {}): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-')) + tempRoots.push(dir) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content) writeFileSync(join(dir, 'cordis.yml'), configBody) @@ -282,6 +288,7 @@ describe('loader tree replacement', () => { describe('include refresh with overlay patches', () => { it('re-applies entry patches and inserted entries on every re-read (parity with initial load)', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-config-reload-overlay-')) + tempRoots.push(dir) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) writeFileSync(join(dir, 'base.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') writeFileSync(join(dir, 'cordis.yml'), [ @@ -347,6 +354,7 @@ describe('include patches layered over one base', () => { // must therefore be able to reach a row an earlier layer inserted, or // bundle-only rows would be invisible to the user's patch layer. const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-')) + tempRoots.push(dir) writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n') writeFileSync(join(dir, 'cordis.yml'), [ diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index 5f52ab5e2a..6487ad0695 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -7,7 +7,10 @@ import { Context } from '@deepseek-ai/cordis' import Hmr from '@deepseek-ai/cordis-plugin-hmr' import Loader from '@deepseek-ai/cordis-plugin-loader' import Timer from '@deepseek-ai/cordis-plugin-timer' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' + +/** Every per-test tree root, removed once the booted watcher has been disposed. */ +const hmrRoots: string[] = [] async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise { const ctx = new Context() @@ -32,6 +35,10 @@ async function eventually(test: () => boolean, message: string): Promise { } describe('HMR exact config paths', () => { + afterEach(() => { + for (const root of hmrRoots.splice(0)) rmSync(root, { recursive: true, force: true }) + }) + it('observes module changes when its watch base is a filesystem alias', { timeout: 30_000 }, async () => { const target = mkdtempSync(join(tmpdir(), 'dsh-hmr-module-canonical-')) const alias = `${target}-alias` @@ -85,6 +92,7 @@ describe('HMR exact config paths', () => { it('observes add, change, and unlink outside its module roots', { timeout: 20_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + hmrRoots.push(dir) const filename = join(dir, 'plugins.yml') const ctx = await bootHmr(dir) const observed: string[] = [] @@ -111,6 +119,7 @@ describe('HMR exact config paths', () => { it('observes creation when the config parent did not exist at registration', { timeout: 20_000 }, async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + hmrRoots.push(root) const dir = join(root, 'later') const filename = join(dir, 'plugins.yml') const ctx = await bootHmr(root) @@ -129,6 +138,7 @@ describe('HMR exact config paths', () => { it('serializes refreshes and waits for them during disposal', { timeout: 20_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + hmrRoots.push(dir) const filename = join(dir, 'plugins.yml') writeFileSync(filename, 'one') const ctx = await bootHmr(dir) @@ -170,6 +180,7 @@ describe('HMR exact config paths', () => { it('normalizes refresh failures and broadcasts them without escaping the watcher', { timeout: 20_000 }, async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-hmr-config-')) + hmrRoots.push(dir) const filename = join(dir, 'plugins.yml') const ctx = await bootHmr(dir) const failure = Promise.withResolvers<{ filename: string; error: Error }>() diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index 0681157f62..10d1d41b8a 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -11,7 +11,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { withFileLock } from '@deepseek-ai/dsh-atomic-write' -import { describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it } from 'vitest' import { composeEntries, healProfilesModuleFallback, @@ -26,7 +26,16 @@ import { type Profile, } from '../src/index.ts' -const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-')) +const tempRoots: string[] = [] +afterAll(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +const tmp = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-profile-')) + tempRoots.push(dir) + return dir +} /** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */ function stageInstallation( diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 3efdd22db5..a4f6debf97 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -4,11 +4,11 @@ * a real Loader tree, kept live through transactional HMR. */ -import { mkdirSync, mkdtempSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' +import { afterAll, afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Hmr from '@deepseek-ai/cordis-plugin-hmr' import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include' @@ -23,7 +23,16 @@ import { const NAME = 'dsh-test-bin' -const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-user-patches-')) +const tempRoots: string[] = [] +afterAll(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +const tmp = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-user-patches-')) + tempRoots.push(dir) + return dir +} async function eventually(test: () => boolean, message: string): Promise { const deadline = Date.now() + 10_000 diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 37d7b89c3d..128196ef6d 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index 6f41146c61..687a219a2c 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -4,7 +4,7 @@ * active, then resolves that row's config against its injection-ready context. */ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { EventEmitter } from 'node:events' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -34,6 +34,9 @@ interface Fixture { const disposers: (() => Promise)[] = [] +/** Fixture tree roots, removed after their booted tree has been disposed. */ +const tempDirs: string[] = [] + const readyApp: AppReady = { onReady(listener) { listener() @@ -59,6 +62,7 @@ function controlledAppReady(): { service: AppReady; commit(): void } { afterEach(async () => { for (const dispose of disposers.splice(0)) await dispose() + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) internals.stdin = process.stdin internals.stdout = process.stdout internals.stderr = process.stderr @@ -103,6 +107,7 @@ async function bootFixture( options: { objectInject?: boolean; withoutProvider?: boolean } = {}, ): Promise { const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) + tempDirs.push(dir) const observed: Observed = { exits: [], out: '' } writeFileSync(join(dir, 'reader.mjs'), ` export const name = 'reader' diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index 4eeb8e6536..d06d20ab94 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 24ff47b915..3d527659c5 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 8f54c08668..c027747926 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 3db8d68bf4..29c5a7666b 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -4,7 +4,7 @@ * the consumer pending. */ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -24,8 +24,12 @@ interface Observed { const disposers: (() => Promise)[] = [] +/** Fixture tree roots, removed after their booted tree has been disposed. */ +const tempDirs: string[] = [] + afterEach(async () => { for (const dispose of disposers.splice(0)) await dispose() + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) internals.stdout = process.stdout internals.stderr = process.stderr }) @@ -37,6 +41,7 @@ afterEach(async () => { */ async function bootStartup(args: string[]): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-')) + tempDirs.push(dir) const observed: Observed = { exits: [], out: '' } writeFileSync(join(dir, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n') // Loader imports through Node's resolver, so this fixture delegates to the diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index d7fc2a54f7..6a5c36a451 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index f6e7d53158..b0dbc597d2 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 2c9bd25990..ee0217066d 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -168,6 +168,11 @@ # its literals, for example: ['app.internal', ...ctx.webRuntime.trustedHosts]. trustedHosts: !!js ctx.webRuntime.trustedHosts + # Raw Blob and ReadableStream uploads run independently of Connection's + # RPC and generation service. + - id: file-upload + name: '@deepseek-ai/dsh-client-file-upload' + - id: api-remotes name: '@deepseek-ai/dsh-api-remotes' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 46e1ebe45c..9531c638fb 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, @@ -44,6 +44,7 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index ba3806232c..a10272f9d3 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -3,7 +3,7 @@ * releases a consumer whose config reads `ctx.webStartup` directly. */ -import { mkdtempSync, writeFileSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -23,8 +23,12 @@ interface Observed { const disposers: (() => Promise)[] = [] +/** Fixture tree roots, removed after their booted tree has been disposed. */ +const tempDirs: string[] = [] + afterEach(async () => { for (const dispose of disposers.splice(0)) await dispose() + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) internals.stdout = process.stdout internals.stderr = process.stderr }) @@ -39,6 +43,7 @@ async function bootProvider(args: string[]): Promise<{ observed: Observed }> { const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-')) + tempDirs.push(dir) const observed: Observed = { exits: [], out: '' } writeFileSync(join(dir, 'reader.mjs'), ` export function apply(_ctx, config) { globalThis.__webStartupObserved.readerConfig = config } diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 3f826371d5..e6c2329963 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: 2358d94f6db0455aaf8fce094a616a273d4b3bab -README.zh.md: 4fbeacf33dc609102a78418c3679a43f06914bdf +README.md: 27fa0abba847d99dd5553a36e25077e4fac3b56c +README.zh.md: 51a2d40a1ed2ea9a7bfe2ffe9ce96d23a4d27e73 diff --git a/packages/client/README.md b/packages/client/README.md index 2358d94f6d..27fa0abba8 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -29,6 +29,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`web/`](web/README.md) | Boots the browser shell | — | | [`modules/`](modules/README.md) | Loads browser-side client modules | `ctx.clientModules` / `ctx.modules` | | [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery | `ctx.connection` | +| [`file-upload/`](file-upload/README.md) | Sends raw Blob and byte-stream request bodies outside the page thread | `ctx.fileUpload` | | [`store/`](store/README.md) | Provides React-free observable and snapshot-store primitives | — | | [`hmr/`](hmr/README.md) | Refreshes client plugins during development | — | | [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries | `ctx.locale` | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 4fbeacf33d..51a2d40a1e 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -29,6 +29,7 @@ kind: "package-group" | [`web/`](web/README.zh.md) | 启动浏览器外壳 | — | | [`modules/`](modules/README.zh.md) | 加载浏览器侧客户端模块 | `ctx.clientModules` / `ctx.modules` | | [`connection/`](connection/README.zh.md) | 维护浏览器与宿主之间的 RPC 通信与事件投递 | `ctx.connection` | +| [`file-upload/`](file-upload/README.zh.md) | 在页面线程之外发送原始 Blob 与字节流请求体 | `ctx.fileUpload` | | [`store/`](store/README.zh.md) | 提供不依赖 React 的 observable 与 snapshot-store 原语 | — | | [`hmr/`](hmr/README.zh.md) | 在开发期间刷新客户端插件 | — | | [`locale/`](locale/README.zh.md) | 提供本地化偏好与消息词典 | `ctx.locale` | diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 4ca433fc99..e1e4ff6e1c 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 54acac0ed815ea24a4a30a80d0b13eb6ceda6b47 -README.zh.md: 46c7d0b20e3b7a27db963866949d01f1ee96781a +README.md: 91e96a34d15463fd4246bf8fd8aee6112aaa54c4 +README.zh.md: fc6686e688170427528cb51db51e166f3c447f26 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 54acac0ed8..91e96a34d1 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The package carries browser-to-Host Remote calls, exact Fetch responses, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, a generic RPC carrier, the active generation and its Host facts, observable recovery state, an immediate reconnect command, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` applies its retry policy. +The package carries browser-to-Host Remote calls, exact Fetch responses, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, generic RPC, the active generation and its Host facts, observable recovery state, an immediate reconnect command, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` applies its retry policy. ## Table of Contents @@ -25,7 +25,7 @@ The package carries browser-to-Host Remote calls, exact Fetch responses, and con ## Use this package -The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, Host/Origin checks, and exact `GET`/`HEAD` route registry. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. +The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, Host/Origin checks, and exact `GET`/`HEAD`/`POST` route registry. Each exact route declares buffered or streaming request-body handling before the bridge reads any bytes. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads and raw file uploads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. Browser raw-body transfer is provided by [`dsh-client-file-upload`](../file-upload/README.md). ----- @@ -58,7 +58,7 @@ None; this package neither assembles nor sends a provider request. -- **The `/api` bridge buffers each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) is therefore also the per-request resident bound; a streaming body path would be needed to lower it without shrinking the image limits. +- **Buffered `/api` routes retain each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) bounds ordinary image and RPC envelopes. Opt-in streaming routes receive backpressured chunks and bypass the aggregate cap; route implementations own persistence, cancellation, and any storage quota. - **The browser cookie is not marked `Secure`** — loopback HTTP is the shipped transport, so exposing the same authority over plaintext networking can expose the bearer cookie in transit. - **There is no logout operation** — clearing the browser cookie ends one browser session; deleting the owner credential record and restarting `dsh` revokes every session. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 46c7d0b20e..fc6686e688 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC carrier、当前 generation 及其 Host 信息、可观察的恢复状态、立即重连命令,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 执行重试策略。 +本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC、当前 generation 及其 Host 信息、可观察的恢复状态、立即重连命令,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 执行重试策略。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证、Host/Origin 校验与精确 `GET`/`HEAD` 路由注册表。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。 +浏览器通过 HTTP POST 执行 Remote 一元调用。API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证、Host/Origin 校验与精确 `GET`/`HEAD`/`POST` 路由注册表。每条精确路由会在 bridge 读取任何字节前声明缓冲或流式请求体处理方式。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载、原始文件上传等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。浏览器原始请求体传输由 [`dsh-client-file-upload`](../file-upload/README.zh.md) 提供。 ----- @@ -58,7 +58,7 @@ API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation -- **`/api` 桥把每个请求体整体缓冲在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。 +- **缓冲型 `/api` 路由会把每个请求体保留在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)限制普通图片与 RPC 信封。显式启用的流式路由接收带背压的分块并绕过总量上限;路由实现负责持久化、取消与存储配额。 - **浏览器 cookie 不带 `Secure`**:随附载体是 loopback HTTP;若部署经明文网络暴露同一 authority,bearer cookie 可能在传输中泄露。 - **没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录并重启 `dsh` 会撤销全部会话。 diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index e4f1fbe2df..9808c40fde 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport, generation lifecycle, and browser fixture", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index d04eebcf47..932ec1da3e 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2208,13 +2208,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { value: [ { name: 'compact', description: 'fixture:压缩当前会话上下文' }, { name: 'echo', description: 'fixture:回显参数', input: { hint: 'text to echo' } }, - { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '', images: true } }, + { name: 'goal', description: 'set or view the goal for a long-running task', input: { hint: '', attachments: true } }, { name: 'permission', description: 'Switch the permission preset (sandbox mode + approval policy)', input: { hint: '' } }, - { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', images: true } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } }, ], } }, - execute(id: SessionId, line: string, images: readonly unknown[] = []): RpcResult { + execute(id: SessionId, line: string, attachments: readonly unknown[] = []): RpcResult { const missing = requireGoalSession(id) if (missing !== undefined) return missing // Structured split mirroring the Host parser: name + verbatim rawInput @@ -2225,17 +2225,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // Mirror the Host image policy AFTER command resolution, matching the // executor's order (an unknown name answers undefined and logs no // lifecycle): the declaration rejection covers every known command - // without `input.images`, and the two producer grammar rejections cover + // without `input.attachments`, and the two producer grammar rejections cover // the declaring commands' control-only lines. The fixture stores no // bytes, so an accepted batch is acknowledged and dropped. const known = ['permission', 'goal', 'compact', 'echo', 'plan'] - if (images.length > 0 && name !== undefined && known.includes(name)) { + if (attachments.length > 0 && name !== undefined && known.includes(name)) { const rejection = name !== 'goal' && name !== 'plan' - ? `/${name} does not accept image attachments` + ? `/${name} does not accept attachments` : name === 'goal' && args.trim() === '' - ? 'Image attachments only accompany a goal objective: /goal or /goal edit .' + ? 'Attachments only accompany a goal objective: /goal or /goal edit .' : name === 'plan' && args.trim() === 'off' - ? 'Image attachments cannot accompany /plan off.' + ? 'Attachments cannot accompany /plan off.' : undefined if (rejection !== undefined) { const commandId = `fx-cmd-${logOf(id).length}` as CommandId diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 482ef2db91..ad568f55bf 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -1,7 +1,4 @@ -/** - * Browser wire client. The plugin selects fixture or HTTP transport, provides - * the shared API client, and lets API Gateway own the connection loop. - */ +/** Browser wire client: Remote transport and connection generations. */ import type { Context } from '@deepseek-ai/cordis' import { ConnectionController, @@ -107,9 +104,8 @@ interface ClientTransportGlobal { } /** - * The ctx.connection service API: the API client plus a one-shot controller - * starter. API Gateway supplies generation readiness and reset callbacks; - * Connection stays independent of downstream domain state. + * The ctx.connection service API. API Gateway supplies generation readiness + * and reset callbacks; Connection stays independent of downstream domain state. */ export interface ConnectionHandle { /** @@ -178,7 +174,7 @@ function watchBrowserNetwork(controller: ConnectionController): () => void { } /** - * Client plugin body: pick the api by page mode and provide ctx.connection. + * Client plugin body: pick physical carriers by page mode and provide ctx.connection. * @param ctx - client cordis context. */ export function apply(ctx: Context): void { diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index b404e65103..ff6042d117 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -4,6 +4,8 @@ */ import type { IncomingMessage, ServerResponse } from 'node:http' +import { Readable } from 'node:stream' +import type { ConnectionFetchHandler } from './rpc.ts' /** Default carrier cap for all HTTP RPC bodies: sized for the default * aggregate image limit (200 MiB) after base64 expansion plus envelope @@ -11,28 +13,18 @@ import type { IncomingMessage, ServerResponse } from 'node:http' * each body in memory, so this cap is also the per-request resident bound. */ export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024 -/** Transport-independent request handler consumed by the Host HTTP bridge. */ -export interface FetchHandler { - /** - * Handle one standard Fetch request. - * @param request - request produced by the active transport bridge. - * @returns complete or streaming Fetch response. - */ - fetch(request: Request): Promise -} - /** * Bridge one node:http request to the fetch-shaped handler (client close * aborts; response bodies stream out chunk by chunk). - * @param req - incoming node:http request (fully read before dispatch). + * @param req - incoming node:http request. * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. - * @param maxRequestBodyBytes - maximum body bytes buffered before dispatch. + * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route. */ export async function bridge( req: IncomingMessage, res: ServerResponse, - apiHandler: FetchHandler, + apiHandler: ConnectionFetchHandler, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES, ): Promise { const abort = new AbortController() @@ -44,38 +36,57 @@ export async function bridge( res.on('close', () => { if (!res.writableEnded) abort.abort() }) - const declaredLength = req.headers['content-length'] - if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) { - res.writeHead(413, { connection: 'close' }) - res.end() - req.destroy() - return - } - const chunks: Buffer[] = [] - let received = 0 - for await (const chunk of req) { - const buffer = chunk as Buffer - received += buffer.byteLength - if (received > maxRequestBodyBytes) { + /* v8 ignore next 2 -- node:http always sets url/method on server requests. */ + const url = new URL(req.url ?? '/', 'http://dsh.internal') + const method = req.method ?? 'GET' + const headers = Object.fromEntries( + Object.entries(req.headers).filter(([, value]) => typeof value === 'string') as [string, string][], + ) + const bodyMode = apiHandler.requestBodyMode({ method, url }) + let request: Request + if (bodyMode === 'buffered') { + const declaredLength = req.headers['content-length'] + if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) { res.writeHead(413, { connection: 'close' }) res.end() req.destroy() return } - chunks.push(buffer) + const chunks: Buffer[] = [] + let received = 0 + for await (const chunk of req) { + const buffer = chunk as Buffer + received += buffer.byteLength + if (received > maxRequestBodyBytes) { + res.writeHead(413, { connection: 'close' }) + res.end() + req.destroy() + return + } + chunks.push(buffer) + } + request = new Request(url, { + method, + headers, + ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}, + signal: abort.signal, + }) + } else { + request = new Request(url, { + method, + headers, + body: Readable.toWeb(req) as ReadableStream, + signal: abort.signal, + duplex: 'half', + } as RequestInit & { duplex: 'half' }) } - /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server - requests; the fields are only optional on the client-side IncomingMessage type */ - const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), { - method: req.method ?? 'GET', - headers: Object.fromEntries(Object.entries(req.headers).filter(([, v]) => typeof v === 'string') as [string, string][]), - ...chunks.length > 0 ? { body: Buffer.concat(chunks) } : {}, - signal: abort.signal, - }) const response = await apiHandler.fetch(request) - res.writeHead(response.status, Object.fromEntries(response.headers.entries())) + const requestUnread = bodyMode === 'streaming' && !req.readableEnded + const responseHeaders = Object.fromEntries(response.headers.entries()) + res.writeHead(response.status, requestUnread ? { ...responseHeaders, connection: 'close' } : responseHeaders) if (response.body === null) { res.end() + if (requestUnread) req.destroy() return } for await (const chunk of response.body) { @@ -96,4 +107,5 @@ export async function bridge( } } res.end() + if (requestUnread) req.destroy() } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 34cf79bd65..a274cef190 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -22,6 +22,7 @@ export type { ConnectionRpcHandler, ConnectionRequestRejection, ConnectionRpcResult, + ConnectionRequestBodyMode, ConnectionTrustRequest, ClientRequest, HostConnectionHandle, diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index a9bbc72954..d74734e132 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -8,7 +8,7 @@ import { type RpcId as RpcIdType, } from './rpc.ts' import { clientRequestSchema } from './rpc-schema.ts' -import { bridge, type FetchHandler } from './http-bridge.ts' +import { bridge } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' import type { BrowserAuth } from './browser-auth.ts' @@ -34,11 +34,12 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ interface ConnectionRpcInterceptor { readonly matches: ConnectionRpcEndpointMatcher - readonly fetchHandler: FetchHandler + readonly fetchHandler: ConnectionFetchHandler } interface RegisteredFetchRoute { readonly methods: ReadonlySet + readonly requestBody: ConnectionFetchRoute['requestBody'] readonly fetch: ConnectionFetchRoute['fetch'] } @@ -117,6 +118,10 @@ export class HostConnectionService extends Service implements HostConnectionHand channel: '/api', ): ConnectionFetchHandler { return { + requestBodyMode: ({ method, url }) => { + const route = this.fetchRoutes.get(url.pathname) + return route?.methods.has(method) === true ? route.requestBody : 'buffered' + }, fetch: (request) => { const pathname = new URL(request.url).pathname const route = this.fetchRoutes.get(pathname) @@ -138,6 +143,7 @@ export class HostConnectionService extends Service implements HostConnectionHand assertFetchRoute(route) const registered: RegisteredFetchRoute = { methods: new Set(route.methods), + requestBody: route.requestBody, fetch: route.fetch, } return owner.effect(() => { @@ -203,8 +209,9 @@ export class HostConnectionService extends Service implements HostConnectionHand function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, -): FetchHandler { +): ConnectionFetchHandler { return { + requestBodyMode: () => 'buffered', async fetch(request: Request): Promise { const endpoint = endpointFromPath(channel, new URL(request.url).pathname) if (request.method !== 'POST' || endpoint === undefined) { diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 12c8f198be..9474d72c36 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -107,7 +107,10 @@ export type ConnectionRpcHandler = ( export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean /** HTTP methods supported by exact Fetch routes on the shared API channel. */ -export type ConnectionFetchMethod = 'GET' | 'HEAD' +export type ConnectionFetchMethod = 'GET' | 'HEAD' | 'POST' + +/** How the node:http bridge presents one request body to its Fetch route. */ +export type ConnectionRequestBodyMode = 'buffered' | 'streaming' /** One exact, transport-independent Fetch route owned by a Host feature. */ export interface ConnectionFetchRoute { @@ -115,6 +118,8 @@ export interface ConnectionFetchRoute { readonly path: string /** Methods this route owns. Other methods continue through normal shared-channel dispatch. */ readonly methods: readonly ConnectionFetchMethod[] + /** Buffered requests obey the configured JSON cap; streaming requests arrive with backpressure and no aggregate cap. */ + readonly requestBody: ConnectionRequestBodyMode /** Handle one request after the physical carrier has applied its trust and authentication policy. */ readonly fetch: (request: Request) => Promise } @@ -196,6 +201,13 @@ export interface HostConnectionHandle { /** Transport-independent Fetch handler used by HTTP and worker carriers. */ export interface ConnectionFetchHandler { + /** + * Resolve body handling before the bridge reads any request bytes. + * @param request - request method and URL available from node:http headers. + * @returns the registered route's body handling mode. + */ + requestBodyMode(request: { readonly method: string; readonly url: URL }): ConnectionRequestBodyMode + /** * Dispatch one already-authenticated request. * @param request - Fetch request below the shared channel. diff --git a/packages/client/connection/tests/fetch-routes.host.spec.ts b/packages/client/connection/tests/fetch-routes.host.spec.ts index d5dbee979e..ddb9b3b264 100644 --- a/packages/client/connection/tests/fetch-routes.host.spec.ts +++ b/packages/client/connection/tests/fetch-routes.host.spec.ts @@ -25,7 +25,8 @@ describe('Connection exact Fetch routes', () => { Response.json({ query: new URL(request.url).searchParams.get('sessionId') })) const dispose = connection.fetch.register({ path: '/api/session.export', - methods: ['GET', 'HEAD'], + methods: ['GET', 'HEAD', 'POST'], + requestBody: 'streaming', fetch: route, }) const shared = connection.createSharedFetchHandler('/api') @@ -33,11 +34,18 @@ describe('Connection exact Fetch routes', () => { const response = await shared.fetch(new Request( 'http://host/api/session.export?sessionId=session-1', )) + expect(shared.requestBodyMode({ + method: 'POST', url: new URL('http://host/api/session.export'), + })).toBe('streaming') + expect(shared.requestBodyMode({ + method: 'DELETE', url: new URL('http://host/api/session.export'), + })).toBe('buffered') expect(response.status).toBe(200) expect(await response.json()).toEqual({ query: 'session-1' }) expect(route).toHaveBeenCalledOnce() const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' })) - expect(post.status).toBe(404) + expect(post.status).toBe(200) + expect(route).toHaveBeenCalledTimes(2) await dispose() const withdrawn = await shared.fetch(new Request('http://host/api/session.export')) @@ -49,22 +57,26 @@ describe('Connection exact Fetch routes', () => { const { connection, dispose: disposeFiber } = await mounted() const fetch = async (): Promise => new Response() - expect(() => connection.fetch.register({ path: '/outside', methods: ['GET'], fetch })) + expect(() => connection.fetch.register({ path: '/outside', methods: ['GET'], requestBody: 'buffered', fetch })) .toThrow('invalid exact Fetch route') - expect(() => connection.fetch.register({ path: '/api/session.export', methods: [], fetch })) + expect(() => connection.fetch.register({ path: '/api/session.export', methods: [], requestBody: 'buffered', fetch })) .toThrow('declares no methods') expect(() => connection.fetch.register({ path: '/api/session.export', methods: ['GET', 'GET'], fetch, + requestBody: 'buffered', })).toThrow('repeats a method') const dispose = connection.fetch.register({ path: '/api/session.export', methods: ['GET'], fetch, + requestBody: 'buffered', }) expect(() => connection.fetch.register({ path: '/api/session.export', methods: ['HEAD'], fetch, + requestBody: 'buffered', })).toThrow('already registered') await dispose() expect(() => connection.fetch.register({ path: '/api/session.export', methods: ['HEAD'], fetch, + requestBody: 'buffered', })).not.toThrow() await disposeFiber() }) diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index ed7eee91b3..103a9d9266 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -22,7 +22,7 @@ const sid = (id: string): SessionId => id as SessionId describe('createFixtureApi commands/skills', () => { it('serves the addressed session catalog', async () => { const { rpc } = createFixtureFaces() - const commands = await callRemote<{ name: string; input?: { hint: string; images?: boolean } }[]>( + const commands = await callRemote<{ name: string; input?: { hint: string; attachments?: boolean } }[]>( rpc, 'commands/list', { agentId: sid('fx-alpha') }) expect(commands.map(c => c.name)).toEqual(['compact', 'echo', 'goal', 'permission', 'plan']) // input hint rides only the commands declaring it. @@ -30,7 +30,7 @@ describe('createFixtureApi commands/skills', () => { expect(echo?.input?.hint).toBeTruthy() expect(commands.find(c => c.name === 'compact')?.input).toBeUndefined() // Image acceptance is declared per descriptor; only goal and plan carry it. - expect(commands.filter(c => c.input?.images === true).map(c => c.name)).toEqual(['goal', 'plan']) + expect(commands.filter(c => c.input?.attachments === true).map(c => c.name)).toEqual(['goal', 'plan']) }) it('rejects a catalog request for an unknown session', async () => { @@ -97,14 +97,14 @@ describe('createFixtureApi commands/skills', () => { const refused = await callRemote<{ commandId: string; result: { kind: string; text?: string } } | undefined>( rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/echo hi', images: [png] }) expect(refused?.commandId).toBeTruthy() - expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept image attachments' }) + expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept attachments' }) await pump const events = frames .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'event') .map(f => f.event) expect(events).toMatchObject([ { type: 'command/run', data: { name: 'echo', args: ' hi', source: { kind: 'user' } } }, - { type: 'command/done', data: { kind: 'error', text: '/echo does not accept image attachments' } }, + { type: 'command/done', data: { kind: 'error', text: '/echo does not accept attachments' } }, ]) }) @@ -129,13 +129,13 @@ describe('createFixtureApi commands/skills', () => { rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/goal', images: [png] }) expect(bareGoal?.result).toEqual({ kind: 'error', - text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', + text: 'Attachments only accompany a goal objective: /goal or /goal edit .', }) const refused = await callRemote<{ result: { kind: string; text?: string } } | undefined>( rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/plan off', images: [png] }) expect(refused?.result).toEqual({ kind: 'error', - text: 'Image attachments cannot accompany /plan off.', + text: 'Attachments cannot accompany /plan off.', }) }) diff --git a/packages/client/connection/tests/http-bridge.host.spec.ts b/packages/client/connection/tests/http-bridge.host.spec.ts index 2e267ac966..c3d2eb6cd7 100644 --- a/packages/client/connection/tests/http-bridge.host.spec.ts +++ b/packages/client/connection/tests/http-bridge.host.spec.ts @@ -24,6 +24,7 @@ describe('HTTP bridge abort', () => { }) as unknown as ServerResponse await bridge(request, response, { + requestBodyMode: () => 'buffered', fetch: () => { throw new Error('a rejected request must never reach the handler') }, }, 1000) // The socket must not stay parked draining a body the client can trickle @@ -55,6 +56,7 @@ describe('HTTP bridge abort', () => { const started = new Promise((resolve) => { resolveStarted = resolve }) let carrierSignal: AbortSignal | undefined const pending = bridge(request, response, { + requestBodyMode: () => 'buffered', fetch: async (input) => { const fetchRequest = input carrierSignal = fetchRequest.signal @@ -72,4 +74,74 @@ describe('HTTP bridge abort', () => { await pending expect(carrierSignal?.aborted).toBe(true) }) + + it('streams a declared 2.19 GiB request before the body ends and bypasses the JSON buffer cap', async () => { + const request = new Readable({ read() {} }) as unknown as IncomingMessage + Object.assign(request, { + url: '/api/session/uploadFileBinary?sessionId=s1', + method: 'POST', + headers: { + 'content-type': 'application/octet-stream', + 'content-length': String(Math.ceil(2.19 * 1024 ** 3)), + }, + }) + let status: number | undefined + const responseBytes: Uint8Array[] = [] + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead(code: number) { status = code; return this }, + write(chunk: Uint8Array) { responseBytes.push(chunk); return true }, + end(this: { writableEnded: boolean }) { this.writableEnded = true; return this }, + }) as unknown as ServerResponse + + let resolveStarted!: () => void + const started = new Promise((resolve) => { resolveStarted = resolve }) + const received: Uint8Array[] = [] + const pending = bridge(request, response, { + requestBodyMode: () => 'streaming', + fetch: async (input) => { + resolveStarted() + if (input.body === null) throw new Error('streaming request lost its body') + for await (const chunk of input.body) received.push(chunk) + return new Response('stored') + }, + }, 1) + + await started + expect(received).toEqual([]) + request.push(Buffer.from([1, 2])) + request.push(Buffer.from([3, 4])) + request.push(null) + await pending + expect(status).toBe(200) + expect(received).toEqual([Uint8Array.of(1, 2), Uint8Array.of(3, 4)]) + expect(Buffer.concat(responseBytes).toString()).toBe('stored') + }) + + it('closes an unread streaming request after returning an early validation response', async () => { + const destroyed: true[] = [] + const request = new Readable({ read() {} }) as unknown as IncomingMessage + Object.assign(request, { + url: '/api/session/uploadFileBinary', + method: 'POST', + headers: { 'content-type': 'application/json' }, + destroy: () => { destroyed.push(true) }, + }) + let status: number | undefined + let headers: unknown + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead(code: number, values?: unknown) { status = code; headers = values; return this }, + write() { return true }, + end(this: { writableEnded: boolean }) { this.writableEnded = true; return this }, + }) as unknown as ServerResponse + + await bridge(request, response, { + requestBodyMode: () => 'streaming', + fetch: () => Promise.resolve(new Response(null, { status: 415 })), + }, 1) + expect(status).toBe(415) + expect(headers).toMatchObject({ connection: 'close' }) + expect(destroyed).toEqual([true]) + }) }) diff --git a/packages/client/file-upload/README.i18n.yaml b/packages/client/file-upload/README.i18n.yaml new file mode 100644 index 0000000000..e6b4cb5fcb --- /dev/null +++ b/packages/client/file-upload/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/file-upload/README.md +README.md: 2c19e1a564d7b58226e6ccd3cb5b7c81e1b1c14e +README.zh.md: d1553d40d953dd6378a954c43a9b7a36b7334953 diff --git a/packages/client/file-upload/README.md b/packages/client/file-upload/README.md new file mode 100644 index 0000000000..2c19e1a564 --- /dev/null +++ b/packages/client/file-upload/README.md @@ -0,0 +1,101 @@ +--- +description: "Session-addressed browser file uploads with streaming intake, progress, cancellation, and staged receipts for later prompts." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-file-upload + +English | [中文](README.zh.md) + +## Summary + +This package lets browser features store a `Blob`, exact bytes, or a `ReadableStream` for one Session and receive an opaque receipt for a later prompt. Served pages send Blob and stream bodies without aggregating their bytes on the page thread; pages whose Host runs in another execution context supply a Fetch-shaped carrier before Cordis boots. Callers can observe consumed bytes and cancel an active operation. A stream body is consumed once and transfers ownership when it crosses a Worker boundary. The standalone `?fixture` page uses the generated Remote for replayable Blob and exact-byte inputs. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Mount the package before a consumer that injects `fileUpload`, then call `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`. The Session identity addresses both the raw route and generated Remote fallback; callers do not assemble either request. + +```yaml +- id: file-upload + name: '@deepseek-ai/dsh-client-file-upload' +``` + +The package has no Cordis configuration fields. A `Blob` uses XMLHttpRequest inside a dedicated Worker so the service can report browser upload progress, including the total when the browser provides it. A `ReadableStream` transfers to that Worker and feeds Fetch incrementally; progress reports consumed bytes without a total. An `AbortSignal` terminates the dedicated Worker or reaches a page-owned carrier. Exact bytes and fixture Blob inputs use the generated Remote. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The Client plugin provides `ctx.fileUpload`. Its `upload()` method receives the owning Session identity, assembles the raw route request, and invokes the generated Remote fallback for replayable inputs. The provider reads the optional pre-Cordis `__DSH_FILE_UPLOAD__` hook once. Without a hook, each non-fixture raw request owns a short-lived Worker and releases it after completion, failure, or cancellation. With the hook, the service sends the body through the page-owned Fetch carrier; the Web Worker runtime transfers stream bodies through its request frame and exposes them to the Host HTTP bridge as backpressured chunks. + +The Host plugin provides `ctx.fileUploads`. It owns the authenticated streaming route, encoded Remote fallback, command receipt resolver, and staged-receipt lifecycle; encoded admission, attachment-error recognition, and byte storage stay behind `ctx.attachments`. Receipt tables use the receiving Agent's Session object as their key. The Session Controller registers the resolver that can resume a cold ordinary Agent and consumes receipts during prompt admission. Prompt delivery holds each receipt binding in a disposable transaction: disposal restores the previous binding until successful delivery commits it, and queue or history observation then retires the committed receipt. + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | Host streaming route, attachment-service admission, and Agent-scoped receipt lifecycle | +| [`src/types.ts`](src/types.ts) | encoded request, receipt, and durable result types | +| [`src/client/contract.ts`](src/client/contract.ts) | Client upload, progress, and page-hook types | +| [`src/client/runtime.ts`](src/client/runtime.ts) | Dedicated Worker and page-owned carrier implementations | +| [`src/client/index.ts`](src/client/index.ts) | Client plugin registration and `ctx.fileUpload` declaration | + +
+ +**Runtime invariant:** No companion is published. Each upload receipt belongs to one exact Session, and each request uses one selected carrier. Unsupported stream carriers fail before the body is sent. + +----- + + +## Further Exploration + +- [Connection](../connection/README.md) — authenticated RPC, exact Host routes, and connection generations. +- [Session Controller](../../api/session-controller/README.md) — prompt admission that consumes staged receipts. +- [Web Worker runtime](../../experimental/webworker-runtime/README.md) — the page-to-Host Worker request tunnel. +- [Client group map](../README.md) — browser services and UI feature packages. + +----- + + +## Model Experience + +None, as this package transfers browser request bodies and contributes no model input. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +These limits apply to the transport operation itself. + +- **Uploads are not resumable** — a failed or cancelled retry starts from the first byte. +- **Stream bodies are one-shot** — transferring a `ReadableStream` locks the caller's object, so retry requires a newly created stream. +- **Stream progress has no total** — callers receive consumed-byte counts because the stream API carries no byte length. +- **The browser Worker is self-contained** — its source is emitted from a function string. Adding runtime imports requires moving it to a standalone Worker entry bundled by tsdown. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/file-upload/README.zh.md b/packages/client/file-upload/README.zh.md new file mode 100644 index 0000000000..d1553d40d9 --- /dev/null +++ b/packages/client/file-upload/README.zh.md @@ -0,0 +1,101 @@ +--- +description: "按 Session 寻址上传浏览器文件,提供流式接收、进度、取消和供后续 prompt 使用的暂存凭证。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-file-upload + +[English](README.md) | 中文 + +## 概述 + +本包让浏览器功能为一个 Session 存储 `Blob`、精确字节或 `ReadableStream`,并取得供后续 prompt 使用的不透明凭证。普通服务页面发送 Blob 和 stream 请求体时,不会在页面线程聚合全部字节;Host 位于其他执行上下文中的页面会在 Cordis 启动前提供 Fetch 形式的载体。调用方可以观察已消费字节并取消活动操作。stream 请求体只能消费一次,跨 Worker 边界时会转移所有权。独立的 `?fixture` 页面通过生成的 Remote 处理可重放的 Blob 与精确字节输入。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +在注入 `fileUpload` 的消费方之前挂载本包,再调用 `ctx.fileUpload.upload(sessionId, body, name, signal, onProgress)`。Session 标识同时用于寻址原始路由和生成的 Remote 兜底;调用方不组装这两种请求。 + +```yaml +- id: file-upload + name: '@deepseek-ai/dsh-client-file-upload' +``` + +本包没有 Cordis 配置字段。`Blob` 在专用 Worker 内通过 XMLHttpRequest 发送,因此服务可以报告浏览器上传进度,并在浏览器提供总量时一并报告。`ReadableStream` 会转移给该 Worker,再增量传入 Fetch;进度只报告已消费字节,不包含总量。`AbortSignal` 会终止专用 Worker,或传递给页面自己提供的载体。精确字节与 fixture Blob 输入使用生成的 Remote。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +Client 插件提供 `ctx.fileUpload`。其 `upload()` 方法接收所属 Session 标识,组装原始路由请求,并为可重放输入调用生成的 Remote 兜底。提供方只读取一次可选的 Cordis 启动前 `__DSH_FILE_UPLOAD__` 钩子。没有该钩子时,每个非 fixture 原始请求拥有一个短期 Worker,并在完成、失败或取消后释放。存在该钩子时,服务通过页面自己提供的 Fetch 载体发送请求体;Web Worker runtime 会通过请求帧转移 stream 请求体,再以带背压的分片形式交给 Host HTTP bridge。 + +Host 插件提供 `ctx.fileUploads`。它拥有经过认证的流式路由、编码 Remote 兜底、命令凭证解析器与暂存凭证生命周期;编码准入、附件错误识别与字节存储仍由 `ctx.attachments` 提供。凭证表以接收方 Agent 的 Session 对象为键。Session Controller 注册可恢复休眠普通 Agent 的解析器,并在 prompt 准入时消费凭证。Prompt 投递通过可释放事务持有每个凭证绑定。成功投递提交事务前,释放会恢复原绑定;提交后,队列或历史观察会退休该凭证。 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | Host 流式路由、附件服务准入与按 Agent scope 管理的凭证生命周期 | +| [`src/types.ts`](src/types.ts) | 编码请求、凭证与持久结果类型 | +| [`src/client/contract.ts`](src/client/contract.ts) | Client 上传、进度与页面钩子类型 | +| [`src/client/runtime.ts`](src/client/runtime.ts) | 专用 Worker 与页面自有载体实现 | +| [`src/client/index.ts`](src/client/index.ts) | Client 插件注册与 `ctx.fileUpload` 声明 | + +
+ +**运行时不变式:** 不发布伴生入口。每个上传凭证只属于一个准确的 Session,每个请求只使用一个已选定载体。载体不支持的 stream 会在发送请求体前失败。 + +----- + + +## 进一步探索 + +- [Connection](../connection/README.zh.md)——认证 RPC、Host 精确路由与 connection generation。 +- [Session Controller](../../api/session-controller/README.zh.md)——消费暂存凭证的 prompt 准入。 +- [Web Worker runtime](../../experimental/webworker-runtime/README.zh.md)——页面到 Host Worker 的请求隧道。 +- [客户端组地图](../README.zh.md)——浏览器服务与 UI 功能包。 + +----- + + +## 模型体验 + +无。本包只传输浏览器请求体,不提供模型输入。 + +#### KV Cache 影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + + + +以下限制适用于传输操作本身。 + +- **上传不能断点续传**:失败或取消后的重试会从第一个字节开始。 +- **stream 请求体只能使用一次**:转移 `ReadableStream` 会锁定调用方的对象,因此重试必须重新创建 stream。 +- **stream 进度没有总量**:stream API 不携带字节长度,因此调用方只能收到已消费字节数。 +- **浏览器 Worker 必须自包含**:其源代码由函数字符串生成。如果实现需要运行时 import,就必须迁移为由 tsdown 打包的独立 Worker 入口。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json new file mode 100644 index 0000000000..89d0b7ca76 --- /dev/null +++ b/packages/client/file-upload/package.json @@ -0,0 +1,84 @@ +{ + "name": "@deepseek-ai/dsh-client-file-upload", + "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service", + "version": "0.1.3-alpha.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/file-upload" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.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" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes" + ], + "platform": "web", + "immediately": true + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "files": [ + "lib/index.js", + "lib/client.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" + ], + "dependencies": { + "zod": "^4.4.3", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^" + } +} diff --git a/packages/client/file-upload/src/client/contract.ts b/packages/client/file-upload/src/client/contract.ts new file mode 100644 index 0000000000..998a71b2c3 --- /dev/null +++ b/packages/client/file-upload/src/client/contract.ts @@ -0,0 +1,35 @@ +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { FileUploadValue } from '../types.ts' + +/** Browser request body accepted by the background file-upload service. */ +export type FileUploadBody = Blob | ReadableStream + +/** Monotone byte progress reported while a browser body is consumed. */ +export interface FileUploadProgress { + readonly loaded: number + readonly total?: number +} + +/** Browser upload service addressed by one Session identity. */ +export interface FileUploadService { + /** Whether this page has a Host-backed background upload carrier. */ + readonly available: boolean + /** + * Store one file for a Session. Blob and stream bodies use + * the background carrier; exact bytes and fixture fallbacks use Remote. + * @param sessionId - Session that owns the staged receipt. + * @param data - browser Blob, exact bytes, or a one-shot byte stream. + * @param name - optional display name. + * @param signal - optional cancellation for the active upload. + * @param onProgress - optional byte-progress observer for background bodies. + * @returns the staged receipt and durable file reference, or a business error. + */ + upload( + sessionId: SessionId, + data: Blob | Uint8Array | ReadableStream, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: FileUploadProgress) => void, + ): Promise> +} diff --git a/packages/client/file-upload/src/client/index.ts b/packages/client/file-upload/src/client/index.ts new file mode 100644 index 0000000000..bf6696a423 --- /dev/null +++ b/packages/client/file-upload/src/client/index.ts @@ -0,0 +1,26 @@ +/** Browser background-upload Cordis service. */ + +import type { Context } from '@deepseek-ai/cordis' +import { FileUploadRuntime } from './runtime.ts' +import type { FileUploadService } from './contract.ts' + +export type { FileUploadProgress, FileUploadService } from './contract.ts' +export type { FileUploadReceiptId, FileUploadValue } from '../types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Session-addressed browser service for staged file uploads. */ + fileUpload: FileUploadService + } +} + +/** The upload service uses the generated Remote fallback. */ +export const inject = ['remote'] + +/** + * Provide the browser background-upload service. + * @param ctx - Client plugin context. + */ +export function apply(ctx: Context): void { + ctx.plugin(FileUploadRuntime) +} diff --git a/packages/client/file-upload/src/client/runtime.ts b/packages/client/file-upload/src/client/runtime.ts new file mode 100644 index 0000000000..a54e31121c --- /dev/null +++ b/packages/client/file-upload/src/client/runtime.ts @@ -0,0 +1,359 @@ +/** Background browser upload implementation for Blob and byte-stream bodies. */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { FILE_UPLOAD_PATH } from '../protocol.ts' +import type { + ClientFileUploadHooks, EncodedFileUploadRequest, FileUploadFetch, FileUploadValue, +} from '../types.ts' +import type { FileUploadBody, FileUploadService } from './contract.ts' + +interface FileUploadRequest { + readonly path: string + readonly body: FileUploadBody + readonly headers?: Readonly> + readonly signal?: AbortSignal + readonly onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void +} + +interface FileUploadResponse { + readonly status: number + readonly body: string +} + +interface FileUploadRemoteContext extends Context { + readonly remote: { + readonly fileUploads: { + upload( + sessionId: SessionId, + request: EncodedFileUploadRequest, + signal?: AbortSignal, + ): Promise> + } + } +} + +interface UploadWorkerStart { + readonly url: string + readonly body: FileUploadBody + readonly headers: Readonly> +} + +type UploadWorkerOutput = + | { readonly kind: 'progress'; readonly loaded: number; readonly total?: number } + | { readonly kind: 'complete'; readonly status: number; readonly body: string } + | { readonly kind: 'error'; readonly message: string } + +interface UploadWorkerScope { + onmessage: ((event: MessageEvent) => void) | null + postMessage(message: UploadWorkerOutput): void +} + +interface UploadXhr { + readonly upload: { onprogress: ((event: ProgressEvent) => void) | null } + status: number + responseText: string + withCredentials: boolean + onload: ((event: ProgressEvent) => void) | null + onerror: ((event: ProgressEvent) => void) | null + open(method: string, url: string): void + setRequestHeader(name: string, value: string): void + send(body: Blob): void +} + +type UploadWorkerFetch = ( + input: string, + init: RequestInit & { readonly duplex: 'half' }, +) => Promise + +/** + * Self-contained Worker body; its string form becomes the Blob Worker source. + * @param scope - Worker global used for requests and progress messages. + * @param createXhr - XMLHttpRequest factory used for Blob progress. + * @param doFetch - Fetch carrier used for one-shot ReadableStream bodies. + */ +export function fileUploadWorker( + scope: UploadWorkerScope = self, + createXhr: () => UploadXhr = () => new XMLHttpRequest(), + doFetch: UploadWorkerFetch = (input, init) => fetch(input, init), +): void { + scope.onmessage = (event: MessageEvent) => { + const request = event.data + if (request.body instanceof Blob) { + const xhr = createXhr() + xhr.open('POST', request.url) + xhr.withCredentials = true + for (const [name, value] of Object.entries(request.headers)) xhr.setRequestHeader(name, value) + xhr.upload.onprogress = (progress) => { + scope.postMessage({ + kind: 'progress', + loaded: progress.loaded, + ...(progress.lengthComputable ? { total: progress.total } : {}), + } satisfies UploadWorkerOutput) + } + xhr.onload = () => { + scope.postMessage({ kind: 'complete', status: xhr.status, body: xhr.responseText } satisfies UploadWorkerOutput) + } + xhr.onerror = () => { + scope.postMessage({ kind: 'error', message: 'background upload transport failed' } satisfies UploadWorkerOutput) + } + xhr.send(request.body) + return + } + if (!(request.body instanceof ReadableStream)) { + scope.postMessage({ kind: 'error', message: 'background upload worker received an invalid body' }) + return + } + const source = request.body + void (async () => { + const reader = source.getReader() + let loaded = 0 + const body = new ReadableStream({ + async pull(controller) { + const item = await reader.read() + if (item.done) { + controller.close() + return + } + if (!(item.value instanceof Uint8Array)) { + throw new TypeError('background upload stream produced a non-Uint8Array chunk') + } + loaded += item.value.byteLength + scope.postMessage({ kind: 'progress', loaded }) + controller.enqueue(item.value) + }, + async cancel(reason) { + await reader.cancel(reason) + }, + }) + const response = await doFetch(request.url, { + method: 'POST', + headers: request.headers, + credentials: 'include', + body, + duplex: 'half', + }) + scope.postMessage({ + kind: 'complete', + status: response.status, + body: await response.text(), + }) + })().catch((error: unknown) => { + scope.postMessage({ + kind: 'error', + message: error instanceof Error ? error.message : String(error), + }) + }) + } +} + +interface ClientFileUploadGlobal { + __DSH_FILE_UPLOAD__?: ClientFileUploadHooks +} + +interface FileUploadTransport { + post(request: FileUploadRequest): Promise +} + +/** Cordis service that owns one background carrier per upload operation. */ +export class FileUploadRuntime extends Service implements FileUploadService { + readonly available: boolean + private readonly transport: FileUploadTransport + + /** @param ctx - providing Client context. */ + constructor(ctx: Context) { + super(ctx, 'fileUpload') + const hook = (globalThis as ClientFileUploadGlobal).__DSH_FILE_UPLOAD__ + this.available = hook !== undefined || !isFixturePage() + this.transport = hook === undefined ? workerTransport() : customTransport(hook.fetch) + } + + /** + * Post one body with the carrier selected before Cordis boot. + * @param request - target, body, cancellation, and progress observer. + * @returns the response status and text body. + */ + post(request: FileUploadRequest): Promise { + if (!this.available) return Promise.reject(new Error('background upload is unavailable in fixture mode')) + return this.transport.post(request) + } + + /** + * Store one file for a Session. + * @param sessionId - Session that owns the staged receipt. + * @param data - browser Blob, exact bytes, or a one-shot byte stream. + * @param name - optional display name. + * @param signal - optional cancellation for the active upload. + * @param onProgress - optional byte-progress observer for background bodies. + * @returns the staged receipt and durable file reference, or a business error. + */ + async upload( + sessionId: SessionId, + data: Blob | Uint8Array | ReadableStream, + name?: string, + signal?: AbortSignal, + onProgress?: (progress: { readonly loaded: number; readonly total?: number }) => void, + ): Promise> { + if (!(data instanceof Uint8Array) && this.available) { + const query = new URLSearchParams({ sessionId }) + if (name !== undefined) query.set('name', name) + const response = await this.post({ + path: `${FILE_UPLOAD_PATH}?${query.toString()}`, + body: data, + headers: { 'content-type': 'application/octet-stream' }, + ...(signal === undefined ? {} : { signal }), + ...(onProgress === undefined ? {} : { onProgress }), + }) + if (response.status !== 200) { + throw new Error(`file upload transport failed with HTTP ${String(response.status)}`) + } + return parseFileUploadResult(response.body) + } + if (!(data instanceof Uint8Array) && !(data instanceof Blob)) { + throw new Error('stream file upload requires a background carrier') + } + const bytes = data instanceof Uint8Array ? data : new Uint8Array(await data.arrayBuffer()) + return (this.ctx as FileUploadRemoteContext).remote.fileUploads.upload( + sessionId, + { + data: bytesToBase64(bytes), + ...(name === undefined ? {} : { name }), + }, + signal, + ) + } +} + +function customTransport(customFetch: FileUploadFetch): FileUploadTransport { + return { + async post(request) { + const init: RequestInit & { duplex?: 'half' } = { + method: 'POST', + ...(request.headers === undefined ? {} : { headers: request.headers }), + body: request.body, + ...(request.body instanceof ReadableStream ? { duplex: 'half' as const } : {}), + ...(request.signal === undefined ? {} : { signal: request.signal }), + } + const response = await customFetch(resolveUrl(request.path), init) + return { status: response.status, body: await response.text() } + }, + } +} + +function workerTransport(): FileUploadTransport { + return { + post(request) { + if (typeof Worker !== 'function') { + return Promise.reject(new Error('background upload requires Web Worker support')) + } + const workerUrl = URL.createObjectURL(new Blob([ + `(${fileUploadWorker.toString()})()`, + ], { type: 'text/javascript' })) + const worker = new Worker(workerUrl, { name: 'dsh-file-upload' }) + URL.revokeObjectURL(workerUrl) + return new Promise((resolve, reject) => { + let settled = false + const abort = (): void => { + settled = true + worker.terminate() + request.signal?.removeEventListener('abort', abort) + reject(new DOMException('The operation was aborted.', 'AbortError')) + } + const finish = (settle: () => void): void => { + if (settled) return + settled = true + request.signal?.removeEventListener('abort', abort) + worker.terminate() + settle() + } + worker.onmessage = (event: MessageEvent) => { + const output = event.data + if (output.kind === 'progress') { + request.onProgress?.({ + loaded: output.loaded, + ...(output.total === undefined ? {} : { total: output.total }), + }) + } else if (output.kind === 'complete') { + finish(() => { resolve({ status: output.status, body: output.body }) }) + } else { + finish(() => { reject(new Error(output.message)) }) + } + } + worker.onerror = (event) => { + finish(() => { reject(new Error(event.message || 'background upload worker failed')) }) + } + if (request.signal?.aborted === true) { + abort() + return + } + request.signal?.addEventListener('abort', abort, { once: true }) + const message: UploadWorkerStart = { + url: resolveUrl(request.path).href, + body: request.body, + headers: request.headers ?? {}, + } + if (request.body instanceof ReadableStream) worker.postMessage(message, [request.body]) + else worker.postMessage(message) + }) + }, + } +} + +function resolveUrl(path: string): URL { + const pageLocation = Reflect.get(globalThis, 'location') as unknown + const origin = typeof pageLocation === 'object' && pageLocation !== null + && 'origin' in pageLocation && typeof pageLocation.origin === 'string' + ? pageLocation.origin + : undefined + return new URL(path, origin === undefined || origin === 'null' ? 'http://dsh.internal' : origin) +} + +function isFixturePage(): boolean { + const pageLocation = Reflect.get(globalThis, 'location') as unknown + return typeof pageLocation === 'object' && pageLocation !== null + && 'search' in pageLocation && typeof pageLocation.search === 'string' + && new URLSearchParams(pageLocation.search).has('fixture') +} + +function parseFileUploadResult(body: string): RemoteResult { + const value = JSON.parse(body) as unknown + if (!isRecord(value) || typeof value.ok !== 'boolean') { + throw new TypeError('file upload transport returned an invalid result') + } + if (!value.ok) { + const error = value.error + if (!isRecord(error) || typeof error.code !== 'string' + || typeof error.message !== 'string' || !isRecord(error.details)) { + throw new TypeError('file upload transport returned an invalid failure') + } + return { + ok: false, + error: new RemoteError(error.code as never, error.message, error.details as never), + } + } + const result = value.value + const file = isRecord(result) ? result.file : undefined + if (!isRecord(result) || typeof result.receiptId !== 'string' || !isRecord(file) + || typeof file.attachmentId !== 'string' || typeof file.name !== 'string' + || typeof file.bytes !== 'number' || !Number.isSafeInteger(file.bytes) || file.bytes < 0) { + throw new TypeError('file upload transport returned an invalid receipt') + } + return { + ok: true, + value: { + receiptId: result.receiptId as FileUploadValue['receiptId'], + file: { + attachmentId: file.attachmentId as FileUploadValue['file']['attachmentId'], + name: file.name, + bytes: file.bytes, + }, + }, + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/client/file-upload/src/http-route.ts b/packages/client/file-upload/src/http-route.ts new file mode 100644 index 0000000000..0f5539324e --- /dev/null +++ b/packages/client/file-upload/src/http-route.ts @@ -0,0 +1,81 @@ +/** Authenticated raw-byte upload route registered on the Connection fetch registry. */ + +import { brandString } from '@deepseek-ai/dsh-brand' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' +import type { FileUploads } from './index.ts' +import type { FileUploadValue } from './types.ts' + +type FileUploadHttpResult = + | { readonly ok: true; readonly value: FileUploadValue } + | { + readonly ok: false + readonly error: { readonly code: string; readonly message: string; readonly details: object } + } + +/** + * Handle one authenticated raw-byte upload. + * @param service - Host upload service receiving streamed bytes. + * @param request - authenticated HTTP request from Connection. + * @returns JSON result using HTTP status 200 after request validation. + */ +export async function handleFileUploadHttp(service: FileUploads, request: Request): Promise { + if (request.method !== 'POST') { + return new Response(null, { status: 405, headers: { allow: 'POST' } }) + } + const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() + if (mediaType !== 'application/octet-stream') { + return new Response('content type must be application/octet-stream', { status: 415 }) + } + const url = new URL(request.url) + const sessionId = url.searchParams.get('sessionId') + if (sessionId === null || sessionId === '') { + return new Response('sessionId is required', { status: 400 }) + } + const name = url.searchParams.get('name') ?? undefined + let result: FileUploadHttpResult + try { + result = { + ok: true, + value: await service.uploadStream({ + sessionId: brandString(sessionId), + data: requestBodyChunks(request.body), + signal: request.signal, + ...(name === undefined ? {} : { name }), + }), + } + } catch (error) { + const failure = remoteErrorOf(error) + result = { + ok: false, + error: failure !== undefined + ? { code: failure.code, message: failure.message, details: failure.details } + : { + code: 'gateway/internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + } + } + return new Response(JSON.stringify(result), { + status: 200, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }) +} + +async function* requestBodyChunks(body: ReadableStream | null): AsyncIterable { + if (body === null) return + const reader = body.getReader() + try { + while (true) { + const chunk = await reader.read() + if (chunk.done) return + yield chunk.value + } + } finally { + reader.releaseLock() + } +} diff --git a/packages/client/file-upload/src/index.ts b/packages/client/file-upload/src/index.ts new file mode 100644 index 0000000000..2520ba1225 --- /dev/null +++ b/packages/client/file-upload/src/index.ts @@ -0,0 +1,265 @@ +/** Host file-upload service: streamed intake and Agent-scoped staged receipts. */ + +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type {} from '@deepseek-ai/dsh-client-connection' +import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands' +import { scopeOf } from '@deepseek-ai/dsh-scope' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { handleFileUploadHttp } from './http-route.ts' +import { FILE_UPLOAD_PATH } from './protocol.ts' +import type { EncodedFileUploadRequest, FileUploadReceiptId, FileUploadValue } from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host storage and staged-receipt service for browser file uploads. */ + fileUploads: FileUploads + } +} + +interface StagedFileUpload { + readonly file: FileAttachmentRef + /** Prompt that accepted this receipt; absent until successful admission. */ + requestId?: string +} + +/** Resolve or resume the ordinary Agent that owns one Session identity. */ +export type AgentResolver = (sessionId: SessionId) => Promise + +/** Prompt receipt binding that restores its previous owners unless delivery commits it. */ +export interface PromptFileBinding extends Disposable { + /** Keep the receipt bindings until queue or history observation retires them. */ + commit(): void +} + +class PromptFileBindingGuard implements PromptFileBinding { + private settled = false + + constructor(private readonly rollback: () => void) {} + + commit(): void { + this.settled = true + } + + [Symbol.dispose](): void { + if (this.settled) return + this.settled = true + this.rollback() + } +} + +/** Host service owning upload storage and Agent-scoped staged receipts. */ +export class FileUploads extends TypertRemoteService { + static inject = ['agents', 'attachments', 'commands', 'connection'] + + private readonly stagedFiles = new WeakMap>() + private agentResolver: AgentResolver | undefined + + /** @param ctx - Host context carrying Agent, attachment, command, and Connection services. */ + constructor(ctx: Context) { + super(ctx, 'fileUploads') + const resolve: CommandFileReceiptResolver = (agent, receiptId) => + this.resolve(agent, receiptId as FileUploadReceiptId) + ctx.effect( + () => ctx.commands.registerFileReceiptResolver(resolve), + 'file-upload: command file receipt resolver', + ) + ctx.effect( + () => ctx.connection.fetch.register({ + path: FILE_UPLOAD_PATH, + methods: ['POST'], + requestBody: 'streaming', + fetch: request => handleFileUploadHttp(this, request), + }), + 'file-upload: streaming route', + ) + ctx.on('session/event', (session, event) => { this.observeSessionEvent(session, event) }) + ctx.on('session/disposed', (session) => { this.stagedFiles.delete(session) }) + } + + /** + * Register the ordinary-Session resolver used when a raw upload addresses a cold Session. + * @param resolve - resolver that returns the exact live Agent or throws a Remote error. + * @returns disposer removing this resolver. + */ + registerAgentResolver(resolve: AgentResolver): () => void { + if (this.agentResolver !== undefined) throw new Error('file-upload: Agent resolver is already registered') + this.agentResolver = resolve + return () => { + if (this.agentResolver === resolve) this.agentResolver = undefined + } + } + + /** + * Persist one encoded upload and stage it under the Agent receiver selected by Typert. + * @param agent - receiving Agent resolved from the Remote Agent scope. + * @param request - canonical base64 bytes and optional display name. + * @param signal - caller cancellation before storage begins. + * @returns the staged receipt and durable file reference. + */ + @Remote('upload') + upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise { + signal.throwIfAborted() + return this.commit(agent, async () => this.ctx.attachments.admitEncodedFile({ + data: request.data, + ...(request.name === undefined ? {} : { name: request.name }), + })) + } + + /** + * Persist raw chunks for one Session without aggregating the upload. + * @param request - Session identity, ordered bytes, cancellation, and optional display name. + * @returns the staged receipt and durable file reference. + */ + async uploadStream(request: { + readonly sessionId: SessionId + readonly data: AsyncIterable + readonly signal?: AbortSignal + readonly name?: string + }): Promise { + const agent = await this.resolveAgent(request.sessionId) + return this.commit(agent, async () => this.ctx.attachments.saveFileStream({ + data: request.data, + ...(request.signal === undefined ? {} : { signal: request.signal }), + ...(request.name === undefined ? {} : { name: request.name }), + })) + } + + /** + * Resolve one staged receipt inside its receiving Agent scope. + * @param agent - receiving Agent. + * @param receiptId - opaque receipt minted for one completed upload. + * @returns durable file reference, or `undefined` for an unknown or foreign receipt. + */ + resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined { + this.assertAgentScope(agent) + return this.stagedFiles.get(agent.session)?.get(receiptId)?.file + } + + /** + * Bind receipts while one prompt enters an Agent inbox. + * Disposal restores every prior binding unless the caller commits successful delivery. + * @param agent - receiving Agent. + * @param receiptIds - distinct staged receipts referenced by the prompt. + * @param requestId - prompt identity later observed in queue or history. + * @returns binding kept after commit until queue or history observation retires its receipts. + */ + bindPrompt( + agent: Agent, + receiptIds: readonly FileUploadReceiptId[], + requestId: string, + ): PromptFileBinding { + this.assertAgentScope(agent) + const staged = this.stagedFiles.get(agent.session) + const bound = receiptIds.map((receiptId) => { + const upload = staged?.get(receiptId) + if (upload === undefined) throw fileNotStaged() + return { upload, previous: upload.requestId } + }) + for (const { upload } of bound) upload.requestId = requestId + return new PromptFileBindingGuard(() => { + for (const { upload, previous } of bound) { + if (previous === undefined) delete upload.requestId + else upload.requestId = previous + } + }) + } + + /** + * Retire every receipt accepted by one removed queue occurrence. + * @param agent - receiving Agent. + * @param requestId - prompt identity carried by the queue occurrence. + */ + retirePrompt(agent: Agent, requestId: string): void { + this.assertAgentScope(agent) + this.retire(agent.session, requestId) + } + + private async commit(agent: Agent, save: () => Promise): Promise { + this.assertOrdinaryAgent(agent) + let file: FileAttachmentRef + try { + file = await save() + } catch (error) { + if (this.ctx.attachments.isAttachmentError(error)) { + throw new RemoteError('session/attachment-invalid' as never, error.message, { reason: error.code } as never) + } + throw new RemoteError( + 'gateway/internal', + `failed to store file upload: ${String(error)}`, + {}, + { cause: error }, + ) + } + if (this.ctx.agents.get(agent.id) !== agent) { + throw new RemoteError( + 'session/not-found', + `session "${agent.id}" was disposed before its file upload completed`, + { sessionId: agent.id }, + ) + } + let staged = this.stagedFiles.get(agent.session) + if (staged === undefined) { + staged = new Map() + this.stagedFiles.set(agent.session, staged) + } + const receiptId = randomUUID() as FileUploadReceiptId + staged.set(receiptId, { file }) + return { receiptId, file } + } + + private async resolveAgent(sessionId: SessionId): Promise { + const live = this.ctx.agents.get(sessionId) + if (live !== undefined) return live + const resolver = this.agentResolver + if (resolver === undefined) { + throw new RemoteError('session/not-found', `session "${sessionId}" is not attached`, { sessionId }) + } + return resolver(sessionId) + } + + private assertAgentScope(agent: Agent): void { + if (scopeOf(agent.ctx) !== agent) throw new Error('file-upload: operation requires the Agent\'s own scope') + } + + private assertOrdinaryAgent(agent: Agent): void { + this.assertAgentScope(agent) + if (agent.session.header.origin === 'subagent') { + throw new RemoteError( + 'subagent/attachment-invalid' as never, + 'subagent conversations do not accept file uploads', + { reason: 'SUBAGENT_FILE_UNSUPPORTED' } as never, + ) + } + } + + private observeSessionEvent(session: Session, event: SessionEvent): void { + if (event.type !== 'user/message' || event.data.source.kind !== 'user' + || !('rpcId' in event.data.source)) return + if (typeof event.data.source.rpcId === 'string') this.retire(session, event.data.source.rpcId) + } + + private retire(session: Session, requestId: string): void { + const staged = this.stagedFiles.get(session) + if (staged === undefined) return + for (const [receiptId, upload] of staged) { + if (upload.requestId === requestId) staged.delete(receiptId) + } + if (staged.size === 0) this.stagedFiles.delete(session) + } +} + +function fileNotStaged(): RemoteError { + return new RemoteError( + 'session/attachment-invalid' as never, + 'File was not uploaded for this session.', + { reason: 'FILE_NOT_STAGED' } as never, + ) +} + +export default FileUploads diff --git a/packages/client/file-upload/src/protocol.ts b/packages/client/file-upload/src/protocol.ts new file mode 100644 index 0000000000..67ec32811a --- /dev/null +++ b/packages/client/file-upload/src/protocol.ts @@ -0,0 +1,2 @@ +/** Authenticated raw-byte route owned by the file-upload service. */ +export const FILE_UPLOAD_PATH = '/api/session/uploadFileBinary' diff --git a/packages/client/file-upload/src/types.ts b/packages/client/file-upload/src/types.ts new file mode 100644 index 0000000000..b63b2452bf --- /dev/null +++ b/packages/client/file-upload/src/types.ts @@ -0,0 +1,35 @@ +/** Browser-safe request and receipt types for staged file uploads. */ + +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment/types' +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Canonical encoded upload accepted by the Remote fallback. */ +export interface EncodedFileUploadRequest { + /** Canonical base64 encoding of the exact file bytes. */ + readonly data: string + /** Optional display name; the Host sanitizes it into the stored leaf name. */ + readonly name?: string +} + +/** Durable receipt for one staged file upload. */ +export interface FileUploadValue { + /** Per-upload authority accepted only inside the receiving Agent scope. */ + readonly receiptId: FileUploadReceiptId + readonly file: FileAttachmentRef +} + +/** Host-minted authority for one staged file upload in one Agent scope. */ +export type FileUploadReceiptId = Branded<'file-upload-receipt-id'> + +/** + * Fetch-shaped carrier installed by a page that owns its Host transport. + * @param input - absolute same-origin upload URL. + * @param init - raw request body, headers, and cancellation signal. + * @returns the Host response. + */ +export type FileUploadFetch = (input: URL, init: RequestInit) => Promise + +/** Pre-Cordis hook supplied by a page whose Host runs in another execution context. */ +export interface ClientFileUploadHooks { + readonly fetch: FileUploadFetch +} diff --git a/packages/client/file-upload/tests/file-upload-http.host.spec.ts b/packages/client/file-upload/tests/file-upload-http.host.spec.ts new file mode 100644 index 0000000000..cd137857ce --- /dev/null +++ b/packages/client/file-upload/tests/file-upload-http.host.spec.ts @@ -0,0 +1,126 @@ +import { runInNewContext } from 'node:vm' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { describe, expect, it, vi } from 'vitest' +import type { Mock } from 'vitest' +import { handleFileUploadHttp } from '../src/http-route.ts' +import type { FileUploads } from '../src/index.ts' + +function request(input: { + method?: string + sessionId?: string + name?: string + contentType?: string + body?: Uint8Array +} = {}): Request { + const query = new URLSearchParams() + if (input.sessionId !== undefined) query.set('sessionId', input.sessionId) + if (input.name !== undefined) query.set('name', input.name) + const suffix = query.size === 0 ? '' : `?${query.toString()}` + return new Request(`http://host/api/session/uploadFileBinary${suffix}`, { + method: input.method ?? 'POST', + headers: input.contentType === undefined ? {} : { 'content-type': input.contentType }, + ...(input.body === undefined ? {} : { body: new Blob([Uint8Array.from(input.body).buffer]) }), + }) +} + +function uploads(result: unknown): FileUploads & { + uploadStream: Mock + uploadedChunks: Uint8Array[] +} { + const uploadedChunks: Uint8Array[] = [] + const uploadStream = vi.fn(async (input) => { + for await (const chunk of input.data) uploadedChunks.push(chunk) + return await result as Awaited> + }) + return { + uploadedChunks, + uploadStream, + } as unknown as FileUploads & { + uploadStream: Mock + uploadedChunks: Uint8Array[] + } +} + +describe('background file upload Fetch route', () => { + it('accepts one authenticated streaming POST request', async () => { + const service = uploads(Promise.resolve({})) + expect((await handleFileUploadHttp(service, request({ + sessionId: 's1', contentType: 'application/octet-stream', + }))).status).toBe(200) + }) + + it('rejects the wrong method, media type, and missing Session id without storing', async () => { + const service = uploads(Promise.resolve({})) + const wrongMethod = await handleFileUploadHttp(service, request({ method: 'GET' })) + expect(wrongMethod.status).toBe(405) + expect(wrongMethod.headers.get('allow')).toBe('POST') + + const wrongType = await handleFileUploadHttp(service, request({ contentType: 'application/json' })) + expect(wrongType.status).toBe(415) + expect(await wrongType.text()).toBe('content type must be application/octet-stream') + + const missingSession = await handleFileUploadHttp( + service, + request({ contentType: 'application/octet-stream' }), + ) + expect(missingSession.status).toBe(400) + expect(await missingSession.text()).toBe('sessionId is required') + expect(service.uploadStream).not.toHaveBeenCalled() + }) + + it('stores the request bytes and returns the staged receipt', async () => { + const value = { + receiptId: 'receipt-1', + file: { attachmentId: 'file-1', name: 'large & final.bin', bytes: 4 }, + } + const service = uploads(Promise.resolve(value)) + const response = await handleFileUploadHttp(service, request({ + sessionId: 's1', + name: 'large & final.bin', + contentType: 'application/octet-stream; charset=binary', + body: Uint8Array.of(1, 2, 3, 4), + })) + expect(service.uploadStream).toHaveBeenCalledOnce() + const upload = service.uploadStream.mock.calls[0]?.[0] + expect(upload).toMatchObject({ sessionId: 's1', name: 'large & final.bin' }) + expect(upload?.signal).toBeInstanceOf(AbortSignal) + expect(service.uploadedChunks).toEqual([Uint8Array.of(1, 2, 3, 4)]) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8') + expect(response.headers.get('cache-control')).toBe('no-store') + expect(await response.json()).toEqual({ ok: true, value }) + }) + + it('returns business and internal storage failures and keeps an absent name absent', async () => { + const business = uploads(Promise.reject(new RemoteError( + 'session/attachment-invalid', 'denied', { reason: 'NOPE' }, + ))) + const businessResponse = await handleFileUploadHttp(business, request({ + sessionId: 's1', contentType: 'application/octet-stream', + })) + expect(business.uploadStream).toHaveBeenCalledOnce() + const upload = business.uploadStream.mock.calls[0]?.[0] + expect(upload).toMatchObject({ sessionId: 's1' }) + expect(upload?.signal).toBeInstanceOf(AbortSignal) + expect(business.uploadedChunks).toEqual([]) + expect(await businessResponse.json()).toEqual({ + ok: false, + error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } }, + }) + + const internal = uploads(Promise.reject(new Error('disk offline'))) + expect(await (await handleFileUploadHttp(internal, request({ + sessionId: 's1', contentType: 'application/octet-stream', + }))).json()).toEqual({ + ok: false, error: { code: 'gateway/internal', message: 'disk offline', details: {} }, + }) + + const foreignError = runInNewContext('new Error("disk exception")') as unknown as Error + const exception = uploads(Promise.reject(foreignError)) + expect(await (await handleFileUploadHttp(exception, request({ + sessionId: 's1', contentType: 'application/octet-stream', + }))).json()).toEqual({ + ok: false, error: { code: 'gateway/internal', message: 'Error: disk exception', details: {} }, + }) + }) +}) diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts new file mode 100644 index 0000000000..66b27b102a --- /dev/null +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -0,0 +1,516 @@ +import { Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apply } from '../src/client/index.ts' +import { fileUploadWorker, FileUploadRuntime } from '../src/client/runtime.ts' +import type { FileUploadBody } from '../src/client/contract.ts' +import type { ClientFileUploadHooks } from '../src/types.ts' + +interface UploadGlobal { + __DSH_FILE_UPLOAD__?: ClientFileUploadHooks +} + +afterEach(() => { + delete (globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('file upload worker body', () => { + it('sends a Blob with credentials and reports progress, completion, and failure', () => { + const posted: unknown[] = [] + const scope: { + onmessage: ((event: MessageEvent<{ + url: string + body: FileUploadBody + headers: Readonly> + }>) => void) | null + postMessage(message: unknown): void + } = { onmessage: null, postMessage: (message: unknown) => { posted.push(message) } } + const xhr = { + upload: { onprogress: null as ((event: ProgressEvent) => void) | null }, + status: 201, + responseText: '{"ok":true}', + withCredentials: false, + onload: null as ((event: ProgressEvent) => void) | null, + onerror: null as ((event: ProgressEvent) => void) | null, + open: vi.fn(), + setRequestHeader: vi.fn(), + send: vi.fn(), + } + fileUploadWorker(scope, () => xhr) + const body = new Blob(['large']) + scope.onmessage?.({ + data: { url: 'https://harness.test/upload', body, headers: { 'content-type': 'application/octet-stream' } }, + } as never) + expect(xhr.open).toHaveBeenCalledWith('POST', 'https://harness.test/upload') + expect(xhr.withCredentials).toBe(true) + expect(xhr.setRequestHeader).toHaveBeenCalledWith('content-type', 'application/octet-stream') + expect(xhr.send).toHaveBeenCalledWith(body) + + xhr.upload.onprogress?.({ loaded: 2, total: 4, lengthComputable: true } as ProgressEvent) + xhr.upload.onprogress?.({ loaded: 3, total: 0, lengthComputable: false } as ProgressEvent) + xhr.onload?.({} as ProgressEvent) + xhr.onerror?.({} as ProgressEvent) + expect(posted).toEqual([ + { kind: 'progress', loaded: 2, total: 4 }, + { kind: 'progress', loaded: 3 }, + { kind: 'complete', status: 201, body: '{"ok":true}' }, + { kind: 'error', message: 'background upload transport failed' }, + ]) + }) + + it('streams Uint8Array chunks through fetch and reports consumed bytes', async () => { + const posted: unknown[] = [] + const scope = { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage: (message: unknown) => { posted.push(message) }, + } + const fetch = vi.fn(async (_url: string, init: RequestInit & { readonly duplex: 'half' }) => { + const chunks: number[][] = [] + for await (const chunk of init.body as ReadableStream) chunks.push([...chunk]) + expect(chunks).toEqual([[1, 2], [3]]) + expect(init).toMatchObject({ + method: 'POST', + headers: { 'x-test': 'yes' }, + credentials: 'include', + duplex: 'half', + }) + return new Response('stored', { status: 202 }) + }) + const body = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(1, 2)) + controller.enqueue(Uint8Array.of(3)) + controller.close() + }, + }) + fileUploadWorker(scope, () => { throw new Error('XHR must not handle streams') }, fetch) + scope.onmessage?.({ data: { url: 'https://harness.test/upload', body, headers: { 'x-test': 'yes' } } } as never) + await vi.waitFor(() => { + expect(posted).toEqual([ + { kind: 'progress', loaded: 2 }, + { kind: 'progress', loaded: 3 }, + { kind: 'complete', status: 202, body: 'stored' }, + ]) + }) + }) + + it('propagates cancellation from the fetch body to the source stream', async () => { + const posted: unknown[] = [] + const scope = { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage: (message: unknown) => { posted.push(message) }, + } + const cancel = vi.fn() + const source = new ReadableStream({ cancel }) + fileUploadWorker( + scope, + () => { throw new Error('unused') }, + async (_url, init) => { + await (init.body as ReadableStream).cancel('fetch stopped') + return new Response('cancelled') + }, + ) + scope.onmessage?.({ data: { url: '/upload', body: source, headers: {} } } as never) + await vi.waitFor(() => { + expect(cancel).toHaveBeenCalledWith('fetch stopped') + expect(posted.at(-1)).toEqual({ kind: 'complete', status: 200, body: 'cancelled' }) + }) + }) + + it('reports invalid bodies, stream chunks, and fetch failures', async () => { + const posted: unknown[] = [] + const scope = { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage: (message: unknown) => { posted.push(message) }, + } + fileUploadWorker(scope, () => { throw new Error('unused') }) + scope.onmessage?.({ data: { url: '/upload', body: 'bad', headers: {} } } as never) + expect(posted).toEqual([{ kind: 'error', message: 'background upload worker received an invalid body' }]) + + const badChunk = new ReadableStream({ start(controller) { controller.enqueue('bad'); controller.close() } }) + fileUploadWorker( + scope, + () => { throw new Error('unused') }, + async (_url, init) => { + await new Response(init.body).arrayBuffer() + return new Response() + }, + ) + scope.onmessage?.({ data: { url: '/upload', body: badChunk, headers: {} } } as never) + await vi.waitFor(() => { + expect(posted.at(-1)).toEqual({ + kind: 'error', message: 'background upload stream produced a non-Uint8Array chunk', + }) + }) + + const body = new ReadableStream({ start(controller) { controller.close() } }) + fileUploadWorker( + scope, + () => { throw new Error('unused') }, + () => Promise.reject(new Error('offline')), + ) + scope.onmessage?.({ data: { url: '/upload', body, headers: {} } } as never) + await vi.waitFor(() => { + expect(posted.at(-1)).toEqual({ kind: 'error', message: 'offline' }) + }) + + const failedSource = new ReadableStream({ + start(controller) { controller.error('source failed') }, + }) + fileUploadWorker( + scope, + () => { throw new Error('unused') }, + async (_url, init) => { + await (init.body as ReadableStream).getReader().read() + return new Response() + }, + ) + scope.onmessage?.({ data: { url: '/upload', body: failedSource, headers: {} } } as never) + await vi.waitFor(() => { + expect(posted.at(-1)).toEqual({ kind: 'error', message: 'source failed' }) + }) + }) + + it('uses Worker globals when the emitted body supplies no test seams', async () => { + const posted: unknown[] = [] + const scope = { + onmessage: null as ((event: MessageEvent) => void) | null, + postMessage: (message: unknown) => { posted.push(message) }, + } + const xhr = { + upload: { onprogress: null }, + status: 204, + responseText: '', + withCredentials: false, + onload: null, + onerror: null, + open: vi.fn(), + setRequestHeader: vi.fn(), + send: vi.fn(), + } + vi.stubGlobal('self', scope) + vi.stubGlobal('XMLHttpRequest', vi.fn(function () { return xhr })) + fileUploadWorker() + scope.onmessage?.({ data: { url: '/upload', body: new Blob(), headers: {} } } as MessageEvent) + expect(xhr.send).toHaveBeenCalledOnce() + + const fetch = vi.fn(async (_url: string, init: RequestInit) => { + await new Response(init.body).arrayBuffer() + return new Response(null, { status: 204 }) + }) + vi.stubGlobal('fetch', fetch) + fileUploadWorker() + const stream = new ReadableStream({ start(controller) { controller.close() } }) + scope.onmessage?.({ data: { url: '/stream', body: stream, headers: {} } } as MessageEvent) + await vi.waitFor(() => { + expect(fetch).toHaveBeenCalledOnce() + expect(posted.at(-1)).toEqual({ kind: 'complete', status: 204, body: '' }) + }) + }) +}) + +describe('file upload service', () => { + it('uses a page-owned Host fetch for Blob and ReadableStream bodies', async () => { + vi.stubGlobal('location', { origin: 'https://preview.test' }) + const fetch = vi.fn((_url: URL, _init?: RequestInit) => + Promise.resolve(new Response('accepted', { status: 202 }))) + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + const blob = new Blob(['opaque']) + const signal = new AbortController().signal + await expect((ctx.fileUpload as FileUploadRuntime).post({ + path: '/api/upload', body: blob, headers: { 'x-test': 'yes' }, signal, + })).resolves.toEqual({ status: 202, body: 'accepted' }) + expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/api/upload'), { + method: 'POST', headers: { 'x-test': 'yes' }, body: blob, signal, + }) + + const stream = new ReadableStream({ start(controller) { controller.close() } }) + await (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream }) + expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/stream'), { + method: 'POST', body: stream, duplex: 'half', + }) + await fiber.dispose() + }) + + it('mounts through the plugin entry and resolves non-browser URLs', async () => { + vi.stubGlobal('location', { origin: 'null' }) + const fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))) + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } + const ctx = new Context() + const fiber = ctx.plugin({ apply }) + await fiber + const body = new Blob() + await (ctx.fileUpload as FileUploadRuntime).post({ path: '/fallback', body }) + expect(fetch).toHaveBeenCalledWith(new URL('http://dsh.internal/fallback'), { + method: 'POST', body, + }) + await fiber.dispose() + }) + + it('leaves the fixture on its generated Remote fallback', async () => { + vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' }) + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + expect(ctx.fileUpload.available).toBe(false) + await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })) + .rejects.toThrow('background upload is unavailable in fixture mode') + await fiber.dispose() + }) + + it('fails loud when a served browser has no Worker implementation', async () => { + vi.stubGlobal('Worker', undefined) + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })) + .rejects.toThrow('background upload requires Web Worker support') + await fiber.dispose() + }) + + it('forwards progress and completion from a dedicated Worker and then terminates it', async () => { + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + class FakeWorker { + static last: FakeWorker | undefined + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + readonly postMessage = vi.fn() + readonly terminate = vi.fn() + constructor(readonly url: string, readonly options: WorkerOptions) { FakeWorker.last = this } + } + vi.stubGlobal('Worker', FakeWorker) + vi.stubGlobal('location', { origin: 'https://harness.test' }) + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + const progress = vi.fn() + const blob = new Blob(['bytes']) + const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/api/upload', body: blob, onProgress: progress }) + const worker = FakeWorker.last + if (worker === undefined) throw new Error('worker missing') + expect(created).toHaveBeenCalledOnce() + expect(revoked).toHaveBeenCalledWith('blob:worker') + expect(worker.postMessage).toHaveBeenCalledWith({ + url: 'https://harness.test/api/upload', body: blob, headers: {}, + }) + worker.onmessage?.({ data: { kind: 'progress', loaded: 4, total: 5 } } as MessageEvent) + worker.onmessage?.({ data: { kind: 'progress', loaded: 6 } } as MessageEvent) + worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent) + worker.onmessage?.({ data: { kind: 'complete', status: 500, body: 'late' } } as MessageEvent) + await expect(pending).resolves.toEqual({ status: 200, body: 'done' }) + expect(progress.mock.calls).toEqual([ + [{ loaded: 4, total: 5 }], + [{ loaded: 6 }], + ]) + expect(worker.terminate).toHaveBeenCalledOnce() + await fiber.dispose() + }) + + it('transfers stream ownership to the dedicated Worker', async () => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + class FakeWorker { + static last: FakeWorker | undefined + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + readonly postMessage = vi.fn() + readonly terminate = vi.fn() + constructor() { FakeWorker.last = this } + } + vi.stubGlobal('Worker', FakeWorker) + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + const stream = new ReadableStream({ start(controller) { controller.close() } }) + const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream }) + const worker = FakeWorker.last + if (worker === undefined) throw new Error('worker missing') + expect(worker.postMessage).toHaveBeenCalledWith(expect.objectContaining({ body: stream }), [stream]) + worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent) + await expect(pending).resolves.toEqual({ status: 200, body: 'done' }) + await fiber.dispose() + }) + + it('rejects worker messages, worker errors, and caller cancellation', async () => { + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + class FakeWorker { + static all: FakeWorker[] = [] + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + readonly postMessage = vi.fn() + readonly terminate = vi.fn() + constructor() { FakeWorker.all.push(this) } + } + vi.stubGlobal('Worker', FakeWorker) + const ctx = new Context() + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + + const reported = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }) + FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent) + await expect(reported).rejects.toThrow('network failed') + + const errored = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }) + FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent) + await expect(errored).rejects.toThrow('worker crashed') + + const unnamed = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }) + FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent) + await expect(unnamed).rejects.toThrow('background upload worker failed') + + const controller = new AbortController() + const aborted = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: controller.signal }) + controller.abort() + await expect(aborted).rejects.toMatchObject({ name: 'AbortError' }) + expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce() + + const already = new AbortController() + already.abort() + await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: already.signal })) + .rejects.toMatchObject({ name: 'AbortError' }) + expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled() + await fiber.dispose() + }) +}) + +describe('Session-addressed file upload', () => { + const SESSION_ID = 's1' as SessionId + + async function scopedService(options: { + readonly remote?: ReturnType + } = {}) { + const ctx = new Context() + const remote = options.remote ?? vi.fn(() => Promise.resolve({ + ok: true, + value: { + receiptId: 'remote-receipt', + file: { attachmentId: 'remote-file', name: 'file', bytes: 3 }, + }, + })) + ctx.provide('remote', { fileUploads: { upload: remote } } as never) + const fiber = ctx.plugin(FileUploadRuntime) + await fiber + return { ctx, fiber, remote, service: ctx.fileUpload } + } + + it('assembles the scoped streaming request and parses progress and receipt fields', async () => { + vi.stubGlobal('location', { origin: 'https://preview.test' }) + const progress = vi.fn() + const fetch = vi.fn((_url: URL, init: RequestInit) => { + expect(init.body).toBeInstanceOf(Blob) + progress({ loaded: 2, total: 4 }) + return Promise.resolve(new Response(JSON.stringify({ + ok: true, + value: { + receiptId: 'receipt-1', + file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 }, + }, + }), { status: 200 })) + }) + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } + const { fiber, service } = await scopedService() + const signal = new AbortController().signal + const file = new Blob(['data']) + + await expect(service.upload(SESSION_ID, file, 'notes & refs.pdf', signal, progress)).resolves.toEqual({ + ok: true, + value: { + receiptId: 'receipt-1', + file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 }, + }, + }) + expect(fetch).toHaveBeenCalledWith( + new URL('https://preview.test/api/session/uploadFileBinary?sessionId=s1&name=notes+%26+refs.pdf'), + expect.objectContaining({ + method: 'POST', + headers: { 'content-type': 'application/octet-stream' }, + body: file, + signal, + }), + ) + await fiber.dispose() + }) + + it('uses the direct Remote fallback for exact bytes and fixture Blob bodies', async () => { + vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' }) + const remote = vi.fn(() => Promise.resolve({ + ok: true, + value: { + receiptId: 'remote-receipt', + file: { attachmentId: 'remote-file', name: 'bytes.bin', bytes: 3 }, + }, + })) + const { fiber, service } = await scopedService({ remote }) + await expect(service.upload(SESSION_ID, Uint8Array.of(0, 0, 0), 'bytes.bin')) + .resolves.toMatchObject({ ok: true }) + await expect(service.upload(SESSION_ID, new Blob([Uint8Array.of(1)]))) + .resolves.toMatchObject({ ok: true }) + expect(remote.mock.calls).toEqual([ + [SESSION_ID, { data: 'AAAA', name: 'bytes.bin' }, undefined], + [SESSION_ID, { data: 'AQ==' }, undefined], + ]) + await fiber.dispose() + }) + + it('rejects an unavailable stream and malformed background results', async () => { + vi.stubGlobal('location', { origin: 'https://fixture.test', search: '?fixture' }) + const fixture = await scopedService() + const stream = new ReadableStream({ start(controller) { controller.close() } }) + await expect(fixture.service.upload(SESSION_ID, stream)) + .rejects.toThrow('stream file upload requires a background carrier') + await fixture.fiber.dispose() + + vi.stubGlobal('location', { origin: 'https://preview.test' }) + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: () => Promise.resolve(new Response(null, { status: 413 })), + } + const rejected = await scopedService() + await expect(rejected.service.upload(SESSION_ID, new Blob())) + .rejects.toThrow('file upload transport failed with HTTP 413') + await rejected.fiber.dispose() + + const bodies: unknown[] = [ + null, + { ok: 'yes' }, + { ok: false, error: null }, + { ok: false, error: { code: 1, message: 'denied', details: {} } }, + { ok: false, error: { code: 'denied', message: 1, details: {} } }, + { ok: false, error: { code: 'denied', message: 'denied', details: null } }, + { ok: true, value: null }, + { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } }, + ] + for (const body of bodies) { + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: () => Promise.resolve(new Response(JSON.stringify(body), { status: 200 })), + } + const malformed = await scopedService() + await expect(malformed.service.upload(SESSION_ID, new Blob())) + .rejects.toThrow(/file upload transport returned an invalid/) + await malformed.fiber.dispose() + } + + ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: () => Promise.resolve(new Response(JSON.stringify({ + ok: false, + error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } }, + }), { status: 200 })), + } + const failed = await scopedService() + await expect(failed.service.upload(SESSION_ID, new Blob())).resolves.toMatchObject({ + ok: false, + error: { + code: 'session/attachment-invalid', + message: 'denied', + details: { reason: 'NOPE' }, + }, + }) + await failed.fiber.dispose() + }) +}) diff --git a/packages/client/file-upload/tsconfig.client.json b/packages/client/file-upload/tsconfig.client.json new file mode 100644 index 0000000000..dc25e3845d --- /dev/null +++ b/packages/client/file-upload/tsconfig.client.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/contract.ts", + "src/client/index.ts", + "src/client/runtime.ts", + "src/protocol.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../util/brand" }, + { "path": "../../util/crypto" }, + { "path": "../../attachment/attachment" }, + { "path": "../../core/session" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/client/file-upload/tsconfig.host.json b/packages/client/file-upload/tsconfig.host.json new file mode 100644 index 0000000000..6c8c29bc15 --- /dev/null +++ b/packages/client/file-upload/tsconfig.host.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": ["src/http-route.ts", "src/index.ts", "src/protocol.ts", "src/types.ts"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../util/brand" }, + { "path": "../../core/agent" }, + { "path": "../../core/scope" }, + { "path": "../../core/session" }, + { "path": "../../attachment/attachment" }, + { "path": "../../interaction/commands" }, + { "path": "../../typert/protocol" }, + { "path": "../connection/tsconfig.host.json" } + ] +} diff --git a/packages/client/file-upload/tsconfig.json b/packages/client/file-upload/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/client/file-upload/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/client/file-upload/tsdown.config.ts b/packages/client/file-upload/tsdown.config.ts new file mode 100644 index 0000000000..97292cd3cc --- /dev/null +++ b/packages/client/file-upload/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-file-upload', ['lib/types/index.js']) diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index fd84e0880a..e3758ac8b5 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 3a94469e67..382f74ece8 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 41f2be2b94..0fc07f2808 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/package.json b/packages/client/store/package.json index 7098fa466e..2ca4eb3739 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 3c2f9ef51c..1d792dfbf7 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index 9e718a46ec..be914929fa 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-approval/src/client/contract/slots.ts b/packages/client/ui-approval/src/client/contract/slots.ts index 545260b9ae..99296e0c92 100644 --- a/packages/client/ui-approval/src/client/contract/slots.ts +++ b/packages/client/ui-approval/src/client/contract/slots.ts @@ -68,7 +68,7 @@ let nextApprovalKey = 0 /** One answerable Client presentation of a pending Host waterfall. */ export class PendingApproval { /** Domain discriminator used by Session pending-interaction consumers. */ - readonly kind = 'approval' as const + readonly kind: 'approval' /** Opaque render identity and one-shot remount axis. */ readonly key: string /** Tool requesting the decision. */ @@ -92,6 +92,7 @@ export class PendingApproval { * @param request - Host approval request projected through the Remote Event. */ constructor(readonly sessionId: SessionId, request: ApprovalPresentationRequest) { + this.kind = 'approval' nextApprovalKey += 1 this.key = `approval:${String(nextApprovalKey)}` this.toolName = request.toolName diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index aab45ae0a5..16c1ad7c69 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/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-attachment/README.md -README.md: e6945b6cc902cdf8b9a7ef19f849b92de8b0e2cb -README.zh.md: d7410e1179bd773cdda213423bb4b2ad04ef7b5a +README.md: 4a271c134d25bac536e525adbe469f772ea74c09 +README.zh.md: 499ffcb36462df99924c962f4feecf88dd6ee3dd diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index e6945b6cc9..4a271c134d 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -1,5 +1,5 @@ --- -description: "Attachment presentation for the conversation UI: draft-image rail, document drop target, history-image gallery, and original-image lightbox; for users and maintainers of the Web attachment experience." +description: "Attachment presentation for the conversation UI: mixed draft-attachment rail, document drop target, history-image gallery, and original-image lightbox; for users and maintainers of the Web attachment experience." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package renders everything the conversation UI shows about attachments: pending draft images under the composer, a full-viewport drop invitation, durable images in Chat, Trajectory, and Tool results, and a lightbox for the original image. It is a pure presentation layer — attachment data, image loading, and callbacks come from the conversation package through declared slots. Choose it for the DeepSeek Chat-style image experience; non-image files have no surface here. +This package renders everything the conversation UI shows about attachments: one ordered draft rail under the composer, a full-viewport drop invitation, durable images in Chat, Trajectory, and Tool results, and a lightbox for the original image. Attachment data, upload state, image loading, and callbacks come from the declared slot owners. Choose it for the DeepSeek Chat-style attachment experience. ## Table of Contents @@ -25,15 +25,15 @@ This package renders everything the conversation UI shows about attachments: pen ## Use this package -Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md) (and [`ui-tool`](../ui-tool/README.md) for the tool-result gallery); it waits for the conversation package's slot declarations and registers its surfaces into them. Users then see the draft-image rail with per-image remove and click-to-open, the drop overlay with its limits line, message images sized by count, the tool card's gallery, and the Escape/mask/close lightbox. +Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md) and [`ui-tool`](../ui-tool/README.md) when tool results need an image gallery. It waits for their slot declarations and registers its components into them. Users then see the mixed draft-attachment rail, DeepSeek Web file cards with upload controls, the drop overlay with its limits line, message images sized by count, the tool card's gallery, and the Escape/mask/close lightbox. -### Draft images +### Draft attachments -A draft image shows as a fixed 64px thumbnail in one horizontally scrolling row; edge arrows page the rail when overflow hides items, and the scrollbar stays hidden. A newly added item is revealed at the rail's end, removal keeps the scroll position, and a single click opens the original through the owner's `onOpen`. +Images and generic files retain pick order in one non-wrapping horizontal rail. Every item is 64px high: an image is a 64px square thumbnail, while a generic file is a 240px-wide DeepSeek Web card with a 16px radius, blue gradient document glyph, filename, and uppercase extension plus byte size. Edge arrows page hidden overflow, the scrollbar stays hidden, and a newly added item is revealed at the rail's end. Uploading replaces a file glyph with a spinner and shows byte progress when the carrier reports it, with an indeterminate bar before the first report; failure shows retry, and removal controls appear on hover or keyboard focus while remaining visible on touch devices. Clicking an image opens the original. ### Message images and the lightbox -A message's lone image renders at 240px on its longer edge (aspect clamped to [0.25, 4], never upscaled); images among several render as fixed 64px squares. A loaded image opens the document-level lightbox on click; a failed load shows a retry control instead. The lightbox closes on Escape, a mask press, or its close control, and restores focus to its opener. +In Chat, one user message presents files and images in a right-aligned wrapping flow that preserves source order. A lone image without another attachment renders at 240px on its longer edge (aspect clamped to [0.25, 4], never upscaled); when the message has more than one attachment, each image is a fixed 64px square beside 240×64px file cards. A loaded image opens the document-level lightbox on click; a failed load shows a retry control instead. The lightbox closes on Escape, a mask press, or its close control, and restores focus to its opener. ### Drop overlay @@ -51,8 +51,8 @@ The plugin waits for `conversation.input.attachments`, `conversation.message.ima | File | Role | |---|---| -| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | Draft-image rail + drop overlay assembly | -| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | Scrolling thumbnail rail, wheel translation, edge arrows | +| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | Ordered image/file rail + drop overlay assembly | +| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | Horizontal attachment overflow, wheel translation, edge arrows | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | Per-message gallery + lightbox assembly | | [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open; local submission-echo previews render their object URL directly | | [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | Document-level modal preview over the shared mask | @@ -89,7 +89,6 @@ None; this package neither assembles nor sends a provider request. These limits define the current attachment surface. They are package constraints, not a general image-viewer comparison or a task backlog. -- **Images only** — non-image files have no rail card or history renderer yet; DeepSeek Chat-style file cards and upload progress wait until the composer accepts non-image attachments. - **No zoom or download in the lightbox** — the preview renders the original at fit-to-viewport size only. - **The lightbox does not trap focus** — it sets `aria-modal` and restores focus on close, but Tab can reach the page behind it. diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index d7410e1179..499ffcb364 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -1,5 +1,5 @@ --- -description: "对话 UI 的附件呈现:草稿图片栏、文档拖放目标、历史图片画廊与原图灯箱;供 Web 附件体验的用户与维护者阅读。" +description: "对话 UI 的附件呈现:混合草稿附件栏、文档拖放目标、历史图片画廊与原图灯箱;供 Web 附件体验的用户与维护者阅读。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包渲染对话 UI 中与附件相关的一切:composer 下的待发送草稿图片、全视口拖放邀请层、Chat、Trajectory 与工具结果中的持久图片,以及查看原图的灯箱。它是纯呈现层——附件数据、图片加载与回调都经声明槽位来自 conversation 包。需要 DeepSeek Chat 风格的图片体验时选择它;非图片文件在此没有任何表面。 +本包渲染对话 UI 中与附件相关的一切:composer 下的一条有序草稿附件栏、全视口拖放邀请层、Chat、Trajectory 与工具结果中的持久图片,以及查看原图的灯箱。附件数据、上传状态、图片加载与回调来自声明这些槽位的持有方。需要 DeepSeek Chat 风格的附件体验时选择它。 ## 目录 @@ -25,15 +25,15 @@ kind: "package-reference" ## 使用本包 -与 [`ui-conversation`](../ui-conversation/README.zh.md)(以及工具结果图库所需的 [`ui-tool`](../ui-tool/README.zh.md))一起挂载本插件;它等待 conversation 包的槽位声明,并把自身表面注册进这些槽位。用户随即看到:带逐图删除与点击打开的草稿图片栏、带上限说明的拖放遮罩、按数量定尺寸的消息图片、工具卡片的图库,以及支持 Escape/遮罩/关闭按钮的灯箱。 +与 [`ui-conversation`](../ui-conversation/README.zh.md) 一起挂载本插件,工具结果需要图片图库时也要挂载 [`ui-tool`](../ui-tool/README.zh.md)。插件等待这些槽位的声明,并把组件注册进去。用户会看到混合草稿附件栏、带上传控件的 DeepSeek Web 文件卡、带上限说明的拖放遮罩、按数量定尺寸的消息图片、工具卡片图库,以及支持 Escape、遮罩和关闭按钮的灯箱。 -### 草稿图片 +### 草稿附件 -草稿图片以固定 64px 缩略图呈现在一行横向滚动条中;溢出隐藏时由边缘箭头翻页,滚动条保持隐藏。新增条目滚动到栏尾展示,删除保持原位,单击经持有方的 `onOpen` 打开原图。 +图片与通用文件按选择顺序进入同一条不换行的横向附件栏。所有条目均为 64px 高:图片是 64px 方形缩略图,通用文件是 240px 宽的 DeepSeek Web 卡片,带 16px 圆角、蓝色渐变文档图标、文件名,以及大写扩展名与字节大小。溢出隐藏时由边缘箭头翻页,滚动条保持隐藏,新增条目会滚动到栏尾展示。文件上传时图标位置显示 spinner,传输层报告字节时显示进度,首次报告前使用不定态进度条;失败时显示重试。移除按钮在悬停或键盘聚焦时出现,在触摸设备上保持可见。单击图片会打开原图。 ### 消息图片与灯箱 -一条消息仅有的一张图按长边 240px 渲染(宽高比钳制在 [0.25, 4],从不放大);多图中的一张渲染为固定 64px 方块。加载完成的图片单击打开文档级灯箱;加载失败则显示重试控件。灯箱按 Escape、按下遮罩或点关闭按钮关闭,并把焦点还给打开者。 +Chat 中的一条用户消息把文件与图片放在同一个靠右、可换行的排列中,并保持来源顺序。消息仅有一张图片且没有其他附件时,图片按长边 240px 渲染(宽高比钳制在 [0.25, 4],从不放大);消息有多个附件时,每张图片显示为固定 64px 方块,与 240×64px 文件卡同排。加载完成的图片单击打开文档级灯箱;加载失败则显示重试控件。灯箱按 Escape、按下遮罩或点关闭按钮关闭,并把焦点还给打开者。 ### 拖放遮罩 @@ -51,8 +51,8 @@ kind: "package-reference" | 文件 | 职责 | |---|---| -| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | 草稿图片栏+拖放遮罩的组装 | -| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | 滚动缩略图栏、滚轮转换、边缘箭头 | +| [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | 有序图片/文件栏+拖放遮罩的组装 | +| [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | 附件横向溢出、滚轮转换、边缘箭头 | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | 每消息画廊+灯箱的组装 | | [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开;本地提交回显预览直接显示其 object URL | | [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | 铺在共享遮罩上的文档级模态预览 | @@ -89,7 +89,6 @@ kind: "package-reference" 这些限制界定了当前附件表面。它们是包约束,不是通用图片查看器对比或任务积压。 -- **仅支持图片**——非图片文件尚无附件栏卡片与历史渲染;DeepSeek Chat 风格的文件卡片和上传进度等输入框接受非图片附件后再做。 - **灯箱无缩放与下载**——预览仅以适配视口的尺寸渲染原图。 - **灯箱不锁定焦点**——它设置 `aria-modal` 并在关闭时归还焦点,但 Tab 仍可移动到背后的页面。 diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 7b9681d70a..1c5781690c 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/src/AttachmentRail.module.css b/packages/client/ui-attachment/src/AttachmentRail.module.css index eb06d8d1df..53acfde4a7 100644 --- a/packages/client/ui-attachment/src/AttachmentRail.module.css +++ b/packages/client/ui-attachment/src/AttachmentRail.module.css @@ -5,6 +5,7 @@ .rail { display: flex; + align-items: stretch; gap: 10px; overflow-x: auto; overflow-y: hidden; @@ -22,9 +23,7 @@ } .item { - position: relative; - flex: 0 0 64px; - width: 64px; + flex: none; height: 64px; } @@ -83,7 +82,6 @@ transition: none; } } - .arrow { position: absolute; top: 50%; diff --git a/packages/client/ui-attachment/src/AttachmentRail.tsx b/packages/client/ui-attachment/src/AttachmentRail.tsx index 65e2df109b..e2e8f97321 100644 --- a/packages/client/ui-attachment/src/AttachmentRail.tsx +++ b/packages/client/ui-attachment/src/AttachmentRail.tsx @@ -1,31 +1,23 @@ -/** Draft-attachment thumbnail rail: scrollbar-less horizontal overflow paged - * by edge arrows, hover-revealed per-item remove, single-click open. */ +/** Draft-attachment rail: scrollbar-less horizontal overflow paged by edge arrows. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { ReactNode } from 'react' import clsx from 'clsx' import { - IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14, + IconChevronLeftOutline14, IconChevronRightOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' import css from './AttachmentRail.module.css' -/** One rail thumbnail; strings arrive resolved (zero-cordis atom). */ +/** One ordered draft attachment rendered by the rail owner. */ export interface AttachmentRailItem { /** Stable identity for the React key. */ id: string - /** Object or data URL rendered as the thumbnail. */ - previewUrl: string - /** Image alt text (display name with the owner's fallback applied). */ - alt: string - /** Accessible label of the item's remove control. */ - removeLabel: string } /** Rail-level strings the owner resolves from its own locale namespace. */ export interface AttachmentRailLabels { /** Accessible name of the rail group. */ group: string - /** Thumbnail tooltip inviting the original-image preview. */ - open: string /** Accessible label of the left paging arrow. */ scrollLeft: string /** Accessible label of the right paging arrow. */ @@ -40,12 +32,12 @@ const WHEEL_LINE_PX = 16 function pageBehavior(): ScrollBehavior { // jsdom (the unit lane) implements no matchMedia despite lib.dom's // non-optional typing; the optional call keeps that lane on the default. - // oxlint-disable-next-line typescript/no-unnecessary-condition - return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' + const matchMedia = (window as unknown as { matchMedia?: Window['matchMedia'] }).matchMedia + return matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' } /** - * Horizontal thumbnail rail over the caller's draft attachments. + * Horizontal rail over the caller's ordered draft attachments. * * The rail scrolls with its scrollbar hidden; overflow is announced by edge * arrows recomputed from scroll geometry on scroll, item-count changes, and @@ -53,22 +45,18 @@ function pageBehavior(): ScrollBehavior { * panel resizes count, not only window resizes). A vertical wheel pans the * rail horizontally and is consumed exclusively (non-passive listener), a * newly added item is revealed at the rail's end while a rail that mounts - * over an existing draft keeps its start position, and each thumbnail opens - * on a single click while its remove control sits inside the card and - * reveals on hover or focus. The owner decides mounting; it renders the rail - * only while items exist. + * over an existing draft keeps its start position. The owner renders each + * item and decides mounting; it renders the rail only while items exist. * - * @param props.items - resolved thumbnails in draft order. - * @param props.labels - rail-level strings (group name, open tooltip, arrows). - * @param props.onOpen - single-click open of one item's original image. - * @param props.onRemove - remove one item from the draft. + * @param props.items - attachments in draft order. + * @param props.labels - rail-level strings (group name and paging arrows). + * @param props.renderItem - render one attachment card in draft order. * @returns the rail group with its paging arrows. */ -export function AttachmentRail({ items, labels, onOpen, onRemove }: { +export function AttachmentRail({ items, labels, renderItem }: { items: readonly T[] labels: AttachmentRailLabels - onOpen: (item: T) => void - onRemove: (item: T) => void + renderItem: (item: T) => ReactNode }) { const railRef = useRef(null) // null marks the first layout pass: a rail that MOUNTS over an existing @@ -165,24 +153,7 @@ export function AttachmentRail({ items, labels, on onScroll={updateEdges} > {items.map(item => ( -
- - -
+
{renderItem(item)}
))} {edges.right && ( diff --git a/packages/client/ui-attachment/src/FileCard.module.css b/packages/client/ui-attachment/src/FileCard.module.css new file mode 100644 index 0000000000..71de9ec4bf --- /dev/null +++ b/packages/client/ui-attachment/src/FileCard.module.css @@ -0,0 +1,153 @@ +.card { + position: relative; + display: inline-flex; + align-items: center; + gap: 10px; + width: 240px; + height: 64px; + padding: 0 12px; + border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12)); + border-radius: 16px; + background: var(--dsw-specific-input-major, transparent); + box-sizing: border-box; + text-align: left; +} + +.failed { + border-color: var(--dsw-alias-state-error-primary, #d54941); +} + +.icon { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; +} + +.spinner { + width: 20px; + height: 20px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + corner-shape: round; + animation: file-card-spin 0.8s linear infinite; +} + +@keyframes file-card-spin { + to { transform: rotate(360deg); } +} + +.body { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + padding: 8px 0; +} + +.retry { + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--dsw-alias-label-primary); + font-size: 14px; + font-weight: 500; + line-height: 22px; +} + +.meta { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45)); + font-size: 12px; + line-height: 15px; +} + +.metaFailed { + color: var(--dsw-alias-state-error-primary, #d54941); +} + +.remove { + position: absolute; + top: 6px; + right: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: 50%; + corner-shape: round; + background: var(--dsw-alias-button-contrast-fill, rgba(0, 0, 0, 0.72)); + color: var(--dsw-alias-label-primary-inverted, #fff); + opacity: 0; + cursor: pointer; + transition: opacity 0.2s ease-in-out; +} + +.card:hover .remove, +.remove:focus-visible { + opacity: 1; +} + +.card:hover .name, +.card:focus-within .name { + padding-right: 18px; +} + +.removeFailed { + background: var(--dsw-alias-state-error-primary, #d54941); + color: #fff; + opacity: 1; +} + +.progressTrack { + position: absolute; + right: 12px; + bottom: 5px; + left: 12px; + overflow: hidden; + height: 2px; + border-radius: 1px; + background: var(--dsw-alias-fill-tertiary, rgba(0, 0, 0, 0.08)); +} + +.progressBar { + display: block; + width: 35%; + height: 100%; + border-radius: inherit; + background: var(--dsw-alias-brand-primary, #4d6bfe); + animation: file-card-progress 1.2s ease-in-out infinite alternate; +} + +.progressBar[style] { + animation: none; +} + +@keyframes file-card-progress { + from { transform: translateX(-70%); } + to { transform: translateX(220%); } +} + +@media (pointer: coarse) { + .remove { + opacity: 1; + } +} diff --git a/packages/client/ui-attachment/src/FileCard.tsx b/packages/client/ui-attachment/src/FileCard.tsx new file mode 100644 index 0000000000..d5634b7d9a --- /dev/null +++ b/packages/client/ui-attachment/src/FileCard.tsx @@ -0,0 +1,89 @@ +import { DocumentFileIcon, fileSizeText } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './FileCard.module.css' + +/** Localized strings consumed by one pending-file card. */ +export interface FileCardLabels { + /** Card body announcement, e.g. "Pending file {name}". */ + readonly label: string + /** Remove-button label. */ + readonly remove: string + /** Status line while the upload is in flight. */ + readonly uploading: string + /** Status line and retry affordance after a failed upload. */ + readonly failed: string + /** Retry-button label. */ + readonly retry: string +} + +/** Upload display state resolved by the owner. */ +export type FileCardState = 'uploading' | 'ready' | 'error' + +function extensionOf(name: string): string { + const dot = name.lastIndexOf('.') + if (dot <= 0 || dot === name.length - 1) return '' + return name.slice(dot + 1).toUpperCase().slice(0, 8) +} + +/** One pending generic-file card: name, size or upload status, remove, retry. */ +export function FileCard({ + name, bytes, state, progress, labels, onRemove, onRetry, +}: { + name: string + bytes: number + state: FileCardState + progress?: number + labels: FileCardLabels + onRemove: () => void + onRetry: () => void +}) { + const extension = extensionOf(name) + const meta = state === 'uploading' + ? labels.uploading + : state === 'error' + ? labels.failed + : [extension, fileSizeText(bytes)].filter(part => part !== '').join(' ') + const retryable = state === 'error' + return ( +
+ + {state === 'uploading' + ? + : } + + {retryable + ? ( + + ) + : ( + + {name} + {meta} + + )} + + {state === 'uploading' && ( + + + + )} +
+ ) +} diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 61554fe93f..3cf3b6c51e 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -139,15 +139,16 @@ export function MessageImage({ image, load, variant, labels }: { } /** Wrapping image group shared by user and assistant history: a lone image - * renders large, several render as 64px square tiles (DeepSeek Chat rule). */ -export function ImageGallery({ images, load, align, labels }: { + * renders large unless its owning mixed-attachment row requests compact tiles. */ +export function ImageGallery({ images, load, align, compact = false, labels }: { images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' + compact?: boolean labels: MessageImageLabels }) { if (images.length === 0) return null - const variant = images.length === 1 ? 'single' : 'tile' + const variant = compact || images.length > 1 ? 'tile' : 'single' return (
{images.map((image, index) => ( diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css index 85185ca9a7..1c9c72aa2d 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css @@ -5,3 +5,64 @@ closer to the draft text than the other rows, so pull back 6 here. */ margin-bottom: -6px; } + +.imageItem { + position: relative; + width: 64px; + height: 64px; +} + +.thumbnail { + width: 64px; + height: 64px; + padding: 0; + overflow: hidden; + border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 16px; + background: var(--dsw-alias-interactive-bg-hover); + cursor: zoom-in; +} + +.thumbnail img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.remove { + position: absolute; + top: 4px; + right: 4px; + z-index: 1; + display: grid; + place-items: center; + width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: 50%; + corner-shape: round; + background: var(--dsw-alias-button-contrast-fill); + color: var(--dsw-alias-label-primary-inverted); + cursor: pointer; + opacity: 0; + transition: opacity 0.2s ease-in-out; +} + +.imageItem:hover .remove, +.remove:focus-visible { + opacity: 1; +} + +@media (pointer: coarse) { + .remove { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .remove { + transition: none; + } +} diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx index 1e45dc6230..fd469195bf 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -1,12 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { - ComposerAttachment, ComposerAttachmentsProps, + ComposerAttachment, ComposerAttachmentsProps, ComposerImageAttachment, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' import { AttachmentRail } from '../AttachmentRail.tsx' import type { AttachmentRailItem } from '../AttachmentRail.tsx' import { DropOverlay } from '../DropOverlay.tsx' +import { FileCard } from '../FileCard.tsx' import { ImageLightbox } from '../ImageLightbox.tsx' -import { attachmentRailLabels, dropOverlayLabels, lightboxLabels } from './labels.ts' +import { attachmentRailLabels, dropOverlayLabels, fileCardLabels, lightboxLabels } from './labels.ts' import css from './ComposerAttachments.module.css' /** Rail item retaining its browser-owned attachment for callbacks. */ @@ -14,15 +16,14 @@ interface ComposerRailItem extends AttachmentRailItem { attachment: ComposerAttachment } -/** Draft-image rail, document drop target, and original-image preview slot entry. */ +/** Draft image previews, pending-file cards, drop target, and original-image preview. */ export function ComposerAttachments({ - attachments, canAcceptDrop, onAddImages, onRemoveImage, dropLimits, t, + attachments, canAcceptDrop, onAddFiles, onRemoveAttachment, uploads, onRetryFile, dropLimits, t, }: ComposerAttachmentsProps) { - const [preview, setPreview] = useState(null) + const [preview, setPreview] = useState(null) const [dragActive, setDragActive] = useState(false) const dragDepth = useRef(0) const closePreview = useCallback(() => { setPreview(null) }, []) - useEffect(() => { if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null) }, [attachments, preview]) @@ -62,7 +63,7 @@ export function ComposerAttachments({ if (dataTransfer === null) return event.preventDefault() reset() - if (canAcceptDrop) onAddImages([...dataTransfer.files]) + if (canAcceptDrop) onAddFiles([...dataTransfer.files]) } document.addEventListener('dragenter', onDragEnter) document.addEventListener('dragover', onDragOver) @@ -76,15 +77,12 @@ export function ComposerAttachments({ document.removeEventListener('drop', onDrop) window.removeEventListener('dragend', reset) } - }, [canAcceptDrop, onAddImages]) + }, [canAcceptDrop, onAddFiles]) const railItems = useMemo(() => attachments.map(attachment => ({ id: attachment.id, - previewUrl: attachment.previewUrl, - alt: attachment.file.name || t('image.pending'), - removeLabel: t('image.remove', { name: attachment.file.name }), attachment, - })), [attachments, t]) + })), [attachments]) return ( <> @@ -99,8 +97,47 @@ export function ComposerAttachments({ { setPreview(item.attachment) }} - onRemove={(item) => { onRemoveImage(item.attachment.id) }} + renderItem={(item) => { + const attachment = item.attachment + if (attachment.kind === 'file') { + const upload = uploads[attachment.id] + return ( + 0 + ? { progress: upload.loaded / upload.total } + : {}} + labels={fileCardLabels(t, attachment.file.name)} + onRemove={() => { onRemoveAttachment(attachment.id) }} + onRetry={() => { onRetryFile(attachment.id) }} + /> + ) + } + return ( +
+ + +
+ ) + }} />
)} diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index c84914db54..92cdaa455b 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -3,6 +3,14 @@ import { ImageGallery } from '../MessageImage.tsx' import { messageImageLabels } from './labels.ts' /** Historical message-image slot entry. */ -export function MessageImages({ images, loadImage, align, t }: MessageImagesProps) { - return +export function MessageImages({ images, loadImage, align, compact = false, t }: MessageImagesProps) { + return ( + + ) } diff --git a/packages/client/ui-attachment/src/client/labels.ts b/packages/client/ui-attachment/src/client/labels.ts index 4dcc3567db..990f8959c7 100644 --- a/packages/client/ui-attachment/src/client/labels.ts +++ b/packages/client/ui-attachment/src/client/labels.ts @@ -1,6 +1,7 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { AttachmentRailLabels } from '../AttachmentRail.tsx' import type { DropOverlayLabels } from '../DropOverlay.tsx' +import type { FileCardLabels } from '../FileCard.tsx' import type { ImageLightboxLabels } from '../ImageLightbox.tsx' import type { MessageImageLabels } from '../MessageImage.tsx' @@ -41,23 +42,38 @@ export function dropOverlayLabels( accepting: boolean, limits?: { readonly count: number; readonly size: string }, ): DropOverlayLabels { - if (!accepting) return { title: t('image.dropBlocked') } + if (!accepting) return { title: t('attachment.dropBlocked') } return { - title: t('image.dropTitle'), - desc: limits === undefined ? undefined : t('image.dropDesc', limits), + title: t('attachment.dropTitle'), + desc: limits === undefined ? undefined : t('attachment.dropDesc', limits), } } /** - * Resolve draft-image rail strings from the conversation namespace. + * Resolve pending-file card strings from the conversation namespace. + * @param t - conversation namespace translator. + * @param name - browser file name interpolated into remove/retry labels. + * @returns translated file-card labels. + */ +export function fileCardLabels(t: TranslateNS<'conversation'>, name: string): FileCardLabels { + return { + label: t('file.pending'), + remove: t('file.remove', { name }), + uploading: t('file.uploading'), + failed: t('file.uploadFailed'), + retry: t('file.retry', { name }), + } +} + +/** + * Resolve the mixed draft-attachment rail strings from the conversation namespace. * @param t - conversation namespace translator. * @returns translated attachment-rail labels. */ export function attachmentRailLabels(t: TranslateNS<'conversation'>): AttachmentRailLabels { return { - group: t('image.pending'), - open: t('image.openOriginal'), - scrollLeft: t('image.scrollLeft'), - scrollRight: t('image.scrollRight'), + group: t('attachment.pending'), + scrollLeft: t('attachment.scrollLeft'), + scrollRight: t('attachment.scrollRight'), } } diff --git a/packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx b/packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx index cc08a78d1c..089a16672a 100644 --- a/packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx +++ b/packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx @@ -26,15 +26,16 @@ afterEach(() => { vi.unstubAllGlobals() }) const labels: AttachmentRailLabels = { group: '待发送图片', - open: '查看原图', scrollLeft: '向左滚动图片', scrollRight: '向右滚动图片', } function item(id: string): AttachmentRailItem { - return { id, previewUrl: `blob:${id}`, alt: `${id}.png`, removeLabel: `移除图片 ${id}.png` } + return { id } } +const renderItem = (entry: AttachmentRailItem) => {entry.id} + /** Stub the rail's scroll geometry (jsdom reports 0 for every metric). */ function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }) { Object.defineProperty(rail, 'scrollWidth', { value: scrollWidth, configurable: true }) @@ -53,22 +54,16 @@ function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollW } describe('AttachmentRail', () => { - it('renders thumbnails in order and routes open and remove clicks', () => { - const onOpen = vi.fn() - const onRemove = vi.fn() + it('renders owner-provided attachment cards in order', () => { const items = [item('a'), item('b')] - const view = render() + const view = render() const rail = view.getByRole('group', { name: '待发送图片' }) - expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['a.png', 'b.png']) - fireEvent.click(view.getAllByTitle('查看原图')[0]!) - expect(onOpen).toHaveBeenCalledWith(items[0]) - fireEvent.click(view.getByRole('button', { name: '移除图片 b.png' })) - expect(onRemove).toHaveBeenCalledWith(items[1]) + expect([...rail.children].map(child => child.textContent)).toEqual(['a', 'b']) }) it('shows edge arrows from scroll geometry and pages a viewport at a time', () => { const view = render( - , + , ) const rail = view.getByRole('group', { name: '待发送图片' }) const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) @@ -94,7 +89,7 @@ describe('AttachmentRail', () => { it('shows both arrows mid-scroll and recomputes when the rail itself resizes', () => { const view = render( - , + , ) const rail = view.getByRole('group', { name: '待发送图片' }) const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) @@ -110,7 +105,7 @@ describe('AttachmentRail', () => { it('keeps scrolling available when ResizeObserver is unavailable', () => { vi.stubGlobal('ResizeObserver', undefined) const view = render( - , + , ) expect(view.getByRole('group', { name: '待发送图片' })).toBeTruthy() view.unmount() @@ -118,7 +113,7 @@ describe('AttachmentRail', () => { it('pans horizontally on a vertical wheel, consuming the event, with clamped normalized travel', () => { const view = render( - , + , ) const rail = view.getByRole('group', { name: '待发送图片' }) const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) @@ -148,7 +143,7 @@ describe('AttachmentRail', () => { for (const [matches, behavior] of [[true, 'auto'], [false, 'smooth']] as const) { vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList)) const view = render( - , + , ) const rail = view.getByRole('group', { name: '待发送图片' }) const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) @@ -162,16 +157,16 @@ describe('AttachmentRail', () => { it('reveals the rail end when an item is added, not when one is removed', () => { const first = [item('a'), item('b')] const view = render( - , + , ) const rail = view.getByRole('group', { name: '待发送图片' }) stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) view.rerender( - , + , ) expect(rail.scrollLeft).toBe(200) view.rerender( - , + , ) // Removal keeps the position; only growth jumps to the end. expect(rail.scrollLeft).toBe(200) diff --git a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx index ef77068f7e..8e6f01474e 100644 --- a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx +++ b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx @@ -22,24 +22,37 @@ afterEach(() => { const t = ((key: string, params?: Readonly>): string => { const messages: Record = { + 'attachment.pending': '待发送附件', + 'attachment.scrollLeft': '向左滚动附件', + 'attachment.scrollRight': '向右滚动附件', + 'file.pending': '待发送文件', + 'file.uploading': '上传中…', + 'file.uploadFailed': '上传失败,点击重试', + 'file.label': '文件', 'image.pending': '待发送图片', 'image.original': '原图', 'image.preview': '原图预览', 'image.closePreview': '关闭原图预览', 'image.openOriginal': '查看原图', - 'image.scrollLeft': '向左滚动图片', - 'image.scrollRight': '向右滚动图片', - 'image.dropBlocked': '当前无法添加图片', - 'image.dropTitle': '图片拖动到此处即可添加', + 'attachment.dropBlocked': '当前无法添加文件或图片', + 'attachment.dropTitle': '文件或图片拖动到此处即可添加', + } + if (key === 'file.remove') { + const name = params?.name + return `移除文件 ${typeof name === 'string' ? name : ''}` + } + if (key === 'file.retry') { + const name = params?.name + return `重试上传 ${typeof name === 'string' ? name : ''}` } if (key === 'image.remove') { const name = params?.name return `移除图片 ${typeof name === 'string' ? name : ''}` } - if (key === 'image.dropDesc') { + if (key === 'attachment.dropDesc') { const count = params?.count const size = params?.size - return `最多 ${typeof count === 'number' ? String(count) : ''} 张,每张 ${typeof size === 'string' ? size : ''}` + return `图片限制:最多 ${typeof count === 'number' ? String(count) : ''} 张,每张 ${typeof size === 'string' ? size : ''}` } return messages[key] ?? key }) as ComposerAttachmentsProps['t'] @@ -53,12 +66,22 @@ function attachment(id: string, name = `${id}.png`): ComposerAttachment { } } +function fileDraft(id: string, name = `${id}.pdf`): ComposerAttachment { + return { + kind: 'file', + id: id as ComposerAttachment['id'], + file: new File([Uint8Array.of(1, 2, 3)], name, { type: 'application/pdf' }), + } +} + function props(overrides: Partial = {}): ComposerAttachmentsProps { return { attachments: [], canAcceptDrop: true, - onAddImages: () => {}, - onRemoveImage: () => {}, + onAddFiles: () => {}, + onRemoveAttachment: () => {}, + uploads: {}, + onRetryFile: () => {}, t, ...overrides, } as unknown as ComposerAttachmentsProps @@ -66,9 +89,9 @@ function props(overrides: Partial = {}): Composer describe('ComposerAttachments', () => { it('accepts file drops anywhere on the document and keeps non-file drags native', () => { - const onAddImages = vi.fn() + const onAddFiles = vi.fn() const view = render() @@ -82,12 +105,12 @@ describe('ComposerAttachments', () => { const image = attachment('dropped').file const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } expect(fireEvent.dragEnter(document.body, { dataTransfer })).toBe(false) - expect(view.getByRole('status').textContent).toContain('图片拖动到此处即可添加') - expect(view.getByRole('status').textContent).toContain('最多 20 张,每张 5MB') + expect(view.getByRole('status').textContent).toContain('文件或图片拖动到此处即可添加') + expect(view.getByRole('status').textContent).toContain('图片限制:最多 20 张,每张 5MB') expect(fireEvent.dragOver(document.body, { dataTransfer })).toBe(false) expect(dataTransfer.dropEffect).toBe('copy') expect(fireEvent.drop(document.body, { dataTransfer })).toBe(false) - expect(onAddImages).toHaveBeenCalledWith([image]) + expect(onAddFiles).toHaveBeenCalledWith([image]) expect(view.queryByRole('status')).toBeNull() }) @@ -118,30 +141,30 @@ describe('ComposerAttachments', () => { }) it('shows a blocked drop without forwarding its files', () => { - const onAddImages = vi.fn() - const view = render() + const onAddFiles = vi.fn() + const view = render() const image = attachment('blocked').file const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' } fireEvent.dragEnter(document.body, { dataTransfer }) - expect(view.getByRole('status').textContent).toBe('当前无法添加图片') + expect(view.getByRole('status').textContent).toBe('当前无法添加文件或图片') fireEvent.dragOver(document.body, { dataTransfer }) expect(dataTransfer.dropEffect).toBe('none') fireEvent.drop(document.body, { dataTransfer }) - expect(onAddImages).not.toHaveBeenCalled() + expect(onAddFiles).not.toHaveBeenCalled() expect(view.queryByRole('status')).toBeNull() }) it('routes rail removal and closes previews on Escape or attachment removal', () => { - const onRemoveImage = vi.fn() + const onRemoveAttachment = vi.fn() const image = attachment('draft-1', 'pixel.png') - const initial = props({ attachments: [image], onRemoveImage }) + const initial = props({ attachments: [image], onRemoveAttachment }) const view = render() fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' })) - expect(onRemoveImage).toHaveBeenCalledWith(image.id) + expect(onRemoveAttachment).toHaveBeenCalledWith(image.id) fireEvent.click(view.getByTitle('查看原图')) expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() - view.rerender() + view.rerender() expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() view.rerender() @@ -150,6 +173,24 @@ describe('ComposerAttachments', () => { expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() }) + it('keeps images and files in pick order inside one attachment rail', () => { + const view = render() + const rail = view.getByRole('group', { name: '待发送附件' }) + expect([...rail.children].map((child) => { + const image = child.querySelector('img') + return image?.getAttribute('alt') ?? child.querySelector('[title]')?.getAttribute('title') + })).toEqual(['first.png', 'middle.pdf', 'last.png']) + expect(view.queryByRole('group', { name: '待发送文件' })).toBeNull() + }) + it('labels an unnamed attachment and its original-image preview', () => { const image = attachment('unnamed', '') const view = render() @@ -158,3 +199,68 @@ describe('ComposerAttachments', () => { expect(view.getByAltText('原图')).toBeTruthy() }) }) + +describe('ComposerAttachments file drafts', () => { + it('renders uploading, ready, and failed cards with remove and retry affordances', () => { + const onRemoveAttachment = vi.fn() + const onRetryFile = vi.fn() + const view = render() + const group = view.getByRole('group', { name: '待发送附件' }) + expect(group.textContent).toContain('上传中…') + expect(view.container.querySelector('[style="width: 25%;"]')).toBeTruthy() + expect(group.textContent).toContain('ok.pdf') + expect(group.textContent).toContain('PDF 3B') + expect(group.textContent).toContain('上传失败,点击重试') + fireEvent.click(view.getByRole('button', { name: '重试上传 bad.pdf' })) + expect(onRetryFile).toHaveBeenCalledWith('bad') + fireEvent.click(view.getByRole('button', { name: '移除文件 ok.pdf' })) + expect(onRemoveAttachment).toHaveBeenCalledWith('ok') + }) + + it('treats a draft without upload state as uploading and keeps retry separate from remove', () => { + const onRetryFile = vi.fn() + const onRemoveAttachment = vi.fn() + const view = render() + expect(view.getByRole('group', { name: '待发送附件' }).textContent).toContain('上传中…') + const retry = view.getByRole('button', { name: '重试上传 bad.pdf' }) + const remove = view.getByRole('button', { name: '移除文件 bad.pdf' }) + expect(retry.contains(remove)).toBe(false) + fireEvent.click(remove) + expect(onRemoveAttachment).toHaveBeenCalledWith('bad') + expect(onRetryFile).not.toHaveBeenCalled() + fireEvent.click(retry) + expect(onRetryFile).toHaveBeenCalledWith('bad') + }) + + it('uses the localized file label when the browser supplies no name', () => { + const view = render() + const group = view.getByRole('group', { name: '待发送附件' }) + expect(group.textContent).toContain('文件') + expect(group.textContent).toContain('3B') + }) +}) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 76a720b983..595f20cb1c 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -219,11 +219,16 @@ describe('ImageGallery', () => { expect(view.getByAltText('echo.png')).toBeTruthy() }) - it('renders a lone image large and several images as square tiles', () => { + it('renders a lone image large and compact or grouped images as square tiles', () => { const load = vi.fn(() => new Promise(() => {})) const lone = render() expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1) lone.unmount() + const compact = render( + , + ) + expect(compact.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(1) + compact.unmount() const several = render( , ) @@ -272,9 +277,9 @@ describe('ImageGallery', () => { useInput, inputActions: { setDraft: vi.fn(), - addImages: vi.fn(() => true), - removeImage: vi.fn(), - pruneImages: vi.fn(), + addAttachments: vi.fn(() => true), + removeAttachment: vi.fn(), + pruneAttachments: vi.fn(), submit: vi.fn(), }, images: [{ attachment }], diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 020dba8b91..9e7d5bf5c8 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 8183f95981..c086da700b 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: 79214bf2feb8384cecbb152aa31af162dc6dfdde -README.zh.md: 674959e1974c74abba75f179eff50fe8488458fc +README.md: 5a557f2a2125bddfe18631359665d6fa5e998458 +README.zh.md: 2fc241ad71e35eb54afcf4dde38c361248848d5f diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 79214bf2fe..5a557f2a21 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -25,7 +25,7 @@ The browser Chat target for Conversation assembly. It registers Chat event defin ## System prompt row -Chat shows a collapsed `System prompt` row for each non-empty initial or resumed request, explicit message-series start, or real system-field change. It does not repeat the row for same-series config-only or tool-only changes, tool steps, or retries. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A partial history window renders a non-initial header conservatively until the preceding page arrives; a header without a system prompt creates no row. +Chat shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, real system-field change, or non-initial request whose preceding header is outside the loaded history window. Once that predecessor is available, an unchanged resume does not repeat the row; same-series config-only or tool-only changes, tool steps, and retries also create no repetition. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A header without a system prompt creates no row. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 674959e197..2fc241ad71 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -25,7 +25,7 @@ Conversation 组装的浏览器 Chat target。本包注册 Chat event definition ## 系统提示词行 -Chat 会为每个非空的初始或恢复请求、显式消息序列起点或真实 system 字段变化显示一行默认折叠的`系统提示词`。同一序列内仅配置或仅工具变化、工具步骤与重试不会重复该行。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示保留原始换行的精确模型可见文本。历史窗口不完整时,非初始 header 会保守显示,直到前一页到达;没有系统提示词的 header 不创建该行。 +Chat 会为非空的初始请求、显式消息序列起点、真实 system 字段变化,或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的`系统提示词`。前序 header 到达后,内容未变的 resume 不会重复该行;同一序列内仅配置或仅工具变化、工具步骤与重试也不会重复。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示保留原始换行的精确模型可见文本。没有系统提示词的 header 不创建该行。 ----- diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index d6071127e6..7b63dd130b 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index 7c616f4a0e..2f44408b33 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -292,3 +292,57 @@ animation: none; } } + +.attachmentRow { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + max-width: 100%; + gap: 8px; +} + +.fileCard { + display: inline-flex; + flex: 0 0 240px; + align-items: center; + gap: 10px; + width: 240px; + min-height: 64px; + padding: 8px 12px; + border: 0.5px solid var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.12)); + border-radius: 16px; + background: var(--dsw-specific-input-major, transparent); + box-sizing: border-box; +} + +.fileIcon { + flex: none; + width: 24px; + height: 28px; +} + +.fileContent { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; +} + +.fileName { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--dsw-alias-label-primary); + font-size: 14px; + font-weight: 500; + line-height: 22px; +} + +.fileMeta { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--dsw-alias-label-tertiary, rgba(0, 0, 0, 0.45)); + font-size: 12px; + line-height: 15px; +} diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 07e9fb32a0..fc4f257048 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -1,8 +1,8 @@ -import { memo, useEffect, useMemo, useState } from 'react' +import { Fragment, memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { DocumentFileIcon, fileSizeText, JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' import { CompactionItem } from './CompactionItem.tsx' @@ -11,24 +11,37 @@ import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' type UserImage = Extract +type UserFile = Extract +type PresentedAttachment = + | { readonly type: 'image'; readonly image: MessageImageSource } + | { readonly type: 'file'; readonly file: UserFile['attachment'] } + +function extensionOf(name: string): string { + const dot = name.lastIndexOf('.') + if (dot <= 0 || dot === name.length - 1) return '' + return name.slice(dot + 1).toUpperCase().slice(0, 8) +} function contentParts(content: readonly unknown[]): { text: string - images: { attachment: UserImage['attachment'] }[] + attachments: PresentedAttachment[] rest: unknown[] } { const texts: string[] = [] - const images: { attachment: UserImage['attachment'] }[] = [] + const attachments: PresentedAttachment[] = [] const rest: unknown[] = [] for (const block of content) { const b = block as { type?: string; text?: string; attachment?: unknown } if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text) else if (b.type === 'image' && b.attachment !== undefined) { - images.push({ attachment: (b as UserImage).attachment }) + attachments.push({ type: 'image', image: { attachment: (b as UserImage).attachment } }) + } + else if (b.type === 'file' && b.attachment !== undefined) { + attachments.push({ type: 'file', file: (b as UserFile).attachment }) } else rest.push(block) } - return { text: texts.join(''), images, rest } + return { text: texts.join(''), attachments, rest } } function retrySeconds(milliseconds: number): number { @@ -148,7 +161,8 @@ function TurnMaxTokensItem({ t }: { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t, + content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], skillNames = [], + previewAttachments, t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -160,12 +174,15 @@ function UserStyleBubble({ echo?: boolean /** Exact session mention labels associated by the adjacent recall node. */ referenceLabels?: readonly string[] - /** Local submission-echo previews replacing the content-derived image group. */ - previewImages?: readonly MessageImageSource[] + /** Skill names the step's `skill-invocation` injections loaded for this message. */ + skillNames?: readonly string[] + /** Local submission-echo attachments replacing the content-derived attachment sequence. */ + previewAttachments?: readonly PresentedAttachment[] t: ChatViewSlotProps['t'] }): ReactNode { - const { text, images: contentImages, rest } = contentParts(content) - const images = previewImages ?? contentImages + const { text, attachments: contentAttachments, rest } = contentParts(content) + const attachments = previewAttachments ?? contentAttachments + const compactImages = attachments.length > 1 const truncated = (total: number): string => t('json.truncated', { total }) const showBubble = text !== '' || rest.length > 0 return ( @@ -175,9 +192,34 @@ function UserStyleBubble({ data-submission-echo={echo || undefined} >
- {renderMessageImages({ images, align: 'end' })} + {attachments.length > 0 && ( +
+ {attachments.map((attachment, index) => attachment.type === 'image' + ? ( + + {renderMessageImages({ + images: [attachment.image], + align: 'end', + compact: compactImages, + })} + + ) + : ( + + + + {attachment.file.name} + + {[extensionOf(attachment.file.name), fileSizeText(attachment.file.bytes)] + .filter(Boolean).join(' ')} + + + + ))} +
+ )} {showBubble &&
- {projectUserText(text, referenceLabels)} + {projectUserText(text, referenceLabels, skillNames)} {rest.map((block, i) => )}
} {referenceLabels.length > 0 && ( @@ -237,21 +279,26 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }: () => (submission.text === '' ? [] : [{ type: 'text', text: submission.text }]), [submission.text], ) - const previewImages = useMemo( - () => submission.images.map(image => ({ - preview: { - url: image.previewUrl, - ...(image.name === undefined ? {} : { name: image.name }), - ...(image.width === undefined ? {} : { width: image.width }), - ...(image.height === undefined ? {} : { height: image.height }), - }, - })), - [submission.images], + const previewAttachments = useMemo( + () => submission.attachments.map(attachment => attachment.type === 'image' + ? { + type: 'image', + image: { + preview: { + url: attachment.value.previewUrl, + ...(attachment.value.name === undefined ? {} : { name: attachment.value.name }), + ...(attachment.value.width === undefined ? {} : { width: attachment.value.width }), + ...(attachment.value.height === undefined ? {} : { height: attachment.value.height }), + }, + }, + } + : { type: 'file', file: attachment.value }), + [submission.attachments], ) return ( ( 0)) return node + const data: Record = { ...candidate.data } + if (names.length === 0) delete data.skillNames + else data.skillNames = names + return { ...candidate, data } +} + +/** What skill-name batches are made of; every other Node is transparent. */ +type SlashEntryKind = 'message' | 'skill' | 'boundary' + +interface SlashEntry { + readonly key: string + readonly seq: number + readonly kind: SlashEntryKind + /** The injected skill name of a `skill` entry. */ + readonly name: string | null +} + +/** + * Classify one Node for batching: a direct message, a `skill-invocation` + * context, or a boundary of any other kind. A context that injects no skill + * (workspace rules, the catalog, a recall) is transparent and yields null. + */ +function slashEntryOf(node: ChatConversationViewNode): SlashEntry | null { + const candidate = node as ChatNode + if (candidate.kind === 'user' || candidate.kind === 'steering') { + return { key: node.key, seq: node.anchorSeq, kind: 'message', name: null } + } + if (candidate.kind === 'context') { + const name = skillInvocationName(candidate.data.source) + return name === null ? null : { key: node.key, seq: node.anchorSeq, kind: 'skill', name } + } + return { key: node.key, seq: node.anchorSeq, kind: 'boundary', name: null } +} + +function sameSlashEntry(left: SlashEntry, right: SlashEntry): boolean { + return left.seq === right.seq && left.kind === right.kind && left.name === right.name +} + +/** + * Attaches each direct message's step-loaded skill names to its Node. + * + * A step's `skill-invocation` injections follow the direct messages the host + * scanned for `/name` gestures and precede the step's first Node of any other + * kind, so every non-message, non-context Node closes a batch. Every ended + * Turn publishes its `turn-tail` Node on `turn/end` whatever the reason, so a + * batch never spans Turns, and `step/start` precedes the direct message in + * the log, so no boundary separates a message from its injections. Names + * attach to every direct message of the batch: the bubble decorates only the + * tokens its own text carries. + * + * The index holds only messages, skill injections, and boundaries, ordered by + * `anchorSeq`. An apply re-reads just the batches around the Nodes whose + * classification changed and never scans the store, so an assistant + * streaming frame costs nothing here (the append hot path never scans the + * Chat Nodes). + */ +export class SkillNameProjector { + private readonly entries = new Map() + /** Every indexed entry in `anchorSeq` order. */ + private sorted: SlashEntry[] = [] + + /** + * Rebuild the index from a whole Node set and attach names to its messages. + * @param nodes - every materialized Chat Node, in any order. + * @returns the same Nodes, direct messages carrying their batch's names. + */ + replace(nodes: readonly ChatConversationViewNode[]): readonly ChatConversationViewNode[] { + this.entries.clear() + this.sorted = [] + for (const node of nodes) { + const entry = slashEntryOf(node) + if (entry === null) continue + this.entries.set(entry.key, entry) + this.sorted.push(entry) + } + this.sorted.sort((left, right) => left.seq - right.seq) + const names = new Map() + for (let index = 0; index < this.sorted.length; index++) { + if (this.sorted[index]?.kind === 'boundary') continue + const end = this.runEnd(index) + this.assignRun(index, end, names) + index = end + } + return nodes.map(node => withSkillNames(node, names.get(node.key) ?? EMPTY_KEYS)) + } + + /** + * Fold one incremental upsert set: re-read only the batches around the + * Nodes whose classification changed. + * @param upserts - the changed Nodes. + * @param store - the resident Nodes, read by key for the messages of an affected batch. + * @returns the upserts plus any resident message whose names changed. + */ + apply( + upserts: readonly ChatConversationViewNode[], + store: ChatNodeStore, + ): readonly ChatConversationViewNode[] { + const dirty: number[] = [] + for (const node of upserts) { + const next = slashEntryOf(node) + const previous = this.entries.get(node.key) + if (previous !== undefined) { + if (next !== null && sameSlashEntry(previous, next)) { + // A rebuilt message Node carries Definition state only, never the + // names a previous apply attached: re-read its batch. + if (next.kind === 'message') dirty.push(next.seq) + continue + } + this.remove(previous) + dirty.push(previous.seq) + } + if (next === null) continue + this.insert(next) + dirty.push(next.seq) + } + if (dirty.length === 0) return upserts + const names = new Map() + for (const seq of dirty) this.collectAround(seq, names) + const byKey = new Map(upserts.map(node => [node.key, node])) + for (const [key, list] of names) { + const node = byKey.get(key) ?? store.get(key) + if (node === undefined) continue + const next = withSkillNames(node, list) + if (next !== node || byKey.has(key)) byKey.set(key, next) + } + return [...byKey.values()] + } + + private insert(entry: SlashEntry): void { + this.sorted.splice(this.lowerBound(entry.seq), 0, entry) + this.entries.set(entry.key, entry) + } + + private remove(entry: SlashEntry): void { + this.sorted.splice(this.sorted.indexOf(entry), 1) + this.entries.delete(entry.key) + } + + /** First index whose seq is at least `seq`. */ + private lowerBound(seq: number): number { + let low = 0 + let high = this.sorted.length + while (low < high) { + const middle = (low + high) >>> 1 + if ((this.sorted[middle]?.seq ?? Number.POSITIVE_INFINITY) < seq) low = middle + 1 + else high = middle + } + return low + } + + /** Last index of the boundary-free run containing `index`. */ + private runEnd(index: number): number { + let end = index + while (end + 1 < this.sorted.length && this.sorted[end + 1]?.kind !== 'boundary') end++ + return end + } + + /** First index of the boundary-free run containing `index`. */ + private runStart(index: number): number { + let start = index + while (start - 1 >= 0 && this.sorted[start - 1]?.kind !== 'boundary') start-- + return start + } + + /** Record the names every message of the run `[start, end]` carries. */ + private assignRun(start: number, end: number, names: Map): void { + const list: string[] = [] + for (let index = start; index <= end; index++) { + const entry = this.sorted[index] + if (entry?.kind === 'skill' && entry.name !== null && !list.includes(entry.name)) list.push(entry.name) + } + for (let index = start; index <= end; index++) { + const entry = this.sorted[index] + if (entry?.kind === 'message') names.set(entry.key, list) + } + } + + /** + * Re-read the run(s) around one changed seq: the run holding a message or + * skill entry, or — for a boundary, or a seq that left the index — the runs + * on both sides of that position. + */ + private collectAround(seq: number, names: Map): void { + const at = this.lowerBound(seq) + const here = this.sorted[at] + if (here !== undefined && here.seq === seq && here.kind !== 'boundary') { + this.assignRun(this.runStart(at), this.runEnd(at), names) + return + } + if (at - 1 >= 0 && this.sorted[at - 1]?.kind !== 'boundary') { + this.assignRun(this.runStart(at - 1), at - 1, names) + } + const right = here !== undefined && here.seq === seq ? at + 1 : at + if (right < this.sorted.length && this.sorted[right]?.kind !== 'boundary') { + this.assignRun(right, this.runEnd(right), names) + } + } +} + interface LegacyContribution { readonly anchorSeq: number readonly nodes: readonly ConversationNode[] @@ -756,6 +963,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) this.locations.rebuild(this.order, this.store) @@ -785,7 +993,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder() let structural = false const contentOnly: ChatConversationViewNode[] = [] diff --git a/packages/client/ui-chat/src/client/conversation-nodes/command.ts b/packages/client/ui-chat/src/client/conversation-nodes/command.ts index b4723d78fa..e21f50c5f5 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/command.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/command.ts @@ -1,13 +1,12 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { CompactionCheckpointSource } from '@deepseek-ai/dsh-compaction/checkpoint' import type {} from '@deepseek-ai/dsh-compaction/types' import type {} from '@deepseek-ai/dsh-commands/types' import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type { ManualCompactionChatData } from '../contract/chat-nodes.ts' -import type { CommandNode, CompactionSummaryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts index 5874c7bd61..9612f08741 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/compaction.ts @@ -1,9 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-compaction/types' -import type { CompactionSummaryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' import { compactSource, compactSummary, updateCompactionState } from './command.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts b/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts index 0d909cdcdd..1695a467fe 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts @@ -88,6 +88,17 @@ export function sessionRecallLabels(source: unknown): string[] { return collect(record, 'references', 'label') } +/** + * Read the skill name a durable skill-invocation injection loaded. + * @param source - Logged `user/message` source. + * @returns The skill name, or null for every other source. + */ +export function skillInvocationName(source: unknown): string | null { + const record = asRecord(source) + if (record === null || readString(record, 'kind') !== 'skill-invocation') return null + return readString(record, 'name') +} + /** * Classify finalized Assistant content for Chat rendering. * @param content - Core content blocks. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts index adba1ef867..dbfb160b9d 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/fallback.ts @@ -1,7 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' -import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationNodeDefinition, UnknownSurfaceNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { UnknownSurfaceNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index 1927a468c5..3cd6538bd5 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -1,7 +1,8 @@ import type { Context } from '@deepseek-ai/cordis' -import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type { ContextMessageNode, SteeringMessageNode, UserMessageNode } from '../contract/snapshot.ts' import type { InboxState } from './inbox.ts' import { chatNode } from './common.ts' import { contextForm, contextProvenance } from './event-projection.ts' @@ -9,11 +10,15 @@ import { contextForm, contextProvenance } from './event-projection.ts' interface ReferencedUserMessageNode extends UserMessageNode { /** Labels cited by the immediately following session-reference context. */ readonly referenceLabels?: readonly string[] + /** Skill names the same step's `skill-invocation` injections loaded. */ + readonly skillNames?: readonly string[] } interface ReferencedSteeringMessageNode extends SteeringMessageNode { /** Labels cited by the immediately following session-reference context. */ readonly referenceLabels?: readonly string[] + /** Skill names the same step's `skill-invocation` injections loaded. */ + readonly skillNames?: readonly string[] } type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode diff --git a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts index 278927c6ae..ea73fae9f6 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/partial.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/partial.ts @@ -1,5 +1,7 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm/types' -import type { AssistantBlock, PartialAssistant } from '../contract/snapshot.ts' +import type { + AssistantBlock, PartialAssistant, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { emptyAssistantBlock, toAssistantBlock } from './event-projection.ts' /** diff --git a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts index 2cf518924b..e41c3ec334 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts @@ -78,7 +78,7 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver match.event.data.reason === 'initial', ), showsPrompt: previous === undefined - || match.event.data.reason !== 'change' + || match.event.data.reason === 'series' || match.event.data.startsSeries === true || change === 'system' || change === 'system-and-tools', @@ -89,8 +89,17 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver update: context => context.state, buildViewNode: (context) => { const state = context.state - if (state === undefined || !state.showsPrompt || state.prompt.system === '') return null - return chatNode(context, 'system-prompt', state.anchorSeq, { text: state.prompt.system }) + if (state === undefined) return null + const current = context.current.get('chat') as ChatNode | null | undefined + const visible = state.showsPrompt && state.prompt.system !== '' + if (!visible && current?.kind !== 'system-prompt') return null + return chatNode( + context, + 'system-prompt', + state.anchorSeq, + { text: state.prompt.system }, + { visibility: visible ? 'visible' : 'hidden' }, + ) }, } } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts index eeaf8d461b..d721821f22 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/retry.ts @@ -1,10 +1,9 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationLocation, ConversationMatch, ConversationNodeDefinition, + ConversationLocation, ConversationMatch, ConversationNodeDefinition, ModelRetryNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { RetryChatData } from '../contract/chat-nodes.ts' -import type { ModelRetryNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts index b1f675c4e4..acfcb4bca8 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -1,11 +1,11 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall, + ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type {} from '@deepseek-ai/dsh-tools/types' import type { ToolChatData } from '../contract/chat-nodes.ts' -import type { RunningToolCall, ToolCallBlock, ToolResultNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts index e3b41095ce..941f23ac43 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-error.ts @@ -1,8 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { TurnErrorNode } from '../contract/snapshot.ts' import { chatNode } from './common.ts' import { displayFailure } from './event-projection.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts index 31508ab77c..746d00bc95 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-max-tokens.ts @@ -1,8 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { TurnMaxTokensNode } from '../contract/snapshot.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { diff --git a/packages/client/ui-chat/src/client/model/conversation-context.ts b/packages/client/ui-chat/src/client/model/conversation-context.ts index 7f8116ffcc..442c455435 100644 --- a/packages/client/ui-chat/src/client/model/conversation-context.ts +++ b/packages/client/ui-chat/src/client/model/conversation-context.ts @@ -1,5 +1,6 @@ -import type { ConversationNode } from '../contract/snapshot.ts' -import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationNode, ConversationPromptSnapshot, +} from '@deepseek-ai/dsh-client-ui-conversation/client' /** Operation that started a new append-only model context. */ export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite' diff --git a/packages/client/ui-chat/src/client/model/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts index ce311f5291..878a78e63a 100644 --- a/packages/client/ui-chat/src/client/model/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -2,7 +2,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-tools/types' import type { ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode, -} from '../contract/snapshot.ts' +} from '@deepseek-ai/dsh-client-ui-conversation/client' interface ProjectedBlock { source: ToolCallBlock diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 7bcd9373b2..d9a77dd63b 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -48,10 +48,11 @@ interface MessageItemProps { readonly node: ConversationNode readonly t: ChatNodeViewProps['t'] readonly referenceLabels?: readonly string[] + readonly skillNames?: readonly string[] } /** Legacy-node fixture adapter for the independently registered renderers. */ -function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps) { +function MessageItem({ node, t: translate, referenceLabels, skillNames }: MessageItemProps) { const kind = node.kind === 'assistant' ? 'assistant-step' : node.kind const viewNode: ChatConversationViewNode = { key: `fixture:${node.kind}:${node.seq}`, @@ -63,8 +64,12 @@ function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps) visibility: 'visible', data: node.kind === 'model-retry' ? { attempts: [node], current: node } - : (node.kind === 'user' || node.kind === 'steering') && referenceLabels !== undefined - ? { ...node, referenceLabels } + : (node.kind === 'user' || node.kind === 'steering') && (referenceLabels !== undefined || skillNames !== undefined) + ? { + ...node, + ...(referenceLabels === undefined ? {} : { referenceLabels }), + ...(skillNames === undefined ? {} : { skillNames }), + } : node, } const props = { node: viewNode, t: translate, renderMessageImages, useChat: useDetachedChat } as ChatNodeViewProps @@ -141,6 +146,22 @@ describe('MessageItem arms', () => { expect(view.container.textContent).toContain('README.md, please.') }) + it('decorates a slash token as a skill chip only when the step resolved that skill', () => { + const message = { + kind: 'user' as const, + seq: 1, + time: 1_000, + content: [{ type: 'text', text: '/123 then /demo-skill go' }] as never, + source: null, + } + const plain = render() + expect(plain.container.querySelectorAll('[data-ref-chip]').length).toBe(0) + const resolved = render() + const chips = [...resolved.container.querySelectorAll('[data-ref-chip="skill"]')] + expect(chips.map(chip => chip.textContent)).toEqual(['/demo-skill']) + expect(resolved.container.textContent).toContain('/123 then ') + }) + 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', { @@ -1041,3 +1062,29 @@ describe('small branch tails', () => { expect(view.container.textContent).toBe('1 轮 · 1 步| 输入 0 tok · 输出 10 tok') }) }) + +describe('user file attachments', () => { + it('renders one card per durable file block with its name and compact size', () => { + const view = render( + , + ) + expect(view.getByTitle('notes.pdf').textContent).toContain('3.2MB') + expect(view.getByTitle('tiny.txt').textContent).toContain('12B') + expect(view.getByTitle('mid.csv').textContent).toContain('500KB') + expect(view.getByText('summarize these')).toBeTruthy() + }) +}) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 9e0818f37c..2c8fe220ea 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -379,9 +379,9 @@ function makeHarness( useInput: (() => { throw new Error('unused') }), inputActions: { setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }, useStore: bindSnapshotSelector(chat), @@ -1041,7 +1041,7 @@ describe('ChatView', () => { pendingSubmissions: [ { requestId: 'req-1' as never, placement: 'transcript', - time: 5_000, text: '即发即显', images: [], + time: 5_000, text: '即发即显', attachments: [], }, ], }, @@ -1081,7 +1081,9 @@ describe('ChatView', () => { placement: 'steering', time: 5_500, text: '带图纠偏', - images: [{ previewUrl: 'blob:steer-preview', name: 'steer.png' }], + attachments: [{ + type: 'image', value: { previewUrl: 'blob:steer-preview', name: 'steer.png' }, + }], }], }, ) @@ -1115,7 +1117,7 @@ describe('ChatView', () => { pendingSubmissions: [ { requestId: 'req-q' as never, placement: 'queued', - time: 6_000, text: '排队中', images: [], + time: 6_000, text: '排队中', attachments: [], }, ], }, @@ -1149,9 +1151,11 @@ describe('ChatView', () => { placement: 'transcript', time: 7_000, text: '', - images: [ - { previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, - { previewUrl: 'blob:echo-b' }, + attachments: [ + { + type: 'image', value: { previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, + }, + { type: 'image', value: { previewUrl: 'blob:echo-b' } }, ], }], }, @@ -1159,17 +1163,74 @@ describe('ChatView', () => { const baseRenderSlot = h.props.renderSlot const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never) - const images = (owner as { images: readonly unknown[] }).images - return
+ const { images, compact } = owner as { images: readonly unknown[]; compact?: boolean } + return ( +
+ ) }) as unknown as ChatViewSlotProps['renderSlot'] const view = render() - const gallery = view.getByTestId('echo-images') - expect(gallery.getAttribute('data-count')).toBe('2') - expect(JSON.parse(gallery.getAttribute('data-first') ?? '{}')).toEqual({ + const images = view.getAllByTestId('echo-image') + expect(images).toHaveLength(2) + expect(images.every(image => image.getAttribute('data-count') === '1')).toBe(true) + expect(images.every(image => image.getAttribute('data-compact') === 'true')).toBe(true) + expect(images[0]?.parentElement).toBe(images[1]?.parentElement) + expect(JSON.parse(images[0]?.getAttribute('data-first') ?? '{}')).toEqual({ preview: { url: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, }) }) + it('a mixed echo renders the Web file card between its selected images', () => { + const h = makeHarness( + { nodes: [] }, + { + pendingSubmissions: [{ + requestId: 'req-mixed' as never, + placement: 'transcript', + time: 7_500, + text: '', + attachments: [ + { type: 'image', value: { previewUrl: 'blob:first', name: 'first.png' } }, + { + type: 'file', + value: { attachmentId: 'file-1' as never, name: 'notes.txt', bytes: 23 }, + }, + { type: 'image', value: { previewUrl: 'blob:last', name: 'last.png' } }, + ], + }], + }, + ) + const baseRenderSlot = h.props.renderSlot + const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { + if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never) + const { images, compact } = owner as { + images: ReadonlyArray<{ preview?: { name?: string } }> + compact?: boolean + } + return ( +
+ ) + }) as unknown as ChatViewSlotProps['renderSlot'] + const view = render() + const first = view.getByTestId('images-first.png') + const file = view.getByTitle('notes.txt') + const last = view.getByTestId('images-last.png') + expect(first.getAttribute('data-compact')).toBe('true') + expect(last.getAttribute('data-compact')).toBe('true') + expect(first.parentElement).toBe(file.parentElement) + expect(file.parentElement).toBe(last.parentElement) + expect(first.compareDocumentPosition(file) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + expect(file.compareDocumentPosition(last) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + expect(view.getByText('TXT 23B')).toBeTruthy() + }) + it('animates only the latest unresolved model retry', () => { const retryNode = retry(2) const nextRetry = { ...retry(3), turn: 2, retry: 2 } diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index f401455a32..e5b69158b0 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -1402,7 +1402,7 @@ describe('built-in conversation node Definitions', () => { }) }) - it('materializes series starts and system changes but not same-series config or tool changes', () => { + it('materializes series starts and system changes but not unchanged resumes, config, or tool changes', () => { const tools = [{ name: 'read', description: 'Read', parameters: { type: 'object' } }] const expandedTools = [...tools, { name: 'write', description: 'Write', parameters: { type: 'object' } }] const value = assembler([ @@ -1458,7 +1458,6 @@ describe('built-in conversation node Definitions', () => { expect(prompts.map(prompt => ({ anchorSeq: prompt.anchorSeq, data: prompt.data }))).toEqual([ { anchorSeq: 1, data: { text: '# Initial' } }, { anchorSeq: 4, data: { text: '# Initial' } }, - { anchorSeq: 5, data: { text: '# Initial' } }, { anchorSeq: 6, data: { text: '# Updated' } }, ]) @@ -1480,18 +1479,20 @@ describe('built-in conversation node Definitions', () => { windowed.prepend([ at(5, 'request/header', { reason: 'initial', - header: { config: { provider: 'fake', model: 'fake' }, system: '# Original prompt' }, + header: { config: { provider: 'fake', model: 'fake' }, system: '# Resumed prompt' }, }), ], false) windowed.flush() const restored = snapshot(windowed) - const restoredPrompts = restored.order.flatMap((key) => { - const candidate = restored.nodes.get(key) - return candidate?.kind === 'system-prompt' ? [candidate] : [] - }) - expect(restoredPrompts.map(prompt => prompt.data)).toEqual([ - { text: '# Original prompt' }, - { text: '# Resumed prompt' }, + const restoredPrompts = restored.nodes.values() + .filter(candidate => candidate.kind === 'system-prompt') + expect(restoredPrompts.map(prompt => ({ + anchorSeq: prompt.anchorSeq, + visibility: prompt.visibility, + data: prompt.data, + })).sort((left, right) => left.anchorSeq - right.anchorSeq)).toEqual([ + { anchorSeq: 5, visibility: 'visible', data: { text: '# Resumed prompt' } }, + { anchorSeq: 10, visibility: 'hidden', data: { text: '# Resumed prompt' } }, ]) }) @@ -1766,6 +1767,58 @@ describe('built-in conversation node Definitions', () => { }) }) + it('associates a direct message with the skill invocations injected for its step', () => { + const skillInvocation = (id: string) => ({ + ...textMessage(id, 'instructions'), + source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' }, + }) + const instructions = (id: string) => ({ + ...textMessage(id, 'workspace rules'), + source: { kind: 'agent-instructions', changes: [{ path: 'AGENTS.md' }] }, + }) + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'user/message', textMessage('gesture', '/demo-skill go'), { surfaceOp: 'append' }), + at(3, 'step/start', { turn: 1, step: 1 }), + at(4, 'user/message', instructions('rules-1'), { surfaceOp: 'append' }), + at(5, 'user/message', skillInvocation('skill-body'), { surfaceOp: 'append' }), + at(6, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('answer-1', 'done'), + }, { surfaceOp: 'append' }), + at(7, 'step/end', { turn: 1, step: 1 }), + at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + at(9, 'turn/start', { turn: 2 }), + at(10, 'user/message', textMessage('later', '/demo-skill again?'), { surfaceOp: 'append' }), + at(11, 'step/start', { turn: 2, step: 1 }), + at(12, 'user/message', instructions('rules-2'), { surfaceOp: 'append' }), + ]) + + const users = [...snapshot(value).nodes.values()].filter(candidate => candidate.kind === 'user') + expect(users).toHaveLength(2) + expect(users[0]?.data).toMatchObject({ skillNames: ['demo-skill'] }) + expect(users[1]?.data).not.toHaveProperty('skillNames') + }) + + it('updates an already published direct node when its skill injection arrives', () => { + const value = assembler([ + at(1, 'user/message', textMessage('gesture', '/demo-skill go'), { surfaceOp: 'append' }), + ]) + const before = node(snapshot(value), 'user') + expect(before?.data).not.toHaveProperty('skillNames') + + value.append(at(2, 'user/message', { + ...textMessage('skill-body', 'instructions'), + source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' }, + }, { surfaceOp: 'append' })) + value.flush() + + const after = node(snapshot(value), 'user') + expect(after?.key).toBe(before?.key) + expect(after?.data).toMatchObject({ skillNames: ['demo-skill'] }) + }) + it('keeps replacement copies out of Chat business nodes', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index 57afd2abd8..9f7d3dd062 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -153,9 +153,9 @@ describe('render branch tails', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useStore={bindSnapshotSelector(chat)} @@ -215,9 +215,9 @@ describe('render branch tails', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useStore={bindSnapshotSelector(chat)} diff --git a/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts b/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts new file mode 100644 index 0000000000..6e1f375adb --- /dev/null +++ b/packages/client/ui-chat/tests/skill-name-projector.client.spec.ts @@ -0,0 +1,101 @@ +/** + * SkillNameProjector: the step's `skill-invocation` injections attach to the + * direct messages of the same batch, incrementally — an assistant-only apply + * must neither scan the store nor re-emit message Nodes. + */ +import { describe, expect, it } from 'vitest' +import type { ChatConversationViewNode } from '../src/client/contract/chat-nodes.ts' +import type { ChatNodeStore } from '../src/client/contract/snapshot.ts' +import { SkillNameProjector } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' + +function viewNode(kind: string, seq: number, data: Record): ChatConversationViewNode { + return { + key: `${kind}:${seq}`, + kind, + id: String(seq), + target: 'chat', + anchorSeq: seq, + location: { kind: 'session' }, + visibility: 'visible', + data: { kind, seq, time: seq, ...data }, + } as unknown as ChatConversationViewNode +} +const user = (seq: number, text: string) => + viewNode('user', seq, { content: [{ type: 'text', text }], source: { kind: 'user' } }) +const skill = (seq: number, name: string) => + viewNode('context', seq, { content: [], source: { kind: 'skill-invocation', name, form: 'instructions' } }) +const instructions = (seq: number) => + viewNode('context', seq, { content: [], source: { kind: 'agent-instructions', changes: [] } }) +const assistant = (seq: number, text = 'answer') => viewNode('assistant-step', seq, { text }) + +function storeOf(nodes: readonly ChatConversationViewNode[]): ChatNodeStore & { valuesCalls: number } { + const byKey = new Map(nodes.map(node => [node.key, node])) + const store = { + valuesCalls: 0, + get: (key: string) => byKey.get(key), + values: () => { + store.valuesCalls += 1 + return [...byKey.values()] + }, + source: () => { throw new Error('unused') }, + processSource: () => { throw new Error('unused') }, + } + return store +} + +const names = (node: ChatConversationViewNode | undefined) => + (node?.data as { skillNames?: readonly string[] } | undefined)?.skillNames + +describe('SkillNameProjector', () => { + it('attaches a batch\'s injected skill names on replace and leaves other Nodes untouched', () => { + const projector = new SkillNameProjector() + const answer = assistant(6) + const out = projector.replace([user(2, '/demo go'), instructions(4), skill(5, 'demo'), answer, user(10, '/demo later?')]) + expect(names(out[0])).toEqual(['demo']) + expect(names(out[4])).toBeUndefined() + expect(out[3]).toBe(answer) + }) + + it('an assistant-only apply neither scans the store nor re-emits message Nodes', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), skill(5, 'demo'), assistant(6)]) + const store = storeOf(replaced) + const frame = assistant(6, 'answer grows') + const out = projector.apply([frame], store) + expect(out).toEqual([frame]) + expect(store.valuesCalls).toBe(0) + }) + + it('a late skill injection updates only the direct messages of its batch', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), assistant(6), user(10, 'unrelated')]) + const store = storeOf(replaced) + const injection = skill(3, 'demo') + const out = projector.apply([injection], store) + expect(out.map(node => node.key).sort()).toEqual(['context:3', 'user:2']) + expect(names(out.find(node => node.key === 'user:2'))).toEqual(['demo']) + }) + + it('re-decorates a direct message the assembler re-emits without names', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), skill(5, 'demo'), assistant(6)]) + expect(names(replaced[0])).toEqual(['demo']) + const store = storeOf(replaced) + // A rebuilt message Node carries Definition state only: no skillNames. + const rebuilt = user(2, '/demo go') + const out = projector.apply([rebuilt], store) + expect(out.map(node => node.key)).toEqual(['user:2']) + expect(names(out[0])).toEqual(['demo']) + expect(store.valuesCalls).toBe(0) + }) + + it('a boundary arriving inside a batch splits it and drops the names past it', () => { + const projector = new SkillNameProjector() + const replaced = projector.replace([user(2, '/demo go'), skill(4, 'demo'), user(8, '/demo again')]) + expect(names(replaced[2])).toEqual(['demo']) + const store = storeOf(replaced) + const out = projector.apply([assistant(6)], store) + expect(out.map(node => node.key).sort()).toEqual(['assistant-step:6', 'user:8']) + expect(names(out.find(node => node.key === 'user:8'))).toBeUndefined() + }) +}) diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index be9c1f8b43..e87a9b0483 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/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-commands/README.md -README.md: b27c795eaf9a7954925961bde81872307b9f3d97 -README.zh.md: 65d31d0f390fce0ea341bf3391903539d3e0d0f7 +README.md: 045364d43e27d8be07ce5847af5d02e461d10aa6 +README.zh.md: aa9c5d8b0729202098f9323892da710f54d0f9a9 diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index b27c795eaf..045364d43e 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -31,9 +31,9 @@ Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` so A contribution is a client-owned command — a host-name collision fails loud. A decoration adds a bare-invocation popup to an EXISTING host command: the host command keeps its catalog row, its argument claim, and its lifecycle logging, and a decorated name with no host row in the session's directory never fires. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names; prefixes rank first. -### Image-carrying submissions +### Attachment-carrying submissions -When the composer submits with image attachments, only a host command declaring `input.images` proceeds; every other command route throws the localized `imagesUnsupported` refusal, which renders as a transient toast while the draft and images stay in place — a command can never consume the text and strand the images. +When the composer submits with images or generic files, only a host command declaring `input.attachments` proceeds. Every other command route throws the localized `attachmentsUnsupported` refusal, rendered as a transient toast while the draft and attachment cards stay in place. Handler errors preserve the same draft state for retry. ----- diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index 65d31d0f39..aa9c5d8b07 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -31,9 +31,9 @@ kind: "package-reference" 贡献项是客户端自有命令——与宿主命令同名会明确报错。装饰为**已存在的**宿主命令添加裸调用弹窗:宿主命令保留其目录行、参数声明与生命周期记账,被装饰的名字在会话目录中无宿主行时永不触发。菜单查询按顺序且不区分大小写地模糊匹配命令名的子序列;前缀排名最高。 -### 带图提交 +### 带附件提交 -composer 携带图片附件提交时,只有声明了 `input.images` 的宿主命令继续;其余每条命令路径都会抛出本地化的 `imagesUnsupported` 拒绝,以瞬态 toast 呈现,草稿与图片保持原位——命令绝不消费文本却抛下图片。 +composer 携带图片或通用文件提交时,只有声明了 `input.attachments` 的宿主命令继续。其余命令路径都会抛出本地化的 `attachmentsUnsupported` 拒绝,以瞬态 toast 呈现,草稿与附件卡保持原位。处理器返回错误时保留相同草稿状态供用户重试。 ----- diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index 10e327f192..2cf32925cb 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-commands/src/client/locales.ts b/packages/client/ui-commands/src/client/locales.ts index 1d04fe7eb7..384597a9b2 100644 --- a/packages/client/ui-commands/src/client/locales.ts +++ b/packages/client/ui-commands/src/client/locales.ts @@ -9,7 +9,7 @@ export const zh = { 'status.empty': '无选项', 'overlay.aria': '/{command} 选项', 'listbox.aria': '/{command} 匹配项', - 'notice.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片', + 'notice.attachmentsUnsupported': '/{command} 不接受附件,请先移除附件', } satisfies Record /** The command namespace key union. */ @@ -24,5 +24,5 @@ export const en = { 'status.empty': 'No options', 'overlay.aria': '/{command} options', 'listbox.aria': '/{command} matches', - 'notice.imagesUnsupported': '/{command} does not accept image attachments; remove them first', + 'notice.attachmentsUnsupported': '/{command} does not accept attachments; remove them first', } satisfies Record diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index 4a606fe607..ae7d350e46 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -2,8 +2,9 @@ * CommandUiRuntime (`ctx.commandUi`): the '/' command source over the * session-keyed directory, the client-contribution registry, and the * per-session popupSelect controllers. Candidate synthesis merges the host - * catalog with contributions by availability, then fuzzy query/position - * filtering; a host/contribution name collision fails loud. Every execute + * catalog with contributions by availability, then position filtering and + * the `/` menu's shared name ranking (ui-primitives `rankByName`); a + * host/contribution name collision fails loud. Every execute * addresses the session's agent by sessionId — sessions are always * agent-backed. */ @@ -17,9 +18,10 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' +import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick, - SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, + SubmitAttachment, SubmitEnvelope, SubmitOutcome, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, CommandUiContract } from './contract.ts' import type { CommandDescriptor } from './directory.ts' @@ -56,69 +58,6 @@ interface LiveState { readonly popups: Map> } -/** One fuzzy match with its stable source position. */ -interface RankedCandidate { - readonly candidate: InputTriggerCandidate - readonly index: number - readonly prefix: boolean - readonly score: number -} - -/** Extra weight for command-name starts and separator boundaries. */ -function boundaryBonus(name: string, index: number): number { - return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0 -} - -/** - * Score the strongest ordered-subsequence alignment in O(name × query). - * Boundary and adjacent matches earn weight; skipped and leading characters - * cost weight. - */ -function fuzzyScore(name: string, query: string): number | undefined { - if (query === '') return 0 - if (query.length > name.length) return undefined - const noMatch = Number.NEGATIVE_INFINITY - let previous = Array(name.length).fill(noMatch) - for (let index = 0; index < name.length; index++) { - if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index - } - for (let queryIndex = 1; queryIndex < query.length; queryIndex++) { - const current = Array(name.length).fill(noMatch) - let bestGapped = noMatch - for (let index = 0; index < name.length; index++) { - const gappedIndex = index - 2 - if (gappedIndex >= 0) { - const prior = previous[gappedIndex] ?? noMatch - if (prior !== noMatch) bestGapped = Math.max(bestGapped, prior + gappedIndex) - } - if (name.charAt(index) !== query.charAt(queryIndex)) continue - const bonus = 1 + boundaryBonus(name, index) - const adjacent = index > 0 ? previous[index - 1] ?? noMatch : noMatch - if (adjacent !== noMatch) current[index] = adjacent + bonus + 4 - if (bestGapped !== noMatch) current[index] = Math.max(current[index] ?? noMatch, bestGapped + bonus + 1 - index) - } - previous = current - } - let best = noMatch - for (const score of previous) best = Math.max(best, score) - return best === noMatch ? undefined : best -} - -/** Case-insensitive fuzzy filtering with stable ordering for equal matches. */ -function fuzzyCandidates(candidates: readonly InputTriggerCandidate[], rawQuery: string): readonly InputTriggerCandidate[] { - const query = rawQuery.toLowerCase() - if (query === '') return candidates - const ranked: RankedCandidate[] = [] - candidates.forEach((candidate, index) => { - const name = candidate.name.toLowerCase() - const score = fuzzyScore(name, query) - if (score !== undefined) ranked.push({ candidate, index, prefix: name.startsWith(query), score }) - }) - ranked.sort((left, right) => - Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index) - return ranked.map(match => match.candidate) -} - /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */ export class CommandUiRuntime extends Service implements CommandUiContract { static inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands'] @@ -246,7 +185,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { } } - /** Menu candidates: host catalog + contribution availability, then position filtering and fuzzy name ranking. */ + /** Menu candidates: host catalog + contribution availability, then position filtering and the shared name ranking. */ private async candidates(session: ClientSessionContext, req: CandidateRequest): Promise { const list = await this.directory.ensureReady(session.sessionId, req.signal) const rows: InputTriggerCandidate[] = [] @@ -262,7 +201,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { } rows.push({ name: contribution.name, description: contribution.description }) } - return fuzzyCandidates( + return rankByName( rows.filter(c => req.position === 'leading' || c.hint === undefined), req.query, ) @@ -310,10 +249,10 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * bare host commands act on the bare token only; leadingInput claims * args-tolerant. * - * Envelope policy: an enter submission carrying images resolves only - * through a command declaring image acceptance. Every other command route — + * Envelope policy: an enter submission carrying attachments resolves only + * through a command declaring attachment acceptance. Every other command route — * popup, non-accepting claim, bare detached execute — throws the refusal - * so the machine surfaces one composer notice and the draft and images + * so the machine surfaces one composer notice and the draft and attachments * stay in place; nothing executes and nothing is dropped. */ private async matchEnter( @@ -329,13 +268,13 @@ export class CommandUiRuntime extends Service implements CommandUiContract { const bare = ws === -1 const name = token.slice(1) if (name === '') return undefined - const refuseImages = (): never => { - throw new Error(this.t('notice.imagesUnsupported', { command: name })) + const refuseAttachments = (): never => { + throw new Error(this.t('notice.attachmentsUnsupported', { command: name })) } const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(session)) { if (!bare) return undefined - if (envelope.images > 0) refuseImages() + if (envelope.attachments > 0) refuseAttachments() this.openPopup(name, contribution.ui, session, { via: 'enter', token }) return 'handled' } @@ -347,17 +286,17 @@ export class CommandUiRuntime extends Service implements CommandUiContract { if (bare) { const decoration = this.live.decorations.get(name) if (decoration !== undefined && decoration.available(session)) { - if (envelope.images > 0) refuseImages() + if (envelope.attachments > 0) refuseAttachments() this.openPopup(name, decoration.ui, session, { via: 'enter', token }) return 'handled' } } if (desc.input !== undefined) { - if (envelope.images > 0 && desc.input.images !== true) refuseImages() + if (envelope.attachments > 0 && desc.input.attachments !== true) refuseAttachments() return { claim: this.leadingClaim(desc, session) } } if (!bare) return undefined - if (envelope.images > 0) refuseImages() + if (envelope.attachments > 0) refuseAttachments() this.consumeVia(session.sessionId, { via: 'enter', token }) this.runDetached(desc, session, trimmed) return 'handled' @@ -381,8 +320,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract { return { token, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), - ...(desc.input?.images === true ? { images: true } : {}), - submit: (args, _actx, images) => this.execute(session, token + args, images), + ...(desc.input?.attachments === true ? { attachments: true } : {}), + submit: (args, _actx, attachments) => this.execute(session, token + args, attachments), } } @@ -394,21 +333,21 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * executor durably logged the lifecycle (`command/run`/`command/done`) and * the outcome renders as a persistent flow node — the composer never * echoes it. A handler error result reports an error outcome so the - * composer keeps the submission (draft and images) for correction. + * composer keeps the draft and attachments for correction. * A refused call throws. */ private async execute( session: ClientSessionContext, line: string, - images: readonly SubmitImageAttachment[] = [], + attachments: readonly SubmitAttachment[] = [], ): Promise { - const result = await this.ctx.remote.commands.execute(session.sessionId, line, images) + const result = await this.ctx.remote.commands.execute(session.sessionId, line, attachments) if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`) if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` } this.notifyExecuted(session.sessionId, submittedCommandName(line), result.value.result) - // An image-carrying submission consumed its images only on handler - // success; an error outcome keeps draft and images in the composer. - if (images.length > 0 && result.value.result.kind === 'error') { + // A submission consumes its attachments only after handler success; an + // error outcome keeps the draft and attachments in the composer. + if (attachments.length > 0 && result.value.result.kind === 'error') { return { kind: 'error', text: result.value.result.text } } return { kind: 'success' } diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index ae8ef7a884..23ce7ae505 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -13,7 +13,7 @@ import type { CommandResult } from '@deepseek-ai/dsh-commands/types' import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitImageAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' import { CommandUiRuntime } from '../src/client/service.ts' @@ -64,7 +64,7 @@ async function bench(opts: BenchOptions = {}) { const ctx = new Context() const registered = new Map() const listCalls: Array<{ sessionId: SessionId }> = [] - const executeCalls: Array<{ sessionId: SessionId; line: string; images: readonly SubmitImageAttachment[] }> = [] + const executeCalls: Array<{ sessionId: SessionId; line: string; images: readonly SubmitAttachment[] }> = [] // The service reads the generated commands Remote, which delivers the // carrier's outcome, so a programmed failure answers the error branch. const commandsRemote = { @@ -77,7 +77,7 @@ async function bench(opts: BenchOptions = {}) { return value.commands }) }, - execute: async (sessionId: SessionId, line: string, images: readonly SubmitImageAttachment[] = []) => { + execute: async (sessionId: SessionId, line: string, images: readonly SubmitAttachment[] = []) => { executeCalls.push({ sessionId, line, images }) return await carried(async () => { const fallback = (): Promise => Promise.resolve({ matched: true }) @@ -209,24 +209,15 @@ describe('candidates', () => { expect(list).toEqual([{ name: 'goal', description: 'leadingInput kind', hint: 'goal text' }]) }) - it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', async () => { + it('ranks rows through the shared name ranker: prefixes first, then alignment, then source order', async () => { const commands: CommandDescriptor[] = [ - { name: 'q-xylophone', description: '' }, - { name: 'qx-long', description: '' }, - { name: 'fabulous', description: '' }, - { name: 'foo-bar', description: '' }, - { name: 'zuv', description: '' }, - { name: 'zu1v', description: '' }, - { name: 'yu1v', description: '' }, - { name: 'zu12v', description: '' }, + { name: 'z_a_b', description: '' }, + { name: 'abc', description: '' }, ] const { source } = await bench({ commands: () => Promise.resolve({ commands }) }) const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name) - await expect(names('QX')).resolves.toEqual(['qx-long', 'q-xylophone']) - await expect(names('fb')).resolves.toEqual(['foo-bar', 'fabulous']) - await expect(names('uv')).resolves.toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v']) + await expect(names('AB')).resolves.toEqual(['abc', 'z_a_b']) await expect(names('zzz')).resolves.toEqual([]) - await expect(names('query-longer-than-every-name')).resolves.toEqual([]) }) it('catalogs are per session: another session pulls its own key', async () => { @@ -288,9 +279,9 @@ describe('decorations (bare-invocation UI on host commands)', () => { command.decorate(goalDecoration()) const scope = mint('s1') await warm(proj('s1')) - expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal, { images: 0 })).toBe('handled') + expect(await source.matchEnter!(proj('s1'), '/goal', new AbortController().signal, { attachments: 0 })).toBe('handled') expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' }) - const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal, { images: 0 }) + const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal, { attachments: 0 }) if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim') expect(argued.claim.token).toBe('/goal ') }) @@ -309,7 +300,7 @@ describe('decorations (bare-invocation UI on host commands)', () => { command.decorate(goalDecoration({ name: 'phantom' })) const scope = mint('s1') await warm(proj('s1')) - expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal, { images: 0 })).toBeUndefined() + expect(await source.matchEnter!(proj('s1'), '/phantom', new AbortController().signal, { attachments: 0 })).toBeUndefined() expect(menuPick(source, 'phantom', proj('s1'))).toBeUndefined() expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false) }) @@ -318,7 +309,7 @@ describe('decorations (bare-invocation UI on host commands)', () => { const { command, source, warm, executeCalls } = await bench() command.decorate(goalDecoration({ name: 'plan', available: () => false })) await warm(proj('s1')) - expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 })).toBe('handled') + expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { attachments: 0 })).toBe('handled') expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }]) }) @@ -431,7 +422,7 @@ describe('matchEnter (enter column)', () => { const { source } = await bench({ commands: () => new Promise((resolve) => { release = resolve }), }) - const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { images: 0 }) + const wait = source.matchEnter!(proj('s1'), '/goal args', signal(), { attachments: 0 }) release({ commands: S1_CMDS }) const outcome = await wait if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') @@ -442,14 +433,14 @@ describe('matchEnter (enter column)', () => { const { source } = await bench({ commands: () => Promise.reject(new Error('warmup boom')), }) - await expect(source.matchEnter!(proj('s1'), '/goal', signal(), { images: 0 })).rejects.toThrow('warmup boom') + await expect(source.matchEnter!(proj('s1'), '/goal', signal(), { attachments: 0 })).rejects.toThrow('warmup boom') }) it('leadingInput claims args-tolerant (bare and with trailing text)', async () => { const { source, warm } = await bench() await warm(proj('s1')) for (const line of ['/goal', '/goal refactor the loop']) { - const outcome = await source.matchEnter!(proj('s1'), line, signal(), { images: 0 }) + const outcome = await source.matchEnter!(proj('s1'), line, signal(), { attachments: 0 }) if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') expect(outcome.claim.token).toBe('/goal ') } @@ -464,7 +455,7 @@ describe('matchEnter (enter column)', () => { return true }) await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 0 })).resolves.toBe('handled') + await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { attachments: 0 })).resolves.toBe('handled') expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }]) await Promise.resolve() expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }]) @@ -473,7 +464,7 @@ describe('matchEnter (enter column)', () => { it('bare kind with trailing text → undefined and no RPC (default sink owns the line)', async () => { const { source, warm, executeCalls } = await bench() await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/plan now', signal(), { images: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/plan now', signal(), { attachments: 0 })).resolves.toBeUndefined() expect(executeCalls).toEqual([]) }) @@ -481,18 +472,18 @@ describe('matchEnter (enter column)', () => { const { command, source, mint, listCalls } = await bench() command.register(themeContribution()) const scope = mint('s1') - await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 0 })).resolves.toBe('handled') + await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { attachments: 0 })).resolves.toBe('handled') expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(true) expect(listCalls).toEqual([]) // contribution short-circuits ahead of ensureReady - await expect(source.matchEnter!(proj('s1'), '/theme dark', signal(), { images: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/theme dark', signal(), { attachments: 0 })).resolves.toBeUndefined() }) it('unknown name, bare "/", and non-slash lines → undefined', async () => { const { source, warm } = await bench() await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/nope', signal(), { images: 0 })).resolves.toBeUndefined() - await expect(source.matchEnter!(proj('s1'), '/', signal(), { images: 0 })).resolves.toBeUndefined() - await expect(source.matchEnter!(proj('s1'), 'plain text', signal(), { images: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/nope', signal(), { attachments: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), '/', signal(), { attachments: 0 })).resolves.toBeUndefined() + await expect(source.matchEnter!(proj('s1'), 'plain text', signal(), { attachments: 0 })).resolves.toBeUndefined() }) }) @@ -500,19 +491,19 @@ describe('matchEnter envelope policy (images)', () => { const signal = () => new AbortController().signal const IMG_CMDS: CommandDescriptor[] = [ ...S1_CMDS, - { name: 'vision', description: 'image-accepting leadingInput', input: { hint: 'describe', images: true } }, + { name: 'vision', description: 'image-accepting leadingInput', input: { hint: 'describe', attachments: true } }, ] - const png: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } + const png: SubmitAttachment = { type: 'image', mediaType: 'image/png', data: 'AA==' } it('a leadingInput command not declaring acceptance refuses; a declaring one claims with images minted', async () => { const { source, warm } = await bench({ commands: () => Promise.resolve({ commands: IMG_CMDS }) }) await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/goal ship', signal(), { images: 1 })) - .rejects.toThrow('command:notice.imagesUnsupported{"command":"goal"}') - const outcome = await source.matchEnter!(proj('s1'), '/vision what is this', signal(), { images: 1 }) + await expect(source.matchEnter!(proj('s1'), '/goal ship', signal(), { attachments: 1 })) + .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"goal"}') + const outcome = await source.matchEnter!(proj('s1'), '/vision what is this', signal(), { attachments: 1 }) if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') expect(outcome.claim.token).toBe('/vision ') - expect(outcome.claim.images).toBe(true) + expect(outcome.claim.attachments).toBe(true) }) it('bare popup routes refuse images: contribution and decorated host both stay closed', async () => { @@ -521,18 +512,18 @@ describe('matchEnter envelope policy (images)', () => { command.decorate({ name: 'plan', available: () => true, ui: themeUi() }) const scope = mint('s1') await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { images: 1 })) - .rejects.toThrow('command:notice.imagesUnsupported{"command":"theme"}') - await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 2 })) - .rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}') + await expect(source.matchEnter!(proj('s1'), '/theme', signal(), { attachments: 1 })) + .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"theme"}') + await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { attachments: 2 })) + .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"plan"}') expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false) }) it('bare host detached execute refuses images before any RPC', async () => { const { source, warm, executeCalls } = await bench() await warm(proj('s1')) - await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { images: 1 })) - .rejects.toThrow('command:notice.imagesUnsupported{"command":"plan"}') + await expect(source.matchEnter!(proj('s1'), '/plan', signal(), { attachments: 1 })) + .rejects.toThrow('command:notice.attachmentsUnsupported{"command":"plan"}') expect(executeCalls).toEqual([]) }) @@ -543,7 +534,7 @@ describe('matchEnter envelope policy (images)', () => { execute: () => Promise.resolve({ matched: true, result }), }) await warm(proj('s1')) - const outcome = await source.matchEnter!(proj('s1'), '/vision x', signal(), { images: 1 }) + const outcome = await source.matchEnter!(proj('s1'), '/vision x', signal(), { attachments: 1 }) if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') // Handler error: the error outcome keeps draft and images in the composer. await expect(outcome.claim.submit('x', new Context(), [png])) @@ -643,7 +634,7 @@ describe('detached admission notices', () => { // Admission miss (matched:false): immediate composer feedback stays. mode = 'miss' - await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { images: 0 }) + await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { attachments: 0 }) await flush() expect(notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'unknown or malformed command: /plan' }]) @@ -722,7 +713,7 @@ describe('popupFor', () => { consumes.push(r) return true }) - await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 }) + await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { attachments: 0 }) const popup = command.popupFor(scope.ctx) await Promise.resolve() await popup.select(0) @@ -733,7 +724,7 @@ describe('popupFor', () => { const { command, source, mint } = await bench() command.register(themeContribution()) const scope = mint('s1') - await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { images: 0 }) + await source.matchEnter!(proj('s1'), '/theme', new AbortController().signal, { attachments: 0 }) const popup = command.popupFor(scope.ctx) expect(popup.state.getSnapshot().open).toBe(true) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 0471a9e271..16c22d55b6 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: dabe5afda6554a62f5ca038952e5a02e5a2f712e -README.zh.md: c38ea211c5d8d0ea384cfa39eba2608571166c89 +README.md: 9b81858adee21d3b8d8a158fe5ffa239b6d4ffe3 +README.zh.md: 40c02c469032e98191cbaa2a0203cc1f5a5a889e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index dabe5afda6..9b81858ade 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -42,11 +42,11 @@ View selection is deterministic: a registered persisted selection wins, otherwis The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated. -The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local image previews or durable image parts as thumbnails, while an edit exposes the literal sent text. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. +The resident composer survives no-Session and Session transitions. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. -Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint and encodes images through the browser's native `FileReader` data-URL path. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their image ids through admission and Session scope disposal. When an echo retires as observed, the durable image cache exposes its preview immediately, fetches the admitted attachment, replaces the preview with the canonical URL, and revokes each URL after its use ends. Direct subagent continuations skip local echoes because their transport does not preserve the browser request id. +Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. -While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Continuable subagents keep separate Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). +While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Plan mode and active goals do not change attachment intake. Continuable subagents keep separate Send and Stop actions but expose no paperclip, paste, or drop intake ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). ## Temporary composer entries diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c38ea211c5..40c02c4690 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -42,11 +42,11 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并把本地图片预览或持久化图片部分显示为缩略图,编辑态则展示字面发送文本。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 -默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`);Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有图片 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开预览 URL,同时读取 admitted 附件,随后用规范化 URL 替换预览,并在两个 URL 各自停止使用后撤销。直接 subagent continuation 不创建本地回显,因为其 transport 不保留浏览器 request id。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript,繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。prompt 复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation service 在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 -普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。可继续 subagent 保留独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 +普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。Plan Mode 与 active goal 不改变附件入口。continuable 子代理保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 ## 临时 composer entry diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 89b8706b37..b8f8b78f78 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, @@ -29,6 +29,7 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-client-file-upload", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer", @@ -64,6 +65,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 4c8527c1ee..9cc8f95758 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,5 +1,6 @@ /** Registers the target-neutral Conversation assembly, shell, input, and docks. */ import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -13,7 +14,7 @@ import { UiConversation } from './conversation/assembly.ts' import type { ViewTab } from './contract/views.ts' import type { ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected, - ConversationSessionInjected, + ConversationSessionInjected, DraftFileUploads, } from './contract/slots.ts' import type { InputNotice } from './contract/input.ts' import { createConversationStore, readConversationViewPreference } from './stores.ts' @@ -43,9 +44,20 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Services required by the Conversation plugin. */ export const inject = [ - 'slots', 'sessions', 'uiSession', 'uiWorkspace', 'locale', 'settingsScope', + 'slots', 'sessions', 'fileUpload', 'uiSession', 'uiWorkspace', 'locale', 'settingsScope', ] +/** Conversation runtime configuration. */ +export interface Config { + /** Maximum generic-file uploads allowed to run concurrently in browser Workers. */ + maxConcurrentFileUploads?: number +} + +/** Validated Conversation runtime configuration. */ +export const Config: z = z.object({ + maxConcurrentFileUploads: z.natural().min(1).default(2), +}) + // Stable no-session sources keep the renderer's observable-hook cache and // hook order unchanged across current-Session transitions. const ABSENT_NOTICES = { @@ -65,6 +77,11 @@ const ABSENT_MENU_LAUNCHER = { getSnapshot: (): string | null => null, subscribe: () => () => {}, } +const EMPTY_FILE_UPLOADS: DraftFileUploads = {} +const ABSENT_FILE_UPLOADS = { + getSnapshot: () => EMPTY_FILE_UPLOADS, + subscribe: () => () => {}, +} interface WorkspaceNavigation { connectWorkspace( @@ -94,9 +111,11 @@ function concreteConversation(ctx: Context): ConversationController { * Mount the Conversation core and target-neutral presentation. * @param ctx - Client root context. */ -export function apply(ctx: Context): void { +export function apply(ctx: Context, config: Config = Config({})): void { const sessions = ctx.sessions const slots = ctx.slots + // Schemastery's field default is materialized before Cordis calls apply. + const maxConcurrentFileUploads = config.maxConcurrentFileUploads as number const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation const uiConversation = new UiConversation(ctx, sessions) @@ -216,15 +235,19 @@ export function apply(ctx: Context): void { if (sessionId !== undefined && nextId !== sessionId) { const from = inputHub.shell(sessionId) const draft = from.snapshot.draft - const imageIds = from.snapshot.imageIds + const attachmentIds = from.snapshot.attachmentIds const next = inputHub.shell(nextId) - if (imageIds.length === 0 || next.addImages(imageIds)) { + if (attachmentIds.length === 0 || next.addAttachments(attachmentIds)) { + if (sessions.binding(nextId) === undefined) { + throw new Error(`ui-conversation: session "${nextId}" resolved no binding`) + } + concreteConversation(ctx).rebindDraftFiles(nextId, attachmentIds) if (draft !== '') { next.setDraft(draft) from.setDraft('') } - if (imageIds.length > 0) { - for (const id of imageIds) from.removeImage(id) + if (attachmentIds.length > 0) { + for (const id of attachmentIds) from.removeAttachment(id) } } } @@ -284,15 +307,17 @@ export function apply(ctx: Context): void { if (sessionId === undefined) { return { keyboard: undefined, - addImages: undefined, - removeImage: undefined, - draftImages: undefined, + addFiles: undefined, + removeAttachment: undefined, + resolveDraftAttachments: undefined, + retryFileUpload: undefined, resolveSubmitMode: (running, gesture, steeringAvailable) => submissionPolicy.resolve(running, gesture, steeringAvailable), toggleCommandMenu: undefined, stop: undefined, command: undefined, hooks: { + fileUploads: ABSENT_FILE_UPLOADS, notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER, @@ -304,11 +329,12 @@ export function apply(ctx: Context): void { const inputTriggers = inputHub.inputTriggers(sessionId) return { keyboard: shell, - addImages: (files) => { + addFiles: (files) => { + if (sessions.binding(sessionId) === undefined) return t('file.sessionUnavailable') try { - const images = conversation.createDraftImages(files) - if (!shell.addImages(images.map(image => image.id))) { - conversation.releaseDraftImages(images) + const drafts = conversation.createDrafts(sessionId, files) + if (!shell.addAttachments(drafts.map(draft => draft.id))) { + conversation.releaseDraftAttachments(drafts) } return null } catch (error: unknown) { @@ -316,11 +342,13 @@ export function apply(ctx: Context): void { return error instanceof Error ? error.message : String(error) } }, - removeImage: (id) => { - conversation.releaseDraftImage(id) - shell.removeImage(id) + removeAttachment: (id) => { + if (shell.removeAttachment(id)) conversation.releaseDraftAttachment(id) + }, + resolveDraftAttachments: ids => conversation.resolveDraftAttachments(ids), + retryFileUpload: (id) => { + if (sessions.binding(sessionId) !== undefined) conversation.retryFileUpload(sessionId, id) }, - draftImages: ids => conversation.draftImages(ids), resolveSubmitMode: (running, gesture, steeringAvailable) => submissionPolicy.resolve(running, gesture, steeringAvailable), toggleCommandMenu: inputTriggers === undefined @@ -348,6 +376,7 @@ export function apply(ctx: Context): void { return result.ok && result.value.matched }, hooks: { + fileUploads: conversation.fileUploads, notices: shell.notices, lexicon: shell.lexicon, menuLauncher: inputTriggers?.launcher ?? ABSENT_MENU_LAUNCHER, @@ -363,7 +392,11 @@ export function apply(ctx: Context): void { yield registerComposerBar() }) - ctx.plugin(ConversationController, { input: inputHub, blocks: composerBlocks }) + ctx.plugin(ConversationController, { + input: inputHub, + blocks: composerBlocks, + maxConcurrentFileUploads, + }) ctx.plugin(todoDockEntry) ctx.plugin(queueDockEntry) } diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index d6a0741200..9d65d84f1f 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -20,11 +20,19 @@ export interface TokenSpan { readonly draftRev: number } -/** Base64 image payload passed to a claimed command submission. */ -export interface SubmitImageAttachment { - readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' - readonly data: string - readonly name?: string +/** Attachment payload passed to a claimed command submission. */ +export type SubmitAttachment = + | { + readonly type: 'image' + readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + readonly data: string + readonly name?: string + } + | { readonly type: 'file'; readonly receiptId: string } + +/** Command serialization result for one ordered attachment draft. */ +export interface DraftAttachmentSerializationResult { + readonly attachments: readonly SubmitAttachment[] } /** Settled result of a command or default composer submission. */ @@ -37,15 +45,15 @@ export interface SubmitOutcome { export interface CommandClaim { readonly token: string readonly hint?: string - readonly images?: boolean + readonly attachments?: boolean /** * Submit the claimed command. * @param args - command text after the claimed token. * @param actx - current Session scope. - * @param images - serialized draft images accepted by the claim. + * @param attachments - serialized draft attachments accepted by the claim. * @returns command settlement. */ - submit(args: string, actx: Context, images: readonly SubmitImageAttachment[]): Promise + submit(args: string, actx: Context, attachments: readonly SubmitAttachment[]): Promise } /** Structured reference inserted by an input-trigger source. */ @@ -127,7 +135,7 @@ export interface InputTriggerController { adjudicate( line: string, signal: AbortSignal, - envelope: { readonly images: number }, + envelope: { readonly attachments: number }, ): Promise /** @param source - source name. @param hit - synthetic trigger hit. */ toggleSource(source: string, hit: InputTriggerHit): void @@ -162,7 +170,7 @@ declare module '@deepseek-ai/cordis' { } } -/** Browser-runtime identity of one unsent image draft. */ +/** Browser-runtime identity of one unsent attachment draft. */ export type DraftAttachmentId = Branded<'DraftAttachmentId'> /** @@ -181,12 +189,12 @@ export interface InputTarget { export interface SessionInput extends InputTarget { /** Replace the whole draft (persisted-draft seed and programmatic writes). */ setDraft(text: string): void - /** Append ordered browser-owned image ids; busy admission phases refuse. */ - addImages(ids: readonly DraftAttachmentId[]): boolean - /** Remove one browser-owned image id; busy admission phases refuse. */ - removeImage(id: DraftAttachmentId): void + /** Append ordered browser-owned attachment ids; busy admission phases refuse. */ + addAttachments(ids: readonly DraftAttachmentId[]): boolean + /** Remove one browser-owned attachment id; busy admission phases refuse. @returns whether the id was removed. */ + removeAttachment(id: DraftAttachmentId): boolean /** Drop ids whose browser-owned objects no longer exist. */ - pruneImages(ids: readonly DraftAttachmentId[]): void + pruneAttachments(ids: readonly DraftAttachmentId[]): void /** * THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. * @param mode - delivery intent retained through asynchronous adjudication and serialization. @@ -221,12 +229,12 @@ export interface SessionInputResolver { export interface InputActions { /** Replace the whole draft (persisted-draft seed and programmatic writes). */ setDraft(text: string): void - /** Append ordered browser-owned image ids; busy admission phases refuse. */ - addImages(ids: readonly DraftAttachmentId[]): boolean - /** Remove one browser-owned image id; busy admission phases refuse. */ - removeImage(id: DraftAttachmentId): void + /** Append ordered browser-owned attachment ids; busy admission phases refuse. */ + addAttachments(ids: readonly DraftAttachmentId[]): boolean + /** Remove one browser-owned attachment id; busy admission phases refuse. */ + removeAttachment(id: DraftAttachmentId): void /** Drop ids whose browser-owned objects no longer exist. */ - pruneImages(ids: readonly DraftAttachmentId[]): void + pruneAttachments(ids: readonly DraftAttachmentId[]): void /** Enter submission (adjudication / claim transaction / default sink inside). */ submit(): void } @@ -321,13 +329,13 @@ export interface Occurrence { export interface InputState { /** Clipboard-text projection of the editor document (chips expanded to their clipboard form). */ readonly draft: string - /** Ordered runtime-only image ids; bytes and URLs stay in ConversationController. */ - readonly imageIds: readonly DraftAttachmentId[] + /** Ordered runtime-only attachment ids; browser objects stay in ConversationController. */ + readonly attachmentIds: readonly DraftAttachmentId[] /** Monotonic editor revision (span CAS compares against this). */ readonly draftRev: number readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */ - readonly claim?: { readonly token: string; readonly hint?: string; readonly images?: boolean } + readonly claim?: { readonly token: string; readonly hint?: string; readonly attachments?: boolean } /** Reference occurrence view of the editor's chips, sorted by offset. */ readonly occurrences: readonly Occurrence[] /** Read-only transient inbox projection from Session control, including pending steering. */ @@ -369,7 +377,7 @@ export type InputEvent = | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly draft: string; readonly outcome?: SubmitOutcome; readonly message?: string } /** Settlement of one optimistic default send, independent of the frozen command slot. */ | { readonly type: 'sink-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } - /** Commit an image-only send whose empty draft did not need an attempt. */ + /** Commit an attachment-only send whose empty draft did not need an attempt. */ | { readonly type: 'send-committed' } | { readonly type: 'release' } @@ -392,7 +400,7 @@ export type InputEffect = * Clear the committed draft in the editor and cut undo history. A string * snapshot keeps a pure suffix typed during the Host round-trip (content * appended after the sent snapshot survives; interleaved edits cannot be - * separated and clear whole); null clears unconditionally (image-only + * separated and clear whole); null clears unconditionally (attachment-only * sends have no draft to retain). */ | { readonly type: 'commit-draft'; readonly retainSuffixOf: string | null } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 5107c319bf..272d84b8e3 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,7 +1,8 @@ /** Target-neutral Conversation slot declarations and composed component props. */ import type { ReactNode, RefObject } from 'react' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { MaybeSnapshotSelectorHook, ObservableSnapshot, SnapshotSelectorHook, @@ -22,8 +23,11 @@ import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submissi import type { ConversationSnapshot } from './snapshot.ts' import type { ViewTab } from './views.ts' -/** Browser-owned image that has not crossed the durable Host boundary. */ -export interface ComposerAttachment { +/** Browser-owned draft attachment that has not crossed the durable Host boundary. */ +export type ComposerAttachment = ComposerImageAttachment | ComposerFileAttachment + +/** Browser-owned image, base64-encoded into the prompt at send time. */ +export interface ComposerImageAttachment { kind: 'image' id: DraftAttachmentId file: File @@ -34,16 +38,36 @@ export interface ComposerAttachment { height?: number } +/** Browser-owned generic file whose bytes upload to the Host as soon as it is picked. */ +export interface ComposerFileAttachment { + kind: 'file' + id: DraftAttachmentId + file: File +} + +/** Upload lifecycle of one picked file draft (files upload on pick, not on send). */ +export type DraftFileUpload = + | { readonly status: 'uploading'; readonly loaded: number; readonly total?: number } + | { readonly status: 'ready'; readonly receiptId: FileUploadReceiptId; readonly file: FileAttachmentRef } + | { readonly status: 'error'; readonly message: string } + +/** Per-draft upload states keyed by draft attachment id. */ +export type DraftFileUploads = Readonly> + /** Input state handed to the optional attachment presentation plugin. */ export interface ComposerAttachmentsOwnerProps { - /** Browser-owned draft images in input order. */ + /** Browser-owned draft attachments in input order. */ attachments: readonly ComposerAttachment[] - /** Whether a document-level file drop may add images now. */ + /** Whether a document-level file drop may add attachments now. */ canAcceptDrop: boolean /** Add one dropped batch through the composer's validation path. */ - onAddImages: (files: readonly File[]) => void - /** Remove one draft image through the Conversation service. */ - onRemoveImage: (id: DraftAttachmentId) => void + onAddFiles: (files: readonly File[]) => void + /** Remove one draft attachment through the Conversation service. */ + onRemoveAttachment: (id: DraftAttachmentId) => void + /** Current per-draft upload states for file-kind attachments. */ + uploads: DraftFileUploads + /** Restart one failed file upload. */ + onRetryFile: (id: DraftAttachmentId) => void /** Display-ready limits for the drop invitation. */ dropLimits?: { readonly count: number; readonly size: string } | undefined } @@ -79,6 +103,8 @@ export interface MessageImagesOwnerProps { loadImage: MessageImageLoader /** Horizontal placement inside the owning record. */ align: 'start' | 'end' + /** Force every image into the compact message-attachment tile size. */ + compact?: boolean } /** Slot-backed renderer used by Conversation targets without importing an attachment implementation. */ @@ -135,7 +161,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'conversation.input.right': { kind: 'list'; scope: 'session' } /** Resident composer body, including the no-Session inert state. */ 'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps } - /** Optional draft-image rail and drop target. */ + /** Optional draft-attachment rail and drop target. */ 'conversation.input.attachments': { kind: 'single' scope: 'session-maybe' @@ -260,9 +286,11 @@ export interface ComposerBarOwnerProps { /** Package-private operations injected into the resident composer bar. */ export interface ComposerBarInjected { keyboard: ComposerKeyboard | undefined - addImages: ((files: readonly File[]) => string | null) | undefined - removeImage: ((id: DraftAttachmentId) => void) | undefined - draftImages: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined + addFiles: ((files: readonly File[]) => string | null) | undefined + removeAttachment: ((id: DraftAttachmentId) => void) | undefined + resolveDraftAttachments: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined + /** Restart one failed file upload; absent without a session. */ + retryFileUpload: ((id: DraftAttachmentId) => void) | undefined resolveSubmitMode: ( running: boolean, gesture: ComposerSubmitGesture, @@ -272,6 +300,8 @@ export interface ComposerBarInjected { stop: (() => void) | undefined command: ((line: string) => Promise) | undefined hooks: { + /** Live per-draft upload states for file-kind drafts. */ + fileUploads: ObservableSnapshot notices: ObservableSnapshot lexicon: ObservableSnapshot> menuLauncher: ObservableSnapshot @@ -350,7 +380,7 @@ export type ConversationSessionHeaderSlotProps = & InjectFace & PropsLocale<'conversation'> -/** Full props of the draft-image attachment renderer. */ +/** Full props of the draft-attachment renderer. */ export type ComposerAttachmentsProps = PropsRuntime<'conversation.input.attachments'> & PropsLocale<'conversation'> diff --git a/packages/client/ui-conversation/src/client/image-labels.ts b/packages/client/ui-conversation/src/client/image-labels.ts index a783d4f840..feca691412 100644 --- a/packages/client/ui-conversation/src/client/image-labels.ts +++ b/packages/client/ui-conversation/src/client/image-labels.ts @@ -32,6 +32,9 @@ export function attachmentErrorText( ): string { switch (reason) { case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported') + // A prompt cited a file the Host has no staged upload for (expired + // process, foreign id): solvable by re-adding the file. + case 'FILE_NOT_STAGED': return t('file.notStaged') case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels') case 'IMAGE_DIMENSION_TOO_LARGE': if (limits !== undefined) return t('image.dimensionTooLarge', { size: limits.maxImageDimension }) diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 7b23cfa5bd..cad5ccfb44 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -1,5 +1,6 @@ /** Browser Conversation assemble core, React adapter, shell, and input plugin. */ -export { apply, inject } from './apply.ts' +export { apply, Config, inject } from './apply.ts' +export type { Config as ConversationConfig } from './apply.ts' export { UiConversation } from './conversation/assembly.ts' export type { ConversationBinding } from './conversation/assembly.ts' export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' @@ -48,6 +49,7 @@ export { ConversationViewRegistry } from './conversation/view-registry.ts' export type { ConversationKey } from './locales.ts' export type { ComposerAttachment, ComposerAttachmentsOwnerProps, ComposerAttachmentsProps, + ComposerFileAttachment, ComposerImageAttachment, DraftFileUpload, DraftFileUploads, ComposerBarInjected, ComposerBarOwnerProps, ComposerBarProps, ComposerChainProps, ConversationHeaderActionOwnerProps, ConversationHeaderLineageOwnerProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionHeaderSlotProps, @@ -60,7 +62,7 @@ export type { export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, InputActions, InputState, InsertReferenceRequest, InsertTextRequest, - PickOutcome, ReferenceInsert, SessionInput, SessionInputResolver, SubmitImageAttachment, + PickOutcome, ReferenceInsert, SessionInput, SessionInputResolver, SubmitAttachment, SubmitOutcome, TokenSpan, } from './contract/input.ts' export type { ComposerBlock, ComposerBlocks } from './contract/composer-blocks.ts' diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index e20a006dac..e221abf64b 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -23,12 +23,19 @@ export interface TextRefRange { /** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */ const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g +/** + * What may follow a `/name` token: whitespace or the draft end, the boundary + * the host skill gesture (`dsh-tool-skill`) requires, so `/nfs-hg/xxx`, + * `/plan.md`, and `/plan。` are prose, never a reference. + */ +const SLASH_TOKEN_END_RE = /^(?:\s|$)/ /** * Scan the draft for plain-text reference tokens against the hot lexicons. * Word-boundary discipline: the trigger must sit at the draft * start or after whitespace ('x/name' never matches); the name must be an - * exact lexicon member. + * exact lexicon member; a `/name` token must end at whitespace or the draft + * end ('/name/x' is a path, '/name。' is prose). * @param draft - draft text. * @param lexicon - per-trigger name lists (a missing trigger scans nothing). * @returns matched ranges in draft order. @@ -44,6 +51,7 @@ export function scanTextRefs( while ((m = TEXT_REF_RE.exec(draft)) !== null) { const trigger = m[2] as '/' | '@' const name = m[3] ?? '' + if (trigger === '/' && !SLASH_TOKEN_END_RE.test(draft.slice(m.index + m[0].length))) continue if (lexicon.get(trigger)?.includes(name)) { const start = m.index + (m[1]?.length ?? 0) out.push({ start, end: start + 1 + name.length, trigger }) diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index e2fb81f09b..1cacd83812 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -23,7 +23,7 @@ import { mergeRegister } from '@lexical/utils' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, InputActions, InputEffect, InputNotice, InputState, InputTriggerController, PickOutcome, - Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment, + Occurrence, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitAttachment, SubmitOutcome, TokenSpan, } from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' @@ -63,17 +63,17 @@ export interface SessionInputDeps { /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ defaultSink( text: string, - imageIds: readonly DraftAttachmentId[], + attachmentIds: readonly DraftAttachmentId[], mode: InputSubmitMode, signal: AbortSignal, ): Promise - /** Command-plane image plumbing (the hub owns the conversation face and the copy). */ - commandImages: { + /** Command-plane attachment plumbing (the hub owns the conversation face and the copy). */ + commandAttachments: { /** Resolve ordered draft ids to wire payloads without sending them; rejects when an id no longer resolves. */ - serialize(ids: readonly DraftAttachmentId[]): Promise - /** Free consumed draft images after a successful command submit. */ + serialize(ids: readonly DraftAttachmentId[]): Promise + /** Free consumed draft attachments after a successful command submit. */ release(ids: readonly DraftAttachmentId[]): void - /** Localized composer notice for a claimed command that does not accept images. */ + /** Localized composer notice for a claimed command that does not accept attachments. */ unsupportedNotice(token: string): string } } @@ -117,7 +117,7 @@ const HISTORY_MERGE_DELAY_MS = 1000 interface DetachedDraft { readonly draft: string readonly occurrences: readonly Occurrence[] - readonly imageIds: readonly DraftAttachmentId[] + readonly attachmentIds: readonly DraftAttachmentId[] } /** @@ -135,9 +135,9 @@ export class SessionInputShell implements SessionInput { /** The public provide-channel action face (one stable identity per session). */ readonly actions: InputActions = { setDraft: (text) => { this.setDraft(text) }, - addImages: ids => this.addImages(ids), - removeImage: (id) => { this.removeImage(id) }, - pruneImages: (ids) => { this.pruneImages(ids) }, + addAttachments: ids => this.addAttachments(ids), + removeAttachment: (id) => { this.removeAttachment(id) }, + pruneAttachments: (ids) => { this.pruneAttachments(ids) }, submit: () => { this.submit('queue') }, } @@ -150,24 +150,24 @@ export class SessionInputShell implements SessionInput { private readonly unregister: () => void private noticeSeq = 0 private lastMirroredDraft = '' - private imageIds: readonly DraftAttachmentId[] = [] + private attachmentIds: readonly DraftAttachmentId[] = [] private disposed = false /** Draft persistence mirror (Conversation store write; receives the clipboard projection). */ private mirrorFn: ((text: string) => void) | undefined /** Live lexicon subscription disposer; undefined until the controller resolves. */ private lexiconOff: (() => void) | undefined - /** Default sends retained until admission settles or scope disposal releases their images. */ + /** Default sends retained until admission settles or scope disposal releases their attachments. */ private readonly detachedDrafts = new Map() /** Failed default sends waiting to be restored together in submission order. */ private readonly failedDetached = new Map() /** Revision of the last automatic failure restoration. */ private failedRestoreRev: number | undefined private restoringFailures = false - private imageFlightSeq = 0 - /** Image-only sends retained until admission settles or scope disposal releases their images. */ - private readonly imageFlights = new Map() constructor(private readonly deps: SessionInputDeps) { @@ -281,37 +281,38 @@ export class SessionInputShell implements SessionInput { }, { discrete: true, tag: HISTORY_MERGE_TAG }) } - /** Append ordered image ids unless an admission transaction is locked. */ - addImages(ids: readonly DraftAttachmentId[]): boolean { + /** Append ordered attachment ids unless an admission transaction is locked. */ + addAttachments(ids: readonly DraftAttachmentId[]): boolean { if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false if (ids.length === 0) return true - this.imageIds = [...this.imageIds, ...ids] + this.attachmentIds = [...this.attachmentIds, ...ids] this.publish() return true } /** - * Remove one image id from this draft. Busy admission phases refuse, like - * {@link addImages}: a removal landing while a command submit serializes + * Remove one attachment id from this draft. Busy admission phases refuse, like + * {@link addAttachments}: a removal landing while a command submit serializes * would otherwise vanish from the rail yet still ride the in-flight send. */ - removeImage(id: DraftAttachmentId): void { - if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return - const next = this.imageIds.filter(candidate => candidate !== id) - if (next.length === this.imageIds.length) return - this.imageIds = next + removeAttachment(id: DraftAttachmentId): boolean { + if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false + const next = this.attachmentIds.filter(candidate => candidate !== id) + if (next.length === this.attachmentIds.length) return false + this.attachmentIds = next this.publish() + return true } /** - * Keep only image ids that still resolve in the browser attachment registry. + * Keep only ids that still resolve in the browser attachment registry. * @param available - live registry ids. */ - pruneImages(available: readonly DraftAttachmentId[]): void { + pruneAttachments(available: readonly DraftAttachmentId[]): void { const keep = new Set(available) - const next = this.imageIds.filter(id => keep.has(id)) - if (next.length === this.imageIds.length) return - this.imageIds = next + const next = this.attachmentIds.filter(id => keep.has(id)) + if (next.length === this.attachmentIds.length) return + this.attachmentIds = next this.publish() } @@ -319,11 +320,11 @@ export class SessionInputShell implements SessionInput { * Clear the draft as a successful-send commit: the editor empties (no undo * unit) and the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent * content (the command path gets the same discipline from submit-settled). - * @param imageIds - admitted image ids to remove from this draft. + * @param attachmentIds - admitted attachment ids to remove from this draft. */ - commitSend(imageIds: readonly DraftAttachmentId[]): void { - const submitted = new Set(imageIds) - this.imageIds = this.imageIds.filter(id => !submitted.has(id)) + commitSend(attachmentIds: readonly DraftAttachmentId[]): void { + const submitted = new Set(attachmentIds) + this.attachmentIds = this.attachmentIds.filter(id => !submitted.has(id)) this.dispatchRun(({ type: 'send-committed' })) } @@ -358,34 +359,34 @@ export class SessionInputShell implements SessionInput { * dismisses and the menu tracks frozen. */ submit(mode: InputSubmitMode = 'queue'): void { - if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) { + if (this.snapshot.draft.trim() === '' && this.attachmentIds.length > 0) { if (this.snapshot.phase === 'plain') { - const imageIds = [...this.imageIds] + const attachmentIds = [...this.attachmentIds] const controller = new AbortController() - this.imageFlightSeq += 1 - const flight = this.imageFlightSeq - this.imageFlights.set(flight, { controller, imageIds }) - this.commitSend(imageIds) - void this.deps.defaultSink('', imageIds, mode, controller.signal).then((outcome) => { - if (this.disposed || !this.imageFlights.delete(flight)) return + this.attachmentFlightSeq += 1 + const flight = this.attachmentFlightSeq + this.attachmentFlights.set(flight, { controller, attachmentIds }) + this.commitSend(attachmentIds) + void this.deps.defaultSink('', attachmentIds, mode, controller.signal).then((outcome) => { + if (this.disposed || !this.attachmentFlights.delete(flight)) return if (outcome.kind === 'success') return - this.restoreImages(imageIds) + this.restoreAttachments(attachmentIds) if (outcome.text !== undefined) this.notify('error', outcome.text) }, (error: unknown) => { - if (this.disposed || !this.imageFlights.delete(flight)) return - this.restoreImages(imageIds) + if (this.disposed || !this.attachmentFlights.delete(flight)) return + this.restoreAttachments(attachmentIds) this.notify('error', error instanceof Error ? error.message : String(error)) }) } return } - // Claimed pre-gate: a claim that does not declare image acceptance never - // submits while images are attached — one notice, everything retained. + // Claimed pre-gate: a claim that does not declare attachment acceptance never + // submits while attachments are present — one notice, everything retained. // Enter-time adjudication applies the same policy for unclaimed lines // inside the command source itself. const before = this.snapshot - if (before.phase === 'claimed' && this.imageIds.length > 0 && before.claim?.images !== true) { - this.notify('error', this.deps.commandImages.unsupportedNotice(before.claim?.token ?? before.draft)) + if (before.phase === 'claimed' && this.attachmentIds.length > 0 && before.claim?.attachments !== true) { + this.notify('error', this.deps.commandAttachments.unsupportedNotice(before.claim?.token ?? before.draft)) return } this.dispatchRun(({ type: 'enter', mode, draft: this.projection.clipboardText })) @@ -562,18 +563,18 @@ export class SessionInputShell implements SessionInput { // ---- wiring-layer extras (not on the frozen SessionInput face) ---- /** - * Teardown the shell and return every browser-owned image still retained by + * Teardown the shell and return every browser-owned attachment still retained by * the draft or an unsettled default send. - * @returns image ids the scope disposer must release. + * @returns attachment ids the scope disposer must release. */ dispose(): readonly DraftAttachmentId[] { if (this.disposed) return [] - const retained = new Set(this.imageIds) + const retained = new Set(this.attachmentIds) for (const record of this.detachedDrafts.values()) { - for (const imageId of record.imageIds) retained.add(imageId) + for (const attachmentId of record.attachmentIds) retained.add(attachmentId) } - for (const flight of this.imageFlights.values()) { - for (const imageId of flight.imageIds) retained.add(imageId) + for (const flight of this.attachmentFlights.values()) { + for (const attachmentId of flight.attachmentIds) retained.add(attachmentId) flight.controller.abort() } this.disposed = true @@ -582,7 +583,7 @@ export class SessionInputShell implements SessionInput { this.editor.setRootElement(null) this.detachedDrafts.clear() this.failedDetached.clear() - this.imageFlights.clear() + this.attachmentFlights.clear() return [...retained] } @@ -687,17 +688,17 @@ export class SessionInputShell implements SessionInput { draft: string, mode: InputSubmitMode, ): void { - const imageIds = [...this.imageIds] - this.imageIds = [] + const attachmentIds = [...this.attachmentIds] + this.attachmentIds = [] const occurrences = this.projection.occurrences - const record = { draft, occurrences, imageIds } + const record = { draft, occurrences, attachmentIds } this.detachedDrafts.set(attempt.seq, record) if (this.failedRestoreRev === this.rev) { this.failedDetached.clear() this.failedRestoreRev = undefined } if (occurrences.length === 0) { - this.settleSink(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal)) + this.settleSink(attempt, this.deps.defaultSink(draft.trim(), attachmentIds, mode, attempt.signal)) return } const inputTriggers = this.deps.inputTriggers?.() @@ -721,7 +722,7 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + part.length } out += draft.slice(cursor) - this.settleSink(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal)) + this.settleSink(attempt, this.deps.defaultSink(out.trim(), attachmentIds, mode, attempt.signal)) }, (error: unknown) => { if (this.dead(attempt)) return @@ -758,7 +759,7 @@ export class SessionInputShell implements SessionInput { const record = this.detachedDrafts.get(attempt.seq) if (record === undefined) return this.detachedDrafts.delete(attempt.seq) - this.restoreImages(record.imageIds) + this.restoreAttachments(record.attachmentIds) this.failedDetached.set(attempt.seq, record) if (this.projection.clipboardText === '' || this.failedRestoreRev === this.rev) { this.restoreFailedDrafts() @@ -821,13 +822,13 @@ export class SessionInputShell implements SessionInput { } } - /** Return failed-send images to the head of the rail (ids still resolve — release happens only after success). */ - private restoreImages(imageIds: readonly DraftAttachmentId[]): void { - if (imageIds.length === 0) return - const current = new Set(this.imageIds) - const restored = imageIds.filter(id => !current.has(id)) + /** Return failed-send attachments to the head of the rail; release happens only after success. */ + private restoreAttachments(attachmentIds: readonly DraftAttachmentId[]): void { + if (attachmentIds.length === 0) return + const current = new Set(this.attachmentIds) + const restored = attachmentIds.filter(id => !current.has(id)) if (restored.length === 0) return - this.imageIds = [...restored, ...this.imageIds] + this.attachmentIds = [...restored, ...this.attachmentIds] this.publish() } @@ -839,7 +840,7 @@ export class SessionInputShell implements SessionInput { this.dispatchRun(({ type: 'adjudicated', attempt, outcome: undefined })) return } - inputTriggers.adjudicate(draft.trim(), attempt.signal, { images: this.imageIds.length }).then( + inputTriggers.adjudicate(draft.trim(), attempt.signal, { attachments: this.attachmentIds.length }).then( (outcome: PickOutcome) => { if (this.dead(attempt)) return this.dispatchRun(({ type: 'adjudicated', attempt, outcome })) @@ -855,27 +856,27 @@ export class SessionInputShell implements SessionInput { /** * The submit transaction: claim.submit against the session scope; ok maps * from the outcome kind. An accepting claim receives the serialized draft - * images, which are cleared and released only on a success outcome; a - * failure (serialize, transport, or handler error) keeps draft and images + * attachments, which are cleared and released only on a success outcome; a + * failure (serialize, transport, or handler error) keeps draft and attachments * for correction. */ private beginSubmit(attempt: SubmitAttempt, claim: CommandClaim, args: string): void { - const imageIds = claim.images === true ? [...this.imageIds] : [] + const attachmentIds = claim.attachments === true ? [...this.attachmentIds] : [] Promise.resolve() .then(async () => { - const images = imageIds.length > 0 ? await this.deps.commandImages.serialize(imageIds) : [] + const attachments = attachmentIds.length > 0 ? await this.deps.commandAttachments.serialize(attachmentIds) : [] // Serialization may outlive the attempt (large files, session // teardown); a dead attempt must not reach the Host executor. if (this.dead(attempt)) return undefined - return claim.submit(args, this.deps.actx, images) + return claim.submit(args, this.deps.actx, attachments) }) .then( (outcome) => { if (outcome === undefined || 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.deps.commandImages.release(imageIds) + if (outcome.kind === 'success' && attachmentIds.length > 0) { + const submitted = new Set(attachmentIds) + this.attachmentIds = this.attachmentIds.filter(id => !submitted.has(id)) + this.deps.commandAttachments.release(attachmentIds) } this.dispatchRun(({ type: 'submit-settled', attempt, ok: outcome.kind === 'success', @@ -903,7 +904,7 @@ export class SessionInputShell implements SessionInput { const core = this.core.state return { draft: this.projection.clipboardText, - imageIds: this.imageIds, + attachmentIds: this.attachmentIds, draftRev: this.rev, phase: core.phase, ...(core.claim !== undefined ? { claim: core.claim } : {}), diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 6c7132425e..00730b15f7 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -15,8 +15,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import { queueReadFaceOf } from './queue-store.ts' import type { - ComposerKeyboard, DraftAttachmentId, InputTriggerController, SessionInputResolver, SessionInput, - SubmitImageAttachment, SubmitOutcome, + ComposerKeyboard, DraftAttachmentId, DraftAttachmentSerializationResult, InputTriggerController, + SessionInputResolver, SessionInput, SubmitOutcome, } from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { PopupDismissFace } from './facade.ts' @@ -38,12 +38,12 @@ interface ConversationAttachmentFace { sendSession( session: SessionFace, text: string, - imageIds: readonly DraftAttachmentId[], + attachmentIds: readonly DraftAttachmentId[], mode: InputSubmitMode, signal?: AbortSignal, ): Promise - serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise - releaseDraftImage(id: DraftAttachmentId): void + serializeDraftAttachments(attachmentIds: readonly DraftAttachmentId[]): Promise + releaseDraftAttachment(id: DraftAttachmentId): void } /** Session-addressed input facade registry (SessionInputResolver face + composer-layer extras). */ @@ -88,19 +88,22 @@ export class InputHub implements SessionInputResolver { inputTriggers: () => this.controller(actx), popup: () => this.popup(actx), queue: queueReadFaceOf(session), - defaultSink: (text, imageIds, mode, signal) => this.sink(session, text, imageIds, mode, signal), + defaultSink: (text, attachmentIds, mode, signal) => this.sink(session, text, attachmentIds, mode, signal), steerQueue: () => { void this.steerQueue(session, shell) }, - commandImages: { - serialize: ids => this.conversation().serializeDraftImages(ids), + commandAttachments: { + serialize: async (ids) => { + const result = await this.conversation().serializeDraftAttachments(ids) + return result.attachments + }, // Asymmetric with serialize on purpose: release settles AFTER the // submit RPC, where session teardown may already have unloaded the // conversation service (the same tolerance as the scope disposer // above); leaked preview URLs then die with the document. release: (ids) => { const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined - for (const imageId of ids) conversation?.releaseDraftImage(imageId) + for (const attachmentId of ids) conversation?.releaseDraftAttachment(attachmentId) }, - unsupportedNotice: token => this.t('command.imagesUnsupported', { + unsupportedNotice: token => this.t('command.attachmentsUnsupported', { command: token.trim().replace(/^\//u, ''), }), }, @@ -124,7 +127,7 @@ export class InputHub implements SessionInputResolver { const drafts = shell.dispose() this.shells.delete(id) const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined - for (const imageId of drafts) conversation?.releaseDraftImage(imageId) + for (const attachmentId of drafts) conversation?.releaseDraftAttachment(attachmentId) } }, 'conversation.input: session shell') return shell @@ -175,12 +178,12 @@ export class InputHub implements SessionInputResolver { private sink( session: SessionFace, text: string, - imageIds: readonly DraftAttachmentId[], + attachmentIds: readonly DraftAttachmentId[], mode: InputSubmitMode, signal: AbortSignal, ): Promise { - if (text === '' && imageIds.length === 0) return Promise.resolve({ kind: 'success' }) - return this.conversation().sendSession(session, text, imageIds, mode, signal) + if (text === '' && attachmentIds.length === 0) return Promise.resolve({ kind: 'success' }) + return this.conversation().sendSession(session, text, attachmentIds, 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 cfc757d63b..899da6f996 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -55,7 +55,7 @@ export class SubmitMachine { claim: { token: c.token, ...(c.hint !== undefined ? { hint: c.hint } : {}), - ...(c.images === true ? { images: true } : {}), + ...(c.attachments === true ? { attachments: true } : {}), }, } : {}), @@ -216,7 +216,7 @@ export class SubmitMachine { return [{ type: 'notice', level: ev.ok && ev.outcome?.kind !== 'error' ? 'info' : 'error', text }] } - /** Clear after an accepted image-only send; it has no text suffix to retain. */ + /** Clear after an accepted attachment-only send; it has no text suffix to retain. */ private onSendCommitted(): readonly InputEffect[] { if (this.phase !== 'plain') return [] this.claim = undefined diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index ed93b09b4b..6f94a41dd5 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -23,15 +23,16 @@ export const zh = { 'input.stop': '停止生成', 'input.send': '发送消息', 'input.accessMode': '访问模式,当前:{name}', - 'image.dropTitle': '图片拖动到此处即可添加', - 'image.dropDesc': '最多 {count} 张,每张 {size}', - 'image.dropBlocked': '当前无法添加图片', + 'attachment.pending': '待发送附件', + 'attachment.scrollLeft': '向左滚动附件', + 'attachment.scrollRight': '向右滚动附件', + 'attachment.dropTitle': '文件或图片拖动到此处即可添加', + 'attachment.dropDesc': '图片限制:最多 {count} 张,每张 {size}', + 'attachment.dropBlocked': '当前无法添加文件或图片', 'image.pending': '待发送图片', 'image.openOriginal': '查看原图', 'image.openOriginalLabel': '{label},点击查看原图', 'image.remove': '移除图片 {name}', - 'image.scrollLeft': '向左滚动图片', - 'image.scrollRight': '向右滚动图片', 'image.original': '原图', 'image.label': '图片', 'image.loadFailed': '图片加载失败,点击重试', @@ -46,6 +47,16 @@ export const zh = { 'image.dimensionTooLarge': '图片宽高不能超过 {size}px,请缩小后重试', 'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型', 'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试', + 'file.attach': '添加附件', + 'file.pending': '待发送文件', + 'file.remove': '移除文件 {name}', + 'file.uploading': '上传中…', + 'file.uploadFailed': '上传失败,点击重试', + 'file.retry': '重试上传 {name}', + 'file.stillUploading': '文件还在上传,请等待上传完成后发送', + 'file.sessionUnavailable': '会话不可用,无法上传文件', + 'file.notStaged': '文件尚未上传成功,请重新添加后再试', + 'file.label': '文件', 'context.aria': '上下文已用 {percent}', 'context.used': '上下文已用', 'context.system': '系统提示词', @@ -73,7 +84,7 @@ export const zh = { 'todo.progress.pending': '{pending} 待处理', 'todo.rowTitle': '更新任务清单', 'todo.completed': '{done}/{total} 已完成', - 'command.imagesUnsupported': '/{command} 不接受图片附件,请先移除图片', + 'command.attachmentsUnsupported': '/{command} 不接受附件,请先移除附件', 'ask.rowTitle': '提问', 'ask.waiting': '等待回答', 'ask.cancelled': '已取消', @@ -132,6 +143,7 @@ export const zh = { 'details.running': '运行中…', 'queue.count': '{n} 条排队消息', 'queue.image': '排队消息图片', + 'queue.file': '排队文件 {name}', 'queue.edit': '编辑排队消息', 'queue.edit.unsupported': '包含非文本内容,暂不支持编辑', 'queue.save': '保存排队消息', @@ -174,15 +186,16 @@ export const en = { 'input.stop': 'Stop generating', 'input.send': 'Send message', 'input.accessMode': 'Access mode, current: {name}', - 'image.dropTitle': 'Drag images here to add them', - 'image.dropDesc': 'Up to {count} images, {size} each', - 'image.dropBlocked': 'Images cannot be added right now', + 'attachment.pending': 'Pending attachments', + 'attachment.scrollLeft': 'Scroll attachments left', + 'attachment.scrollRight': 'Scroll attachments right', + 'attachment.dropTitle': 'Drag files or images here to add them', + 'attachment.dropDesc': 'Image limit: up to {count} images, {size} each', + 'attachment.dropBlocked': 'Files and images cannot be added right now', 'image.pending': 'Pending images', 'image.openOriginal': 'View original', 'image.openOriginalLabel': '{label}, click to view original', 'image.remove': 'Remove image {name}', - 'image.scrollLeft': 'Scroll images left', - 'image.scrollRight': 'Scroll images right', 'image.original': 'Original image', 'image.label': 'Image', 'image.loadFailed': 'Image failed to load; click to retry', @@ -197,6 +210,16 @@ export const en = { 'image.dimensionTooLarge': 'Image sides must be at most {size}px; downscale it and try again', 'image.modelUnsupported': 'The current model does not support images; switch to a model that does', 'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again', + 'file.attach': 'Add attachment', + 'file.pending': 'Pending files', + 'file.remove': 'Remove file {name}', + 'file.uploading': 'Uploading…', + 'file.uploadFailed': 'Upload failed; click to retry', + 'file.retry': 'Retry uploading {name}', + 'file.stillUploading': 'Files are still uploading; send after they finish', + 'file.sessionUnavailable': 'Session unavailable; files cannot be uploaded', + 'file.notStaged': 'The file has not finished uploading; re-add it and try again', + 'file.label': 'File', 'context.aria': '{percent} of context used', 'context.used': 'of context used', 'context.system': 'System prompt', @@ -224,7 +247,7 @@ export const en = { 'todo.progress.pending': '{pending} pending', 'todo.rowTitle': 'Update to-do list', 'todo.completed': '{done}/{total} completed', - 'command.imagesUnsupported': '/{command} does not accept image attachments; remove them first', + 'command.attachmentsUnsupported': '/{command} does not accept attachments; remove them first', 'ask.rowTitle': 'Ask question', 'ask.waiting': 'waiting', 'ask.cancelled': 'cancelled', @@ -283,6 +306,7 @@ export const en = { 'details.running': 'Running…', 'queue.count': '{n} queued messages', 'queue.image': 'Queued message image', + 'queue.file': 'Queued file {name}', 'queue.edit': 'Edit queued message', 'queue.edit.unsupported': 'Contains non-text content; editing is not supported yet', 'queue.save': 'Save queued message', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index dd2cbc73ce..a328956ba3 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -122,10 +122,51 @@ box-shadow: inset 0 1px 0 var(--dsw-alias-border-l1); } -.thumbs { +.attachments { display: flex; flex: none; gap: 4px; + min-width: 0; + max-width: 55%; + overflow: hidden; +} + +.file { + display: inline-flex; + flex: 0 1 180px; + align-items: center; + gap: 4px; + min-width: 74px; + height: 24px; + padding: 0 6px; + overflow: hidden; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: 6px; + background: var(--dsw-alias-bg-base); + box-sizing: border-box; +} + +.fileIcon { + display: inline-flex; + flex: none; + width: 16px; + height: 16px; +} + +.fileName { + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-primary-dimmed); + font: var(--dsw-font-xs-13); + text-overflow: ellipsis; + white-space: nowrap; +} + +.fileSize { + flex: none; + color: var(--dsw-alias-label-tertiary); + font-size: 10px; + white-space: nowrap; } .thumb { diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 2840268034..2d0d5d876e 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -1,11 +1,12 @@ import type { Context } from '@deepseek-ai/cordis' import { useEffect, useId, useMemo, useState } from 'react' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, - IconEditOutline16, IconQueueOutline14, IconSendOutline14, IconTrashOutline16, projectUserText, Tooltip, + DocumentFileIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14, + IconTrashOutline16, projectUserText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId, QueueRow } from '../contract/queue.ts' import { NS } from '../locales.ts' @@ -26,12 +27,36 @@ export interface QueueDockInjected { * @param content - the row's wire content blocks. * @returns the row's durable image references in block order. */ -function queueImageRefs(content: QueueRow['content']): ImageAttachmentRef[] { - return content.flatMap((block) => { - if (block.type !== 'image') return [] - const { attachment } = block as { attachment?: ImageAttachmentRef } - return attachment === undefined ? [] : [attachment] - }) +function queueAttachments(content: QueueRow['content']): Array< + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } +> { + const attachments: Array< + | { readonly type: 'image'; readonly attachment: ImageAttachmentRef } + | { readonly type: 'file'; readonly attachment: FileAttachmentRef } + > = [] + for (const block of content) { + if (block.type === 'image') { + const { attachment } = block as { attachment?: ImageAttachmentRef } + if (attachment !== undefined) attachments.push({ type: 'image', attachment }) + } + if (block.type === 'file') { + const { attachment } = block as { attachment?: FileAttachmentRef } + if (attachment !== undefined) attachments.push({ type: 'file', attachment }) + } + } + return attachments +} + +/** Compact file identity used beside queue thumbnails. */ +function QueueFile({ attachment, label }: { attachment: FileAttachmentRef; label: string }) { + return ( + + + {attachment.name} + {fileSizeText(attachment.bytes)} + + ) } /** One durable queued image as a fixed-size thumbnail; a load failure keeps the empty placeholder. */ @@ -137,7 +162,7 @@ export function QueueDock({ useSession, updateQueue, notify, loadImage, t }: Que )}
diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 27a4c3e629..29023e1b92 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -16,13 +16,18 @@ import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { ISessions, PendingSubmissionRetirement, SessionFace, } from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-client-file-upload/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' -import type { ComposerAttachment } from './contract/slots.ts' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { + ComposerAttachment, ComposerFileAttachment, ComposerImageAttachment, DraftFileUpload, +} from './contract/slots.ts' import type { QueueAction, QueueItemId } from './contract/queue.ts' import type { ComposerBlocks } from './contract/composer-blocks.ts' import type { - DraftAttachmentId, SessionInputResolver, SubmitImageAttachment, SubmitOutcome, + DraftAttachmentId, DraftAttachmentSerializationResult, SessionInputResolver, SubmitAttachment, SubmitOutcome, } from './contract/input.ts' import type { InputSubmitMode } from './contract/composer-submission.ts' @@ -64,8 +69,8 @@ export interface IConversation { loadOlder(): Promise } -/** Create one browser-only draft descriptor; only its id enters input state. */ -function browserDraftAttachment(file: File): ComposerAttachment { +/** Create one browser-only image draft descriptor; only its id enters input state. */ +function browserDraftAttachment(file: File): ComposerImageAttachment { return { kind: 'image', id: randomUUID() as DraftAttachmentId, @@ -82,7 +87,7 @@ function browserDraftAttachment(file: File): ComposerAttachment { * reads the dimensions into an immutable echo snapshot, so this late write * does not require a store notification. */ -function probeDimensions(attachment: ComposerAttachment): void { +function probeDimensions(attachment: ComposerImageAttachment): void { if (typeof Image !== 'function') return const probe = new Image() probe.onload = () => { @@ -115,8 +120,8 @@ function nextPaint(): Promise { }) } -/** Native canonical base64 of one browser file (FileReader data-URL encode; no main-thread byte loop). */ -function base64Of(file: File): Promise { +/** Native canonical base64 of one browser image (FileReader data-URL encode; no main-thread byte loop). */ +function base64ImageOf(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => { @@ -149,7 +154,20 @@ export class ConversationController extends Service implements IConversation { readonly input: SessionInputResolver /** The per-session composer-block registry. */ readonly blocks: ComposerBlocks + /** Live upload state per file-kind draft; images never appear here. */ + readonly fileUploads: SnapshotStore> = createSnapshotStore>({}) private readonly draftAttachments = new Map() + private readonly fileUploadOperations = new Map + }>() + private readonly pendingFileUploads = new Set>() + private readonly fileUploadQueue: Array<{ + readonly run: () => Promise + readonly settle: () => void + }> = [] + private activeFileUploads = 0 + private readonly maxConcurrentFileUploads: number /** * @param ctx - owning root context (the plugin apply context; the service @@ -158,15 +176,26 @@ export class ConversationController extends Service implements IConversation { * constructed by the plugin apply (the same instances the slot inject * factories close over). */ - constructor(ctx: Context, config: { input: SessionInputResolver; blocks: ComposerBlocks }) { + constructor(ctx: Context, config: { + input: SessionInputResolver + blocks: ComposerBlocks + maxConcurrentFileUploads: number + }) { super(ctx, 'conversation') this.input = config.input this.blocks = config.blocks - ctx.effect(() => () => { + this.maxConcurrentFileUploads = config.maxConcurrentFileUploads + ctx.effect(() => async () => { + const operations = [...this.fileUploadOperations.values()] + for (const operation of operations) operation.controller.abort() + await Promise.allSettled([...this.pendingFileUploads]) + this.fileUploadOperations.clear() + this.fileUploadQueue.length = 0 for (const attachment of this.draftAttachments.values()) { - revokePreview(attachment.previewUrl) + if (attachment.kind === 'image') revokePreview(attachment.previewUrl) } this.draftAttachments.clear() + this.fileUploads.set({}) }, 'conversation draft attachments') } @@ -183,15 +212,15 @@ export class ConversationController extends Service implements IConversation { } /** - * Submit ordered draft images with text through one host admission. A local + * Submit ordered draft attachments with text through one host admission. A local * submission echo enters the session snapshot synchronously; serialization * and the prompt round-trip start after the browser can paint it. On the - * echo's observed retirement the draft images hand their preview URLs to - * the durable image cache and leave the registry; on failure they stay - * registered so the composer can restore them. + * echo's observed retirement seeds admitted image previews into the durable + * cache and removes every attachment from the draft registry. On failure, + * every attachment remains registered so the composer can restore it. * @param session - target session. * @param text - serialized prompt text. - * @param imageIds - ordered draft-local attachment ids. + * @param attachmentIds - 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. @@ -199,17 +228,41 @@ export class ConversationController extends Service implements IConversation { async sendSession( session: SessionFace, text: string, - imageIds: readonly DraftAttachmentId[], + attachmentIds: readonly DraftAttachmentId[], mode: InputSubmitMode, 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 attachments = this.resolveDraftAttachments(attachmentIds) + if (attachments.length !== attachmentIds.length) { + throw new Error('conversation.sendSession: one or more draft attachments are no longer available') } + const uploads = this.fileUploads.getSnapshot() + const uploadFor = (attachment: ComposerFileAttachment): Extract => { + const upload = uploads[attachment.id] + if (upload === undefined || upload.status !== 'ready') { + throw new Error('conversation.sendSession: one or more files have not finished uploading') + } + return upload + } + const pendingAttachments = attachments.map(attachment => attachment.kind === 'image' + ? { + type: 'image' as const, + value: { + previewUrl: attachment.previewUrl, + ...(attachment.file.name === '' ? {} : { name: attachment.file.name }), + ...(attachment.width === undefined ? {} : { width: attachment.width }), + ...(attachment.height === undefined ? {} : { height: attachment.height }), + }, + } + : { type: 'file' as const, value: uploadFor(attachment).file }) + const serializeAttachments = (): Promise[0]> => Promise.all( + attachments.map(async attachment => attachment.kind === 'image' + ? { type: 'image' as const, ...await this.encodeImage(attachment.file) } + : { type: 'file' as const, receiptId: uploadFor(attachment).receiptId }), + ) const snapshot = session.getSnapshot() if (snapshot.subagent !== null) { - const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) + const uploaded = await serializeAttachments() const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] const result = await session.prompt(content, mode, signal) return result.ok ? { kind: 'success' } : { kind: 'error' } @@ -221,21 +274,16 @@ export class ConversationController extends Service implements IConversation { const submission = session.beginSubmission({ mode, text, - images: attachments.map(attachment => ({ - previewUrl: attachment.previewUrl, - ...(attachment.file.name === '' ? {} : { name: attachment.file.name }), - ...(attachment.width === undefined ? {} : { width: attachment.width }), - ...(attachment.height === undefined ? {} : { height: attachment.height }), - })), + attachments: pendingAttachments, onRetire: (settlement) => { - this.settleSubmittedImages(session.sessionId, attachments, settlement) + this.settleSubmittedAttachments(session.sessionId, attachments, settlement) finishRetirement?.(settlement) }, }) let content: Parameters[0] try { await nextPaint() - const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) + const uploaded = await serializeAttachments() content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] } catch (error) { submission.abandon() @@ -248,26 +296,136 @@ export class ConversationController extends Service implements IConversation { } /** - * Create runtime-only draft images and their object URLs. - * @param files - browser files to register after MIME validation. + * Create runtime-only draft attachments. Files whose browser MIME is an + * accepted image type become image drafts (object URL preview, bytes sent + * with the prompt); every other file becomes a file draft whose background + * upload starts immediately and remains owned by this service across Session + * navigation until completion or explicit removal. + * @param sessionId - target Agent-scope identity. + * @param files - browser files to register. * @returns ordered draft descriptors. */ - createDraftImages(files: readonly File[]): readonly ComposerAttachment[] { - for (const file of files) imageMediaType(file.type) + createDrafts(sessionId: SessionId, files: readonly File[]): readonly ComposerAttachment[] { return files.map((file) => { - const attachment = browserDraftAttachment(file) + if (isImageMediaType(file.type)) { + const attachment = browserDraftAttachment(file) + this.draftAttachments.set(attachment.id, attachment) + probeDimensions(attachment) + return attachment + } + const attachment: ComposerFileAttachment = { + kind: 'file', + id: randomUUID() as DraftAttachmentId, + file, + } this.draftAttachments.set(attachment.id, attachment) - probeDimensions(attachment) + this.beginFileUpload(sessionId, attachment) return attachment }) } /** - * Resolve ordered input-state ids to runtime-owned draft images. + * Restart one failed file upload. + * @param sessionId - target Agent-scope identity. + * @param id - draft attachment id whose upload previously failed. + */ + retryFileUpload(sessionId: SessionId, id: DraftAttachmentId): void { + const attachment = this.draftAttachments.get(id) + if (attachment === undefined || attachment.kind !== 'file') return + if (this.fileUploads.getSnapshot()[id]?.status !== 'error') return + this.beginFileUpload(sessionId, attachment) + } + + /** + * Stage carried file drafts again for a new Session. + * @param sessionId - target Agent-scope identity after a Workspace switch. + * @param ids - carried draft attachment ids. + */ + rebindDraftFiles(sessionId: SessionId, ids: readonly DraftAttachmentId[]): void { + for (const id of ids) { + const attachment = this.draftAttachments.get(id) + if (attachment?.kind === 'file') this.beginFileUpload(sessionId, attachment) + } + } + + private beginFileUpload(sessionId: SessionId, attachment: ComposerFileAttachment): void { + this.fileUploadOperations.get(attachment.id)?.controller.abort() + const controller = new AbortController() + this.fileUploads.update((draft) => { + draft[attachment.id] = { status: 'uploading', loaded: 0 } + }) + let settle!: () => void + const done = new Promise((resolve) => { settle = resolve }) + this.fileUploadOperations.set(attachment.id, { controller, done }) + this.pendingFileUploads.add(done) + void done.then(() => { this.pendingFileUploads.delete(done) }) + const run = async (): Promise => { + try { + if (controller.signal.aborted + || this.fileUploadOperations.get(attachment.id)?.controller !== controller) return + const result = await this.ctx.fileUpload.upload( + sessionId, + attachment.file, + attachment.file.name === '' ? undefined : attachment.file.name, + controller.signal, + (progress) => { + if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return + this.fileUploads.update((draft) => { + if (!(attachment.id in draft)) return + draft[attachment.id] = { + status: 'uploading', + loaded: progress.loaded, + ...(progress.total === undefined ? {} : { total: progress.total }), + } + }) + }, + ) + if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return + this.fileUploads.update((draft) => { + if (!(attachment.id in draft)) return + draft[attachment.id] = result.ok + ? { status: 'ready', receiptId: result.value.receiptId, file: result.value.file } + : { status: 'error', message: result.error.message } + }) + } catch (error) { + if (this.fileUploadOperations.get(attachment.id)?.controller !== controller) return + this.fileUploads.update((draft) => { + if (!(attachment.id in draft)) return + draft[attachment.id] = { + status: 'error', + message: error instanceof Error ? error.message : String(error), + } + }) + } finally { + if (this.fileUploadOperations.get(attachment.id)?.controller === controller) { + this.fileUploadOperations.delete(attachment.id) + } + } + } + this.fileUploadQueue.push({ run, settle }) + this.pumpFileUploads() + } + + /** Start queued upload Workers until the configured concurrency is occupied. */ + private pumpFileUploads(): void { + while (this.activeFileUploads < this.maxConcurrentFileUploads) { + const task = this.fileUploadQueue.shift() + if (task === undefined) return + this.activeFileUploads += 1 + void task.run().finally(() => { + this.activeFileUploads -= 1 + task.settle() + this.pumpFileUploads() + }) + } + } + + /** + * Resolve ordered input-state ids to runtime-owned draft attachments. * @param ids - draft attachment ids. * @returns descriptors that remain live, in requested order. */ - draftImages(ids: readonly DraftAttachmentId[]): readonly ComposerAttachment[] { + resolveDraftAttachments(ids: readonly DraftAttachmentId[]): readonly ComposerAttachment[] { const attachments: ComposerAttachment[] = [] for (const id of ids) { const attachment = this.draftAttachments.get(id) @@ -277,37 +435,59 @@ export class ConversationController extends Service implements IConversation { } /** - * Serialize ordered draft images to command-submit wire payloads without - * sending or releasing them (the composer releases only after the command - * settles successfully). - * @param imageIds - ordered draft-local attachment ids. - * @returns base64 payloads in id order. + * Serialize ordered draft attachments to command-submit wire payloads without + * sending or releasing them. Images are encoded; generic files cite receipts + * from their completed background uploads and never reread browser bytes. + * @param attachmentIds - ordered draft-local attachment ids. + * @returns wire payloads in id order. */ - async serializeDraftImages(imageIds: readonly DraftAttachmentId[]): Promise { - const attachments = this.draftImages(imageIds) - if (attachments.length !== imageIds.length) { - throw new Error('conversation.serializeDraftImages: one or more draft images are no longer available') + async serializeDraftAttachments( + attachmentIds: readonly DraftAttachmentId[], + ): Promise { + const attachments = this.resolveDraftAttachments(attachmentIds) + if (attachments.length !== attachmentIds.length) { + throw new Error('conversation.serializeDraftAttachments: one or more draft attachments are no longer available') + } + const uploads = this.fileUploads.getSnapshot() + return { + attachments: await Promise.all(attachments.map(async (attachment) => { + if (attachment.kind === 'image') return { type: 'image' as const, ...await this.encodeImage(attachment.file) } + const upload = uploads[attachment.id] + if (upload === undefined || upload.status !== 'ready') { + throw new Error('conversation.serializeDraftAttachments: one or more files have not finished uploading') + } + return { type: 'file' as const, receiptId: upload.receiptId } + })), } - return Promise.all(attachments.map(attachment => this.encodeImage(attachment.file))) } /** - * Release one browser-owned draft image and preview URL. + * Release one browser-owned draft attachment, aborting its active upload. * @param id - draft attachment id. */ - releaseDraftImage(id: DraftAttachmentId): void { + releaseDraftAttachment(id: DraftAttachmentId): void { const attachment = this.draftAttachments.get(id) if (attachment === undefined) return + const operation = this.fileUploadOperations.get(id) + this.fileUploadOperations.delete(id) + operation?.controller.abort() this.draftAttachments.delete(id) - revokePreview(attachment.previewUrl) + if (attachment.kind === 'image') { + revokePreview(attachment.previewUrl) + return + } + // The stored Host object stays durable; only the draft's upload state ends. + this.fileUploads.set(Object.fromEntries( + Object.entries(this.fileUploads.getSnapshot()).filter(([key]) => key !== id), + )) } /** - * Release a set of browser-owned draft images. + * Release a set of browser-owned draft attachments. * @param attachments - descriptors to release. */ - releaseDraftImages(attachments: readonly ComposerAttachment[]): void { - for (const attachment of attachments) this.releaseDraftImage(attachment.id) + releaseDraftAttachments(attachments: readonly ComposerAttachment[]): void { + for (const attachment of attachments) this.releaseDraftAttachment(attachment.id) } /** Apply one operation to a pending queue occurrence. */ @@ -361,40 +541,41 @@ export class ConversationController extends Service implements IConversation { } /** - * Settle one submission's draft images when its echo retires. Observed: + * Settle one submission's draft attachments when its echo retires. Observed: * each image leaves the registry, handing its preview URL to the durable * image cache (seeded under the admitted reference so the transcript node * renders immediately while the cache reads canonical bytes) or revoking it * when the cache already holds that reference. Failed: nothing changes; * the ids stay registered for the composer's rail restore. */ - private settleSubmittedImages( + private settleSubmittedAttachments( sessionId: SessionId, attachments: readonly ComposerAttachment[], retirement: PendingSubmissionRetirement, ): void { if (retirement.reason !== 'observed') return const uiConversation = this.ctx.get('uiConversation') - attachments.forEach((attachment, index) => { + let observedIndex = 0 + for (const attachment of attachments) { const live = this.draftAttachments.get(attachment.id) - if (live === undefined) return + const ref = retirement.attachments[observedIndex++] + if (live === undefined) continue + if (attachment.kind === 'file') { + this.releaseDraftAttachment(attachment.id) + continue + } this.draftAttachments.delete(attachment.id) - const ref = retirement.attachments[index] - if (ref !== undefined && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) return + if (ref !== undefined && 'mediaType' in ref + && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) continue revokePreview(attachment.previewUrl) - }) - } - - /** Convert browser files to canonical base64 prompt parts. */ - private serializeImages(images: readonly File[]): Promise[0]> { - return Promise.all(images.map(async file => ({ type: 'image' as const, ...await this.encodeImage(file) }))) + } } /** Canonical base64 wire form of one browser image file. */ - private async encodeImage(file: File): Promise { + private async encodeImage(file: File): Promise, 'type'>> { return { mediaType: imageMediaType(file.type), - data: await base64Of(file), + data: await base64ImageOf(file), ...(file.name === '' ? {} : { name: file.name }), } } @@ -412,6 +593,11 @@ function imageMediaType(value: string): ImageMediaType { } } +/** Whether a browser-declared MIME selects the image draft path (all other files upload verbatim). */ +function isImageMediaType(value: string): boolean { + return value === 'image/png' || value === 'image/jpeg' || value === 'image/webp' || value === 'image/gif' +} + function revokePreview(url: string): void { if (url.startsWith('blob:')) URL.revokeObjectURL(url) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index c223459c51..8747f70104 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -14,10 +14,10 @@ */ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react' +import type { ChangeEvent, CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { - IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, + IconPaperclipOutline16, IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). @@ -40,9 +40,10 @@ import css from './InputBar.module.css' export type InputBarProps = ComposerBarProps export const InputBar = memo(function InputBar({ - useSession, useInput, inputActions, keyboard, addImages, removeImage, draftImages, + useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments, + retryFileUpload, resolveSubmitMode, toggleCommandMenu, stop, command, t, - renderSlot, useNotices, useLexicon, useMenuLauncher, + renderSlot, useFileUploads, useNotices, useLexicon, useMenuLauncher, useProjection, sessionId, variant, disabled: inert = false, blocked, workspacePickerOpen = false, onRequestWorkspace, placeholder, accessory, @@ -66,10 +67,16 @@ export const InputBar = memo(function InputBar({ const draft = input?.draft ?? '' const editor = keyboard?.editor ?? null const attachments = useMemo( - () => input === undefined || draftImages === undefined ? [] : draftImages(input.imageIds), - [draftImages, input?.imageIds], + () => input === undefined || resolveDraftAttachments === undefined ? [] : resolveDraftAttachments(input.attachmentIds), + [resolveDraftAttachments, input?.attachmentIds], ) const empty = draft.trim() === '' && attachments.length === 0 + const uploads = useFileUploads(snapshot => snapshot) + // Send waits for every picked file: uploading and failed drafts both hold + // the gate (a failed upload is retried or removed, never silently dropped). + const uploadsPending = attachments.some( + attachment => attachment.kind === 'file' && uploads[attachment.id]?.status !== 'ready', + ) // Transient error banner (machine notices, image-intake rejections, and // prompt failures): the seq keys the Toast so an identical repeated message // restarts the hold-then-fade cycle instead of reusing the faded one. @@ -136,10 +143,10 @@ export const InputBar = memo(function InputBar({ useEffect(() => { if (input === undefined || inputActions === undefined) return - if (attachments.length !== input.imageIds.length) { - inputActions.pruneImages(attachments.map(attachment => attachment.id)) + if (attachments.length !== input.attachmentIds.length) { + inputActions.pruneAttachments(attachments.map(attachment => attachment.id)) } - }, [attachments, input?.imageIds, inputActions]) + }, [attachments, input?.attachmentIds, inputActions]) // Scroll the draft scrollport the minimum that brings the selection focus // into view — the browser's own behavior for typing, performed for the @@ -212,46 +219,56 @@ export const InputBar = memo(function InputBar({ return () => { el.removeEventListener('wheel', onWheel) } }, []) - // Intake pre-check: an addition that would break - // a projected limit is refused as a whole batch, announced immediately, and - // never enters the rail — no more submit-time failure rolling the rail - // back. The host enforces the same limits at submit for callers that bypass + // Intake pre-check: an addition that would break a projected image limit is + // refused as a whole batch, announced immediately, and never enters the + // rail. Only the image subset is limit-checked: generic files carry no + // client-side size or count limit and upload as soon as they are picked. + // The host enforces the same image limits at submit for callers that bypass // this composer. - const intakeImages = useCallback((files: readonly File[]): void => { - if (addImages === undefined || files.length === 0) return + const intakeFiles = useCallback((files: readonly File[]): void => { + if (subagent !== null || addFiles === undefined || files.length === 0) return const rejected = ((): string | null => { if (imageLimits !== undefined) { - // Format precedes limits: a batch with - // a non-image must announce the format problem, not a count or size - // it could never pass anyway — addImages rejects it authoritatively. - if (files.some(file => !(imageLimits.mediaTypes as readonly string[]).includes(file.type))) { - return addImages(files) - } - if (attachments.length + files.length > imageLimits.maxImagesPerMessage) { + const mediaTypes = imageLimits.mediaTypes as readonly string[] + const images = files.filter(file => mediaTypes.includes(file.type)) + const imageAttachments = attachments.filter(attachment => attachment.kind === 'image') + if (imageAttachments.length + images.length > imageLimits.maxImagesPerMessage) { return t('image.tooMany', { count: imageLimits.maxImagesPerMessage }) } - if (files.some(file => file.size > imageLimits.maxImageBytes)) { + if (images.some(file => file.size > imageLimits.maxImageBytes)) { return t('image.fileTooLarge', { size: imageSizeText(imageLimits.maxImageBytes) }) } - const total = attachments.reduce((sum, attachment) => sum + attachment.file.size, 0) - + files.reduce((sum, file) => sum + file.size, 0) + const total = imageAttachments.reduce((sum, attachment) => sum + attachment.file.size, 0) + + images.reduce((sum, file) => sum + file.size, 0) if (total > imageLimits.maxMessageImageBytes) { return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) }) } } - return addImages(files) + return addFiles(files) })() if (rejected !== null) showToast(rejected) - }, [addImages, attachments, imageLimits, showToast, t]) + }, [subagent, addFiles, attachments, imageLimits, showToast, t]) - const canAcceptDrop = !locked && !machineBusy && addImages !== undefined + const canAcceptDrop = subagent === null && !locked && !machineBusy && addFiles !== undefined + + const fileInputRef = useRef(null) + const onPickFiles = (e: ChangeEvent): void => { + const picked = e.target.files === null ? [] : [...e.target.files] + // Reset so picking the same file again re-fires the change event. + e.target.value = '' + if (picked.length > 0) intakeFiles(picked) + } // The keymap handlers read live bar state through this ref so the editor // registration survives re-renders without re-arming per keystroke. const gate = useRef({ - locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages, + locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, + intakeFiles, uploadsPending, showToast, t, }) - gate.current = { locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, intakeImages } + gate.current = { + locked, machineBusy, canSteerQueue, running, subagent, resolveSubmitMode, + intakeFiles, uploadsPending, showToast, t, + } useEffect(() => { if (editor === null || keyboard === undefined) return @@ -272,13 +289,17 @@ export const InputBar = memo(function InputBar({ keyboard.steerQueue() return } + if (g.uploadsPending) { + g.showToast(g.t('file.stillUploading')) + return + } keyboard.submit(g.resolveSubmitMode( g.running, accelerated ? 'accelerated' : 'enter', g.subagent === null, )) }, - intakeFiles: (files) => { gate.current.intakeImages(files) }, + intakeFiles: (files) => { gate.current.intakeFiles(files) }, pasteText: (text) => { if (gate.current.machineBusy || gate.current.locked) return keyboard.paste(text) @@ -321,8 +342,8 @@ export const InputBar = memo(function InputBar({ return } if (inputActions === undefined) return // absent machine: the button is disabled - /* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */ - if (!empty && !disabled && !machineBusy) inputActions.submit() + /* v8 ignore next -- defensive: the primary button is disabled for empty, disabled, and pending-upload states. */ + if (!empty && !disabled && !machineBusy && !uploadsPending) inputActions.submit() } // The Access seat: the projection-fed permission chip (renders nothing @@ -398,8 +419,10 @@ export const InputBar = memo(function InputBar({ {renderSlot('conversation.input.attachments', { attachments, canAcceptDrop, - onAddImages: intakeImages, - onRemoveImage: (id) => { removeImage?.(id) }, + onAddFiles: intakeFiles, + onRemoveAttachment: (id) => { removeAttachment?.(id) }, + uploads, + onRetryFile: (id) => { retryFileUpload?.(id) }, dropLimits: imageLimits === undefined ? undefined : { count: imageLimits.maxImagesPerMessage, size: imageSizeText(imageLimits.maxImageBytes), @@ -452,6 +475,26 @@ export const InputBar = memo(function InputBar({ + + + +
{accessSelect} {sessionId === undefined ? null : renderSlot('conversation.input.plan', { locked })} @@ -487,7 +530,7 @@ export const InputBar = memo(function InputBar({ type="button" className={css.primary} aria-label={primaryLabel} - disabled={primaryStops ? stop === undefined : empty || disabled || machineBusy} + disabled={primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending} onMouseDown={keepFocus} onClick={onPrimary} > 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 a767f54708..2dde9ce7b3 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -33,6 +33,20 @@ function sessionFakeFor() { async function bench() { const runtime = await SlotTestRuntime.create() + const rootUpload = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'root-receipt' as never, + file: { attachmentId: 'root-file' as never, name: 'draft.pdf', bytes: 1 }, + }, + })) + const uploads = new Map Promise>([[ROOT, rootUpload]]) + runtime.fileUpload.available = true + runtime.fileUpload.upload = (sessionId: SessionId, ...args: unknown[]) => { + const upload = uploads.get(sessionId) + if (upload === undefined) throw new Error('test file upload has no Session fixture') + return upload(...args) + } runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const connectWorkspace = vi.fn(async () => ROOT) runtime.ctx.provide('uiWorkspace', { connectWorkspace } as never) @@ -87,7 +101,7 @@ async function bench() { conversationApi(id).injected.hooks.conversationViews return { runtime, feature, slots: runtime.slots, entryOf, conversationApi, headerApi, residentApi, composerApi, - inputApi, viewSource, sessionFake, connectWorkspace, + inputApi, viewSource, sessionFake, connectWorkspace, rootUpload, uploads, } } @@ -213,6 +227,31 @@ describe('Conversation inject API', () => { await b.runtime.dispose() }) + it('releases a draft attachment only after the input shell accepts its removal', async () => { + const b = await bench() + const composer = b.composerApi(ROOT) + expect(composer.addFiles?.([ + new File([Uint8Array.of(1)], 'draft.pdf', { type: 'application/pdf' }), + ])).toBeNull() + const controller = b.runtime.ctx.get('conversation') as unknown as { + releaseDraftAttachment(id: string): void + } + const release = vi.spyOn(controller, 'releaseDraftAttachment') + const input = b.inputApi(ROOT).actions + const remove = vi.spyOn(input, 'removeAttachment').mockReturnValue(false) + const draft = composer.resolveDraftAttachments?.( + b.inputApi(ROOT).state.getSnapshot().attachmentIds, + )[0] + if (draft === undefined) throw new Error('missing draft attachment') + + composer.removeAttachment?.(draft.id) + expect(release).not.toHaveBeenCalled() + remove.mockReturnValueOnce(true) + composer.removeAttachment?.(draft.id) + expect(release).toHaveBeenCalledWith(draft.id) + await b.runtime.dispose() + }) + it('fails loud for an unknown binding or an unloaded scoped service', async () => { const b = await bench() const entry = b.entryOf('conversation.composer.bar') @@ -240,6 +279,10 @@ describe('Conversation inject API', () => { const resident = b.residentApi(ROOT) const { state, actions } = b.inputApi(ROOT) actions.setDraft('carry me') + expect(b.composerApi(ROOT).addFiles?.([ + new File([Uint8Array.of(1)], 'draft.pdf', { type: 'application/pdf' }), + ])).toBeNull() + await vi.waitFor(() => { expect(b.rootUpload).toHaveBeenCalledOnce() }) b.connectWorkspace.mockResolvedValueOnce(ROOT) await resident.selectWorkspace('workspace-1' as WorkspaceId) @@ -247,12 +290,22 @@ describe('Conversation inject API', () => { expect(state.getSnapshot().draft).toBe('carry me') const other = 'other-1' as SessionId - await b.runtime.sessions.add({ id: other }, { current: false }) + const targetUpload = vi.fn(() => Promise.resolve({ + ok: true, + value: { + receiptId: 'target-receipt' as never, + file: { attachmentId: 'target-file' as never, name: 'draft.pdf', bytes: 1 }, + }, + })) + b.uploads.set(other, targetUpload) + await b.runtime.sessions.add({ id: other, session: {} }, { current: false }) b.connectWorkspace.mockResolvedValueOnce(other) await resident.selectWorkspace('workspace-2' as WorkspaceId) expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [other] }) expect(state.getSnapshot().draft).toBe('') expect(b.inputApi(other).state.getSnapshot().draft).toBe('carry me') + await vi.waitFor(() => { expect(targetUpload).toHaveBeenCalledOnce() }) + expect(b.inputApi(other).state.getSnapshot().attachmentIds).toHaveLength(1) await b.runtime.dispose() }) 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 6b9f4e0f81..84b07e69c2 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -51,6 +51,8 @@ interface BenchOptions { planEntry?: React.ReactNode /** The `plan` projection value the standard-kit useProjection serves. */ plan?: { active: boolean; pending: boolean } + /** The `goal` projection value used only to prove attachment intake remains ordinary. */ + goal?: { phase: 'active'; objective: string } modelEntry?: React.ReactNode /** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */ lexicon?: ReadonlyMap<'/' | '@', readonly string[]> @@ -87,7 +89,7 @@ interface BenchOptions { rightItems?: React.ReactNode footer?: React.ReactNode attachments?: readonly ComposerAttachment[] - addImages?: (files: readonly File[]) => string | null + addFiles?: (files: readonly File[]) => string | null commandMenuOpen?: boolean busyEnter?: 'queue' | 'steer' toggleCommandMenu?: (selection: { start: number; end: number }) => void @@ -105,7 +107,7 @@ function row(id: string): SessionSnapshot['queue'][number] { function bench(over?: BenchOptions) { const sink = vi.fn<( text: string, - imageIds: readonly DraftAttachmentId[], + attachmentIds: readonly DraftAttachmentId[], mode: 'queue' | 'steer', signal: AbortSignal, ) => Promise>(() => Promise.resolve({ kind: 'success' })) @@ -121,7 +123,7 @@ function bench(over?: BenchOptions) { const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, - commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` }, + commandAttachments: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` }, queue: { getSnapshot: () => session.getSnapshot().queue, subscribe: fn => session.subscribe(fn), @@ -139,9 +141,9 @@ function bench(over?: BenchOptions) { : {}), }) if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) - if (over?.attachments !== undefined) shell.addImages(over.attachments.map(attachment => attachment.id)) + if (over?.attachments !== undefined) shell.addAttachments(over.attachments.map(attachment => attachment.id)) const stop = vi.fn() - const removeImage = vi.fn((id: DraftAttachmentId) => { shell.removeImage(id) }) + const removeAttachment = vi.fn((id: DraftAttachmentId) => { shell.removeAttachment(id) }) const menuLauncher = createSnapshotStore(over?.commandMenuOpen === true ? 'command' : null) const slotCalls: { key: string; owner: unknown }[] = [] const renderSlot = ((key: string, owner: object) => { @@ -170,13 +172,17 @@ function bench(over?: BenchOptions) { useProjection: ((key: string, selector?: (v: unknown) => unknown) => (selector ?? (v => v))(key === 'permissions' ? over?.permissions - : key === 'plan' ? over?.plan : key === 'imageLimits' ? over?.imageLimits : undefined)), + : key === 'plan' ? over?.plan + : key === 'goal' ? over?.goal + : key === 'imageLimits' ? over?.imageLimits : undefined)), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, - addImages: over?.addImages ?? (() => null), - removeImage, - draftImages: ids => ids.flatMap((id) => { + addFiles: over?.addFiles ?? (() => null), + useFileUploads: bindSnapshotSelector(createSnapshotStore({})), + retryFileUpload: undefined, + removeAttachment, + resolveDraftAttachments: ids => ids.flatMap((id) => { const attachment = over?.attachments?.find(candidate => candidate.id === id) return attachment === undefined ? [] : [attachment] }), @@ -212,7 +218,7 @@ function bench(over?: BenchOptions) { )! const interruptButton = view.container.querySelector('button[aria-label="停止生成"]') return { - view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls, + view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeAttachment, slotCalls, menuLauncher, steerQueue: over?.steerQueue, get placeholder() { return placeholderOf(view.container) }, @@ -245,8 +251,8 @@ function writeDraft(shell: SessionInputShell, text: string): void { describe('image draft rail', () => { it('collects clipboard files while preserving text from a mixed paste', async () => { - const addImages = vi.fn(() => null) - const { textarea, shell } = bench({ addImages }) + const addFiles = vi.fn(() => null) + const { textarea, shell } = bench({ addFiles }) const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' }) fireEvent.paste(textarea, { clipboardData: { @@ -257,7 +263,7 @@ describe('image draft rail', () => { getData: () => '同时粘贴的文字', }, }) - expect(addImages).toHaveBeenCalledWith([image]) + expect(addFiles).toHaveBeenCalledWith([image]) // The paste lands inside the PASTE_COMMAND update; its commit is a microtask away. await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('同时粘贴的文字') }) }) @@ -273,40 +279,40 @@ describe('image draft rail', () => { } const png = (bytes: number, name: string) => new File([new ArrayBuffer(bytes)], name, { type: 'image/png' }) const intake = (result: ReturnType, files: File[]) => { - act(() => { attachmentOwner(result.slotCalls).onAddImages(files) }) + act(() => { attachmentOwner(result.slotCalls).onAddFiles(files) }) } // Count: three at once over a two-image limit → the whole batch refused. - const overCount = bench({ addImages: vi.fn(() => null), imageLimits: limits }) + const overCount = bench({ addFiles: vi.fn(() => null), imageLimits: limits }) intake(overCount, [png(8, 'a.png'), png(8, 'b.png'), png(8, 'c.png')]) expect(overCount.view.getByRole('alert').textContent).toContain('一条消息最多添加 2 张图片') - expect(overCount.props.addImages).not.toHaveBeenCalled() + expect(overCount.props.addFiles).not.toHaveBeenCalled() cleanup() // Per-file bytes. - const overFile = bench({ addImages: vi.fn(() => null), imageLimits: limits }) + const overFile = bench({ addFiles: vi.fn(() => null), imageLimits: limits }) intake(overFile, [png(1024 * 1024 + 1, 'big.png')]) expect(overFile.view.getByRole('alert').textContent).toContain('单张图片不能超过 1MB') - expect(overFile.props.addImages).not.toHaveBeenCalled() + expect(overFile.props.addFiles).not.toHaveBeenCalled() cleanup() // Aggregate bytes across the existing rail plus the new batch. const held = new File([new ArrayBuffer(1024 * 1024 * 1.5)], 'held.png', { type: 'image/png' }) const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file: held, previewUrl: 'blob:held' } - const overTotal = bench({ addImages: vi.fn(() => null), imageLimits: limits, attachments: [attachment] }) + const overTotal = bench({ addFiles: vi.fn(() => null), imageLimits: limits, attachments: [attachment] }) intake(overTotal, [png(1024 * 1024, 'more.png')]) expect(overTotal.view.getByRole('alert').textContent).toContain('图片总大小超过 2MB') - expect(overTotal.props.addImages).not.toHaveBeenCalled() + expect(overTotal.props.addFiles).not.toHaveBeenCalled() cleanup() - // Within every limit: the batch passes through to addImages. - const within = bench({ addImages: vi.fn(() => null), imageLimits: limits }) + // Within every limit: the batch passes through to addFiles. + const within = bench({ addFiles: vi.fn(() => null), imageLimits: limits }) const fits = png(16, 'fits.png') intake(within, [fits]) - expect(within.props.addImages).toHaveBeenCalledWith([fits]) + expect(within.props.addFiles).toHaveBeenCalledWith([fits]) expect(within.view.queryByRole('alert')).toBeNull() }) it('announces the format problem before any limit when the batch holds a non-image', () => { - const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片') + const addFiles = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片') const result = bench({ - addImages, + addFiles, imageLimits: { maxImageBytes: 8, maxImagesPerMessage: 1, @@ -321,14 +327,14 @@ describe('image draft rail', () => { new File([new ArrayBuffer(64)], 'a.pdf', { type: 'application/pdf' }), new File([new ArrayBuffer(64)], 'b.pdf', { type: 'application/pdf' }), ] - act(() => { attachmentOwner(result.slotCalls).onAddImages(files) }) - expect(addImages).toHaveBeenCalledWith(files) + act(() => { attachmentOwner(result.slotCalls).onAddFiles(files) }) + expect(addFiles).toHaveBeenCalledWith(files) expect(result.view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片') }) it('projects display-ready limits into the attachment slot', () => { const result = bench({ - addImages: vi.fn(() => null), + addFiles: vi.fn(() => null), imageLimits: { maxImageBytes: 5 * 1024 * 1024, maxImagesPerMessage: 20, @@ -370,7 +376,7 @@ describe('image draft rail', () => { }) it('marks the attachment slot unavailable while the composer is locked', () => { - const result = bench({ addImages: vi.fn(() => null), inert: true }) + const result = bench({ addFiles: vi.fn(() => null), inert: true }) expect(attachmentOwner(result.slotCalls).canAcceptDrop).toBe(false) }) @@ -382,11 +388,11 @@ describe('image draft rail', () => { { 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 + const { view, textarea, sink, removeAttachment } = result expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false) const owner = attachmentOwner(result.slotCalls) - act(() => { owner.onRemoveImage('draft-2' as DraftAttachmentId) }) - expect(removeImage).toHaveBeenCalledWith('draft-2') + act(() => { owner.onRemoveAttachment('draft-2' as DraftAttachmentId) }) + expect(removeAttachment).toHaveBeenCalledWith('draft-2') let settle!: (outcome: SubmitOutcome) => void sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) fireEvent.keyDown(textarea, { key: 'Enter' }) @@ -418,8 +424,8 @@ describe('image draft rail', () => { it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => { vi.useFakeTimers() try { - const addImages = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片') - const { view, textarea } = bench({ addImages }) + const addFiles = vi.fn(() => '仅支持 PNG、JPG、WebP、GIF 格式的图片') + const { view, textarea } = bench({ addFiles }) const paste = () => { fireEvent.paste(textarea, { clipboardData: { @@ -441,10 +447,10 @@ describe('image draft rail', () => { }) it('announces a rejected attachment-slot intake through the same toast', () => { - const addImages = vi.fn(() => '图片读取服务不可用') - const result = bench({ addImages }) + const addFiles = vi.fn(() => '图片读取服务不可用') + const result = bench({ addFiles }) act(() => { - attachmentOwner(result.slotCalls).onAddImages([ + attachmentOwner(result.slotCalls).onAddFiles([ new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' }), ]) }) @@ -777,7 +783,7 @@ describe('running and lock semantics', () => { }) it('running continuable subagent keeps Send beside an independent Stop', () => { - const { button, interruptButton, textarea, sink, stop } = bench({ + const { button, interruptButton, textarea, sink, stop, view, slotCalls } = bench({ running: true, draft: '后续消息', subagent: { @@ -792,12 +798,26 @@ describe('running and lock semantics', () => { expect(button.getAttribute('aria-label')).toBe('发送消息') expect(interruptButton).not.toBeNull() expect(textarea.getAttribute('aria-disabled')).not.toBe('true') + expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(true) + expect(view.container.querySelector('input[type="file"]')?.disabled).toBe(true) + expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(false) fireEvent.click(button) expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal)) fireEvent.click(interruptButton!) expect(stop).toHaveBeenCalledTimes(1) }) + it.each([ + ['active plan', { plan: { active: true, pending: false } }], + ['active goal', { goal: { phase: 'active' as const, objective: 'inspect files' } }], + ])('%s keeps ordinary generic-file intake enabled', (_name, projection) => { + const added = vi.fn(() => null) + const { view, slotCalls } = bench({ ...projection, addFiles: added }) + expect((view.getByLabelText('添加附件') as HTMLButtonElement).disabled).toBe(false) + expect(view.container.querySelector('input[type="file"]')?.disabled).toBe(false) + expect(attachmentOwner(slotCalls).canAcceptDrop).toBe(true) + }) + it('parent-offline running continuable locks Send but keeps independent Stop usable', () => { const { button, interruptButton, textarea, stop, view } = bench({ running: true, 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 bdb878672b..5224973369 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -15,7 +15,7 @@ import { } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SubmitImageAttachment, SubmitOutcome } from '../src/client/contract/input.ts' +import type { SubmitAttachment, SubmitOutcome } from '../src/client/contract/input.ts' 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 { DraftAttachmentId } from '../src/client/contract/input.ts' @@ -63,10 +63,12 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, - addImages: () => null, - removeImage: () => {}, + addFiles: () => null, + useFileUploads: bindSnapshotSelector(createSnapshotStore({})), + retryFileUpload: undefined, + removeAttachment: () => {}, // Every id resolves so the bar's registry prune never drops a test image. - draftImages: ids => ids.map(id => ({ + resolveDraftAttachments: ids => ids.map(id => ({ kind: 'image' as const, id, file: new File([Uint8Array.of(1)], `${id}.png`, { type: 'image/png' }), previewUrl: `blob:${id}`, @@ -89,22 +91,22 @@ function bench(over?: { running?: boolean disabled?: boolean submit?: (args: string) => Promise - serialize?: (ids: readonly DraftAttachmentId[]) => Promise + serialize?: (ids: readonly DraftAttachmentId[]) => Promise }) { const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) - const serialize = vi.fn(over?.serialize ?? (() => Promise.resolve([]))) + 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` } }) + const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink, commandAttachments: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } }) const wiring = shell const view = mountBar(shell, over) const textarea = view.container.querySelector('[data-composer-input]')! - const claim = (token = '/goal ', hint = '目标', images?: true) => { + const claim = (token = '/goal ', hint = '目标', attachments?: true) => { act(() => { shell.setDraft(token) shell.beginCommand( { token, hint, - ...(images === true ? { images: true } : {}), + ...(attachments === true ? { attachments: true } : {}), submit: over?.submit ?? (() => Promise.resolve({ kind: 'success' as const, source: 'command', name: 'goal' })), }, { start: 0, end: token.length, draftRev: shell.snapshot.draftRev }, @@ -168,50 +170,52 @@ describe('matrix row: claimed', () => { }) }) -describe('matrix row: claimed with images', () => { +describe('matrix row: claimed with attachments', () => { const img = 'img-1' as DraftAttachmentId - it('a claim without image acceptance blocks enter: one notice, draft/images/claim retained', async () => { + it('a claim without attachment acceptance blocks enter: one notice and the full draft retained', async () => { const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const { view, textarea, shell, sink, claim } = bench({ submit }) claim() - act(() => { shell.addImages([img]) }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) await Promise.resolve() expect(shell.snapshot.phase).toBe('claimed') expect(submit).not.toHaveBeenCalled() expect(sink).not.toHaveBeenCalled() - expect(view.getByText('/goal images-unsupported')).toBeTruthy() - expect(shell.snapshot.imageIds).toEqual([img]) + expect(view.getByText('/goal attachments-unsupported')).toBeTruthy() + expect(shell.snapshot.attachmentIds).toEqual([img]) expect(shell.snapshot.draft).toBe('/goal ') }) - it('an accepting claim serializes and forwards the images; success consumes and clears', async () => { + it('an accepting claim serializes and forwards a mixed batch; success consumes and clears', async () => { const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) - const png: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } - const { textarea, shell, claim, serialize, release } = bench({ submit, serialize: () => Promise.resolve([png]) }) + const png: SubmitAttachment = { type: 'image', mediaType: 'image/png', data: 'AA==' } + const file: SubmitAttachment = { type: 'file', receiptId: 'receipt-x' } + const { textarea, shell, claim, serialize, release } = bench({ submit, serialize: () => Promise.resolve([png, file]) }) claim('/goal ', '目标', true) // The claim currency carries the acceptance flag the pre-gate reads. - expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标', images: true }) - act(() => { shell.addImages([img]) }) + expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标', attachments: true }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png]) }) + await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('', SCTX, [png, file]) }) expect(serialize).toHaveBeenCalledWith([img]) await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('') }) expect(release).toHaveBeenCalledWith([img]) - expect(shell.snapshot.imageIds).toEqual([]) + expect(shell.snapshot.attachmentIds).toEqual([]) expect(shell.snapshot.phase).toBe('plain') }) - it('a handler error outcome keeps the images unreleased beside the notice and the draft', async () => { + it('a handler error keeps a serialized file unreleased beside the notice and draft', async () => { const submit = vi.fn(() => Promise.resolve({ kind: 'error' as const, text: '处理失败' })) - const { view, textarea, shell, claim, release } = bench({ submit }) + const file: SubmitAttachment = { type: 'file', receiptId: 'receipt-x' } + const { view, textarea, shell, claim, release } = bench({ submit, serialize: () => Promise.resolve([file]) }) claim('/goal ', '目标', true) - act(() => { shell.addImages([img]) }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(view.getByText('处理失败')).toBeTruthy() }) expect(shell.snapshot.phase).toBe('claimed') - expect(shell.snapshot.imageIds).toEqual([img]) + expect(shell.snapshot.attachmentIds).toEqual([img]) expect(release).not.toHaveBeenCalled() expect(shell.snapshot.draft).toBe('/goal ') }) @@ -220,28 +224,28 @@ describe('matrix row: claimed with images', () => { const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const { view, textarea, shell, claim, release } = bench({ submit, serialize: () => Promise.reject(new Error('附件已失效')) }) claim('/goal ', '目标', true) - act(() => { shell.addImages([img]) }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(view.getByText('附件已失效')).toBeTruthy() }) expect(submit).not.toHaveBeenCalled() - expect(shell.snapshot.imageIds).toEqual([img]) + expect(shell.snapshot.attachmentIds).toEqual([img]) expect(release).not.toHaveBeenCalled() expect(shell.snapshot.phase).toBe('claimed') }) it('a disposed shell never lets a pending serialization reach claim.submit', async () => { const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const })) - let resolveSerialize!: (images: readonly SubmitImageAttachment[]) => void + let resolveSerialize!: (images: readonly SubmitAttachment[]) => void const { shell, textarea, claim } = bench({ submit, serialize: () => new Promise((resolve) => { resolveSerialize = resolve }), }) claim('/goal ', '目标', true) - act(() => { shell.addImages([img]) }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(resolveSerialize).toBeDefined() }) shell.dispose() - resolveSerialize([{ mediaType: 'image/png', data: 'AA==' }]) + resolveSerialize([{ type: 'image', mediaType: 'image/png', data: 'AA==' }]) await Promise.resolve() await Promise.resolve() expect(submit).not.toHaveBeenCalled() @@ -251,11 +255,13 @@ describe('matrix row: claimed with images', () => { const submit = vi.fn(() => new Promise(() => {})) // never settles const { shell, textarea, claim } = bench({ submit, serialize: () => Promise.resolve([]) }) claim('/goal ', '目标', true) - act(() => { shell.addImages([img]) }) + act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(shell.snapshot.phase).toBe('submitting') - act(() => { shell.removeImage(img) }) - expect(shell.snapshot.imageIds).toEqual([img]) + let removed = true + act(() => { removed = shell.removeAttachment(img) }) + expect(removed).toBe(false) + expect(shell.snapshot.attachmentIds).toEqual([img]) }) }) 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 index c0319e9e36..57f10879de 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -11,10 +11,10 @@ import type { DraftAttachmentId } from '../src/client/contract/input.ts' const mention = '@[Research](dsh-session:InNvdXJjZSI)' const spacedMention = '@[Research notes](dsh-session:InNvdXJjZSI)' -const commandImages = { +const commandAttachments = { serialize: () => Promise.resolve([]), release: () => {}, - unsupportedNotice: (token: string) => `${token.trim()} images-unsupported`, + unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported`, } function chip(shell: SessionInputShell): void { @@ -38,7 +38,7 @@ describe('reference submission', () => { const first = new SessionInputShell({ actx: {} as Context, defaultSink: vi.fn(), - commandImages, + commandAttachments, }) first.bindMirror(mirror) first.setDraft('@res') @@ -62,7 +62,7 @@ describe('reference submission', () => { const restored = new SessionInputShell({ actx: {} as Context, defaultSink: sink, - commandImages, + commandAttachments, }) restored.setDraft(mirror.mock.calls.at(-1)?.[0] as string) restored.submit() @@ -90,7 +90,7 @@ describe('reference submission', () => { actx: {} as Context, inputTriggers: () => inputTriggers, defaultSink: sink, - commandImages, + commandAttachments, }) chip(shell) expect(shell.snapshot).toMatchObject({ @@ -136,7 +136,7 @@ describe('reference submission', () => { actx: {} as Context, inputTriggers: () => inputTriggers, defaultSink: sink, - commandImages, + commandAttachments, }) chip(shell) shell.submit() @@ -160,7 +160,7 @@ describe('reference submission', () => { signal = received return new Promise(() => {}) }, - commandImages, + commandAttachments, }) shell.setDraft('send this') shell.submit() @@ -177,7 +177,7 @@ describe('reference submission', () => { const shell = new SessionInputShell({ actx: {} as Context, defaultSink: () => Promise.resolve({ kind: 'error' }), - commandImages, + commandAttachments, }) shell.setDraft('retry this') shell.submit() @@ -193,7 +193,7 @@ describe('reference submission', () => { const shell = new SessionInputShell({ actx: {} as Context, defaultSink: () => new Promise((resolve) => { settlements.push(resolve) }), - commandImages, + commandAttachments, }) shell.setDraft('first') shell.submit() @@ -215,18 +215,18 @@ describe('submit transaction hardening', () => { const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, - commandImages, + commandAttachments, }) - expect(shell.addImages(['img-1' as DraftAttachmentId])).toBe(true) + expect(shell.addAttachments(['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.snapshot.attachmentIds).toEqual([]) }) - expect(shell.addImages(['img-2' as DraftAttachmentId])).toBe(true) + expect(shell.addAttachments(['img-2' as DraftAttachmentId])).toBe(true) shell.submit('queue') expect(sink).toHaveBeenCalledTimes(2) }) @@ -236,14 +236,14 @@ describe('submit transaction hardening', () => { const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, - commandImages, + commandAttachments, }) const imageId = 'img-1' as DraftAttachmentId - shell.addImages([imageId]) + shell.addAttachments([imageId]) shell.submit() await Promise.resolve() await Promise.resolve() - expect(shell.snapshot.imageIds).toEqual([imageId]) + expect(shell.snapshot.attachmentIds).toEqual([imageId]) expect(shell.notices.getSnapshot()).toBeNull() }) @@ -256,9 +256,9 @@ describe('submit transaction hardening', () => { signal = received return new Promise(() => {}) }, - commandImages, + commandAttachments, }) - shell.addImages([imageId]) + shell.addAttachments([imageId]) shell.submit() expect(signal?.aborted).toBe(false) expect(shell.dispose()).toEqual([imageId]) @@ -272,7 +272,7 @@ describe('submit transaction hardening', () => { actx: {} as Context, inputTriggers: () => ({ track, lexicon } as unknown as InputTriggerController), defaultSink: vi.fn(), - commandImages, + commandAttachments, }) shell.setDraft('@sr') const applied = shell.insertText('@src/', { start: 0, end: 3, draftRev: shell.snapshot.draftRev }, true) 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 ec7f586683..108385664f 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -17,7 +17,7 @@ import type { ClientSessionContext, SubmitEnvelope, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { - CommandClaim, PickOutcome, SubmitImageAttachment, SubmitOutcome, + CommandClaim, PickOutcome, SubmitAttachment, SubmitOutcome, } from '../src/client/contract/input.ts' import { bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, SlotTestRuntime, @@ -44,19 +44,19 @@ afterEach(cleanup) interface FakeCommand { name: string description: string - input?: { hint: string; images?: boolean } + input?: { hint: string; attachments?: boolean } } /** Decision-table source over an in-memory directory (menu/space/enter columns for leadingInput + execute). */ function commandSource( commands: FakeCommand[], - execute: (line: string, images?: readonly SubmitImageAttachment[]) => Promise, + execute: (line: string, images?: readonly SubmitAttachment[]) => Promise, ) { const resolve = (name: string): FakeCommand | undefined => commands.find(c => c.name === name) const leadingClaim = (desc: FakeCommand): CommandClaim => ({ token: `/${desc.name} `, ...(desc.input !== undefined ? { hint: desc.input.hint } : {}), - ...(desc.input?.images === true ? { images: true } : {}), + ...(desc.input?.attachments === true ? { attachments: true } : {}), submit: (args, _actx, images) => execute(`/${desc.name} ${args}`, images), }) const executed: string[] = [] @@ -105,10 +105,10 @@ function commandSource( const COMMANDS: FakeCommand[] = [ { name: 'goal', description: '设定目标', input: { hint: '目标内容' } }, { name: 'compact', description: '压缩上下文' }, - { name: 'vision', description: '识别图片', input: { hint: '想问什么', images: true } }, + { name: 'vision', description: '识别图片', input: { hint: '想问什么', attachments: true } }, ] -const PNG: SubmitImageAttachment = { mediaType: 'image/png', data: 'AA==' } +const PNG: SubmitAttachment = { type: 'image', mediaType: 'image/png', data: 'AA==' } /** Real scope bench: a Controller-owned Session scope + InputTriggerController + shell listeners. */ async function scopedBench(register?: (inputTriggers: InputTriggerService) => void) { @@ -125,7 +125,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo 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` } }) + const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink, commandAttachments: { serialize, release, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } }) // The hub's listener wiring, verbatim. actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined) actx.on('slash/input-insert-reference', req => shell.insertReference(req.reference, req.span) ? true : undefined) @@ -152,10 +152,12 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, - addImages: () => null, - removeImage: () => {}, + addFiles: () => null, + useFileUploads: bindSnapshotSelector(createSnapshotStore({})), + retryFileUpload: undefined, + removeAttachment: () => {}, // Every id resolves so the bar's registry prune never drops a test image. - draftImages: ids => ids.map(id => ({ + resolveDraftAttachments: ids => ids.map(id => ({ kind: 'image' as const, id, file: new File([Uint8Array.of(1)], `${id}.png`, { type: 'image/png' }), previewUrl: `blob:${id}`, @@ -272,16 +274,16 @@ describe('scenario D: execute-kind /compact', () => { describe('scenario: images ride an accepting command through the real pipeline', () => { it('adjudication reports the image count; the claim chain serializes, submits, and consumes', async () => { const b = await bench() - act(() => { b.shell.addImages(['img-1' as DraftAttachmentId]) }) + act(() => { b.shell.addAttachments(['img-1' as DraftAttachmentId]) }) act(() => { b.shell.setDraft('/vision 这张图是什么') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/vision 这张图是什么', [PNG]) }) // The envelope the controller forwarded to matchEnter carried the count. - expect(b.envelopes).toEqual([{ images: 1 }]) + expect(b.envelopes).toEqual([{ attachments: 1 }]) expect(b.serialize).toHaveBeenCalledWith(['img-1']) await vi.waitFor(() => { expect(b.shell.snapshot.draft).toBe('') }) expect(b.release).toHaveBeenCalledWith(['img-1']) - expect(b.shell.snapshot.imageIds).toEqual([]) + expect(b.shell.snapshot.attachmentIds).toEqual([]) expect(b.sink).not.toHaveBeenCalled() }) @@ -290,7 +292,7 @@ describe('scenario: images ride an accepting command through the real pipeline', act(() => { b.shell.setDraft('/goal 发布') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布', []) }) - expect(b.envelopes).toEqual([{ images: 0 }]) + expect(b.envelopes).toEqual([{ attachments: 0 }]) expect(b.serialize).not.toHaveBeenCalled() expect(b.release).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 492bc16151..98225cb44d 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -65,7 +65,7 @@ function liveSession(initial: SessionSnapshot) { } } -const INPUT_STATE: InputState = { draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] } +const INPUT_STATE: InputState = { draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] } const t: QueueDockProps['t'] = makeTranslate(zh, commonZh) @@ -124,13 +124,27 @@ describe('QueueDock', () => { placement: 'queued' as const, time: 1, text: '等待上传', - images: [{ previewUrl: 'blob:queue-preview', name: 'queue.png' }], + attachments: [ + { + type: 'image' as const, + value: { previewUrl: 'blob:queue-preview', name: 'queue.png' }, + }, + { + type: 'file' as const, + value: { + attachmentId: 'file-local' as never, + name: 'notes.txt', + bytes: 2447 * 1024 * 1024, + }, + }, + ], }], } const source = liveSession(pending) const view = render() expect(view.getByText('等待上传').closest('[data-submission-echo]')).not.toBeNull() expect(view.getByRole('img', { name: '排队消息图片' }).getAttribute('src')).toBe('blob:queue-preview') + expect(view.getByLabelText('排队文件 notes.txt').textContent).toContain('2.4GB') act(() => { source.push({ @@ -282,6 +296,35 @@ describe('QueueDock', () => { expect(container.querySelector('li')?.textContent).toBe('带图消息') }) + it('renders durable files and images in their original queue order', async () => { + const loadImage = vi.fn(() => Promise.resolve('blob:mixed')) + const mixed: QueuedMessage = { + id: iid('i-mixed'), messageId: 'message-i-mixed' as never, placement: 'queued', + content: [ + { + type: 'file', + attachment: { attachmentId: 'file-durable' as never, name: 'report.csv', bytes: 427 }, + }, + { + type: 'image', + attachment: { + attachmentId: 'image-durable' as never, + mediaType: 'image/png', bytes: 1, width: 1, height: 1, + }, + }, + ], + preview: '', text: null, + } + const snap = snapshotWith([mixed]) + const source = liveSession(snap) + const view = render() + await waitFor(() => { expect(view.container.querySelector('img')).not.toBeNull() }) + const group = view.getByLabelText('排队文件 report.csv').parentElement + expect(group?.children).toHaveLength(2) + expect(group?.children[0]?.getAttribute('aria-label')).toBe('排队文件 report.csv') + expect(group?.children[1]?.tagName).toBe('IMG') + }) + it('keeps the empty thumbnail placeholder when the image read fails', async () => { const loadImage = vi.fn(() => Promise.reject(new Error('read denied'))) const snap = snapshotWith([imageRow('i-broken', 'att-x')]) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 7ff7924133..ff46ef3680 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -6,15 +6,28 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { makeTranslate, RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import type { QueuedMessage } from '@deepseek-ai/dsh-api-session-controller/client' +import type { + BeginSubmissionInput, PendingSubmissionRetirement, QueuedMessage, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { ComposerBlockRegistry } from '../src/client/input/blocks.ts' import { InputHub } from '../src/client/input/hub.ts' -import { ConversationController, UnsupportedImageMediaTypeError } from '../src/client/service.ts' +import { ConversationController } from '../src/client/service.ts' import { zh } from '../src/client/locales.ts' -async function bench() { +async function bench(maxConcurrentFileUploads = 2) { const runtime = await SlotTestRuntime.create() - const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) + runtime.fileUpload.available = true + runtime.fileUpload.upload = (sessionId: SessionId, ...args: unknown[]) => { + const session = runtime.sessions.behavior(sessionId) as { + uploadFile?: (...input: unknown[]) => Promise + } + if (session.uploadFile === undefined) throw new Error('test file upload has no Session override') + return session.uploadFile(...args) + } + const prompt = vi.fn(( + _content?: unknown, _mode?: unknown, _signal?: AbortSignal, _rpcId?: string, + ) => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const loadOlder = vi.fn(() => Promise.resolve()) @@ -28,6 +41,7 @@ async function bench() { const fiber = runtime.ctx.plugin(ConversationController, { input: hub, blocks: new ComposerBlockRegistry(), + maxConcurrentFileUploads, }) await fiber.await() const root = runtime.ctx.get('conversation') as ConversationController @@ -87,13 +101,13 @@ describe('ConversationController', () => { const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1') const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) try { - const [attachment] = b.root.createDraftImages([ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([new Uint8Array(4)], 'a.png', { type: 'image/png' }), ]) if (attachment === undefined) throw new Error('draft attachment missing') - b.root.input.for(b.runtime.sessions.scope('s1')!).addImages([attachment.id]) + b.root.input.for(b.runtime.sessions.scope('s1')!).addAttachments([attachment.id]) await b.runtime.sessions.remove('s1') - expect(b.root.draftImages([attachment.id])).toEqual([]) + expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([]) expect(revoked).toHaveBeenCalledWith('blob:draft-1') } finally { created.mockRestore() @@ -107,15 +121,15 @@ describe('ConversationController', () => { const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:detached') const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) try { - const [attachment] = b.root.createDraftImages([ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1)], 'detached.png', { type: 'image/png' }), ]) if (attachment === undefined) throw new Error('draft attachment missing') - b.shell.addImages([attachment.id]) + b.shell.addAttachments([attachment.id]) b.shell.submit() - expect(b.shell.snapshot.imageIds).toEqual([]) + expect(b.shell.snapshot.attachmentIds).toEqual([]) await b.runtime.sessions.remove('s1') - expect(b.root.draftImages([attachment.id])).toEqual([]) + expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([]) expect(revoked).toHaveBeenCalledWith('blob:detached') } finally { created.mockRestore() @@ -124,18 +138,311 @@ describe('ConversationController', () => { await b.runtime.dispose() }) - it('validates every MIME type before allocating previews', async () => { + it('classifies image MIME drafts as images and every other file as an uploading file draft', async () => { const b = await bench() const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview') - expect(() => b.root.createDraftImages([ + const session = b.runtime.sessions.binding('s1')!.session + const uploadFile = vi.fn((_data: Blob | Uint8Array) => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'receipt-1' as never, + file: { attachmentId: 'sha256:1' as never, name: 'notes.pdf', bytes: 2 }, + }, + })) + ;(session as { uploadFile?: unknown }).uploadFile = uploadFile + const drafts = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }), - new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }), - ])).toThrow(UnsupportedImageMediaTypeError) - expect(created).not.toHaveBeenCalled() + new File([Uint8Array.of(2)], 'notes.pdf', { type: 'application/pdf' }), + ]) + expect(drafts.map(draft => draft.kind)).toEqual(['image', 'file']) + expect(created).toHaveBeenCalledTimes(1) + const fileDraft = drafts[1]! + expect(b.root.fileUploads.getSnapshot()[fileDraft.id]?.status).toBe('uploading') + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[fileDraft.id]?.status).toBe('ready') + }) + expect(uploadFile).toHaveBeenCalledOnce() + expect(uploadFile.mock.calls[0]?.[0]).toBe((fileDraft as { file: File }).file) created.mockRestore() await b.runtime.dispose() }) + it('serializes command files as staged receipts without reading their bytes again', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + ;(session as { uploadFile?: unknown }).uploadFile = vi.fn((_file: Blob | Uint8Array, name?: string) => + Promise.resolve({ + ok: true as const, + value: { + receiptId: `receipt-${name}` as never, + file: { attachmentId: `file-${name}` as never, name: name ?? 'file', bytes: 1 }, + }, + })) + const drafts = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(1)], 'one.txt', { type: 'text/plain' }), + new File([Uint8Array.of(2)], 'two.txt', { type: 'text/plain' }), + ]) + await vi.waitFor(() => { + expect(drafts.every(draft => b.root.fileUploads.getSnapshot()[draft.id]?.status === 'ready')).toBe(true) + }) + function RejectingFileReader(): never { + throw new Error('generic file bytes were reread') + } + vi.stubGlobal('FileReader', RejectingFileReader) + try { + await expect(b.root.serializeDraftAttachments(drafts.map(draft => draft.id))).resolves.toEqual({ + attachments: [ + { type: 'file', receiptId: 'receipt-one.txt' }, + { type: 'file', receiptId: 'receipt-two.txt' }, + ], + }) + } finally { + vi.unstubAllGlobals() + } + await b.runtime.dispose() + }) + + it('bounds upload Workers, advances on settlement, and skips a queued file removed by the user', async () => { + const b = await bench(2) + const session = b.runtime.sessions.binding('s1')!.session + type UploadResult = { + ok: true + value: { receiptId: never; file: { attachmentId: never; name: string; bytes: number } } + } + const gates = new Map void }>() + let active = 0 + let maxActive = 0 + const uploadFile = vi.fn((_file: Blob | Uint8Array, name?: string) => { + const fileName = name ?? 'unnamed' + const gate = Promise.withResolvers() + gates.set(fileName, gate) + active += 1 + maxActive = Math.max(maxActive, active) + return gate.promise.finally(() => { active -= 1 }) + }) + ;(session as { uploadFile?: unknown }).uploadFile = uploadFile + const drafts = b.root.createDrafts(session.sessionId, ['one', 'two', 'three', 'four', 'removed'].map(name => + new File([Uint8Array.of(1)], `${name}.txt`, { type: 'text/plain' }))) + + expect(uploadFile.mock.calls.map(call => call[1])).toEqual(['one.txt', 'two.txt']) + await expect(b.root.serializeDraftAttachments([drafts[2]!.id])) + .rejects.toThrow('one or more files have not finished uploading') + b.root.releaseDraftAttachment(drafts[4]!.id) + + const complete = (name: string) => gates.get(name)?.resolve({ + ok: true, + value: { + receiptId: `receipt-${name}` as never, + file: { attachmentId: `file-${name}` as never, name, bytes: 1 }, + }, + }) + complete('one.txt') + await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledTimes(3) }) + complete('two.txt') + await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledTimes(4) }) + complete('three.txt') + complete('four.txt') + await vi.waitFor(() => { + expect(drafts.slice(0, 4).every(draft => + b.root.fileUploads.getSnapshot()[draft.id]?.status === 'ready')).toBe(true) + }) + expect(maxActive).toBe(2) + expect(uploadFile.mock.calls.map(call => call[1])).not.toContain('removed.txt') + expect(b.root.fileUploads.getSnapshot()[drafts[4]!.id]).toBeUndefined() + await b.runtime.dispose() + }) + + it('keeps one upload alive and observable while another Session is open', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + const settled = Promise.withResolvers<{ + ok: true + value: { receiptId: never; file: { attachmentId: never; name: string; bytes: number } } + }>() + let reportProgress: ((progress: { loaded: number; total?: number }) => void) | undefined + const uploadFile = vi.fn(( + _file: Blob | Uint8Array, + _name?: string, + _signal?: AbortSignal, + onProgress?: (progress: { loaded: number; total?: number }) => void, + ) => { + reportProgress = onProgress + return settled.promise + }) + ;(session as { uploadFile?: unknown }).uploadFile = uploadFile + const [attachment] = b.root.createDrafts(session.sessionId, [ + new File([new Uint8Array(8)], 'background.bin', { type: 'application/octet-stream' }), + ]) + if (attachment === undefined) throw new Error('file draft missing') + b.shell.addAttachments([attachment.id]) + await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledOnce() }) + + await b.runtime.sessions.add({ + id: 's2', + session: { + prompt: b.prompt, updateQueue: b.updateQueue, cancel: b.cancel, loadOlder: b.loadOlder, + }, + }) + b.runtime.sessions.open('s2' as never) + reportProgress?.({ loaded: 3, total: 8 }) + expect(b.root.fileUploads.getSnapshot()[attachment.id]).toEqual({ + status: 'uploading', loaded: 3, total: 8, + }) + expect(b.shell.snapshot.attachmentIds).toEqual([attachment.id]) + + b.runtime.sessions.open('s1' as never) + settled.resolve({ + ok: true, + value: { + receiptId: 'background-receipt' as never, + file: { attachmentId: 'background-file' as never, name: 'background.bin', bytes: 8 }, + }, + }) + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[attachment.id]?.status).toBe('ready') + }) + expect(b.shell.snapshot.attachmentIds).toEqual([attachment.id]) + await b.runtime.dispose() + }) + + it('cancels a superseded upload and stages the carried draft on the target Session', async () => { + const b = await bench() + const source = b.runtime.sessions.binding('s1')!.session + let sourceSignal: AbortSignal | undefined + const sourceUpload = vi.fn((_data: Blob | Uint8Array, _name?: string, signal?: AbortSignal) => { + sourceSignal = signal + return new Promise((resolve) => { + signal?.addEventListener('abort', () => { + resolve({ ok: false, error: { message: 'aborted' } }) + }, { once: true }) + }) + }) + ;(source as { uploadFile?: unknown }).uploadFile = sourceUpload + const [attachment] = b.root.createDrafts(source.sessionId, [ + new File([Uint8Array.of(4)], 'carry.pdf', { type: 'application/pdf' }), + ]) + if (attachment === undefined) throw new Error('file draft missing') + await vi.waitFor(() => { expect(sourceUpload).toHaveBeenCalledOnce() }) + + const target = { + uploadFile: vi.fn(() => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'target-receipt' as never, + file: { attachmentId: 'target-file' as never, name: 'carry.pdf', bytes: 1 }, + }, + })), + } + await b.runtime.sessions.add({ id: 's2', session: target }) + b.root.rebindDraftFiles(b.runtime.sessions.binding('s2')!.session.sessionId, [attachment.id]) + + expect(sourceSignal?.aborted).toBe(true) + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[attachment.id]).toEqual({ + status: 'ready', receiptId: 'target-receipt', + file: { attachmentId: 'target-file', name: 'carry.pdf', bytes: 1 }, + }) + }) + await b.runtime.dispose() + }) + + it('cancels a file upload when its draft is removed', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + let uploadSignal: AbortSignal | undefined + const uploadFile = vi.fn((_data: Blob | Uint8Array, _name?: string, signal?: AbortSignal) => { + uploadSignal = signal + return new Promise((resolve) => { + signal?.addEventListener('abort', () => { + resolve({ ok: false, error: { message: 'aborted' } }) + }, { once: true }) + }) + }) + ;(session as { uploadFile?: unknown }).uploadFile = uploadFile + const [attachment] = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(5)], 'removed.pdf', { type: 'application/pdf' }), + ]) + if (attachment === undefined) throw new Error('file draft missing') + await vi.waitFor(() => { expect(uploadFile).toHaveBeenCalledOnce() }) + + b.root.releaseDraftAttachment(attachment.id) + + expect(uploadSignal?.aborted).toBe(true) + expect(b.root.fileUploads.getSnapshot()[attachment.id]).toBeUndefined() + await b.runtime.dispose() + }) + + it('keeps the accepted file draft until its rpcId appears in the Host queue', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + let retire: ((retirement: unknown) => void) | undefined + ;(session as unknown as { beginSubmission: (input: { onRetire?: (retirement: unknown) => void }) => unknown }) + .beginSubmission = (input) => { + retire = input.onRetire + return { requestId: 'file-rpc-id', abandon: vi.fn() } + } + ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'send-receipt' as never, + file: { attachmentId: 'send-file' as never, name: 'sent.pdf', bytes: 1 }, + }, + })) + const [attachment] = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(6)], 'sent.pdf', { type: 'application/pdf' }), + ]) + if (attachment === undefined) throw new Error('file draft missing') + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[attachment.id]?.status).toBe('ready') + }) + + const sending = b.root.sendSession(session, 'read', [attachment.id], 'queue') + await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) + + expect(b.prompt).toHaveBeenCalledWith([ + { type: 'file', receiptId: 'send-receipt' }, + { type: 'text', text: 'read' }, + ], 'queue', undefined, expect.any(String)) + expect(b.root.resolveDraftAttachments([attachment.id])).toHaveLength(1) + expect(b.prompt.mock.calls[0]?.[3]).toBe('file-rpc-id') + retire?.({ + reason: 'observed', + attachments: [{ attachmentId: 'send-file', name: 'sent.pdf', bytes: 1 }], + }) + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(b.root.resolveDraftAttachments([attachment.id])).toEqual([]) + await b.runtime.dispose() + }) + + it('awaits an aborted upload before disposing the service', async () => { + const b = await bench() + const session = b.runtime.sessions.binding('s1')!.session + let uploadSignal: AbortSignal | undefined + let finishUpload: (() => void) | undefined + ;(session as { uploadFile?: unknown }).uploadFile = vi.fn( + (_data: Uint8Array, _name?: string, signal?: AbortSignal) => { + uploadSignal = signal + return new Promise((resolve) => { + finishUpload = () => { resolve({ ok: false, error: { message: 'aborted' } }) } + }) + }, + ) + b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(7)], 'dispose.pdf', { type: 'application/pdf' }), + ]) + await vi.waitFor(() => { expect(uploadSignal).toBeDefined() }) + + let disposed = false + const disposal = b.fiber.dispose().then(() => { disposed = true }) + await vi.waitFor(() => { expect(uploadSignal?.aborted).toBe(true) }) + await Promise.resolve() + expect(disposed).toBe(false) + finishUpload?.() + await disposal + expect(disposed).toBe(true) + await b.runtime.dispose() + }) + it('fails loudly from the root scope, on an unbound session, or without Client Sessions', async () => { const b = await bench() await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/) @@ -147,6 +454,7 @@ describe('ConversationController', () => { await bare.plugin(ConversationController, { input: new InputHub(bare, makeTranslate(zh, {})), blocks: new ComposerBlockRegistry(), + maxConcurrentFileUploads: 2, }).await() const orphan = bare.get('conversation') as ConversationController await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/) @@ -157,9 +465,9 @@ describe('sendSession submission echo', () => { /** Bench with an observable beginSubmission on the session face. */ async function echoBench() { const b = await bench() - const retire: { onRetire?: ((retirement: unknown) => void) | undefined } = {} + const retire: { onRetire?: ((retirement: PendingSubmissionRetirement) => void) | undefined } = {} const abandon = vi.fn() - const beginSubmission = vi.fn((input: { onRetire?: (retirement: unknown) => void }) => { + const beginSubmission = vi.fn((input: BeginSubmissionInput) => { retire.onRetire = input.onRetire return { requestId: 'req-echo' as never, abandon } }) @@ -178,17 +486,18 @@ describe('sendSession submission echo', () => { it('registers the echo before serialization and prompts with its identity', async () => { const b = await echoBench() try { - const [attachment] = b.root.createDraftImages([ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1, 2, 3)], 'a.png', { type: 'image/png' }), ]) const session = b.runtime.sessions.binding('s1')!.session const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue') // Synchronous: the echo is registered before any encoding starts. - expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({ - mode: 'queue', - text: '带图', - images: [expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' })], - })) + const echo = b.beginSubmission.mock.calls[0]?.[0] + expect(echo?.mode).toBe('queue') + expect(echo?.text).toBe('带图') + expect(echo?.attachments).toHaveLength(1) + expect(echo?.attachments[0]?.type).toBe('image') + expect(echo?.attachments[0]?.value).toMatchObject({ previewUrl: 'blob:echo-1', name: 'a.png' }) expect(b.prompt).not.toHaveBeenCalled() await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) expect(b.prompt).toHaveBeenCalledWith( @@ -201,10 +510,10 @@ describe('sendSession submission echo', () => { 'req-echo', ) // The draft stays registered until the echo's observed retirement. - expect(b.root.draftImages([attachment!.id])).toHaveLength(1) + expect(b.root.resolveDraftAttachments([attachment!.id])).toHaveLength(1) b.retire.onRetire?.({ reason: 'observed', attachments: [] }) await expect(sending).resolves.toEqual({ kind: 'success' }) - expect(b.root.draftImages([attachment!.id])).toEqual([]) + expect(b.root.resolveDraftAttachments([attachment!.id])).toEqual([]) expect(b.revoked).toHaveBeenCalledWith('blob:echo-1') } finally { b.restore() @@ -212,6 +521,70 @@ describe('sendSession submission echo', () => { await b.runtime.dispose() }) + it('preserves mixed image/file selection order through echo, prompt, and observed retirement', async () => { + const b = await echoBench() + try { + const session = b.runtime.sessions.binding('s1')!.session + ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'mixed-file-receipt' as never, + file: { attachmentId: 'mixed-file' as never, name: 'notes.txt', bytes: 1 }, + }, + })) + const drafts = b.root.createDrafts(session.sessionId, [ + new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }), + new File([Uint8Array.of(2)], 'notes.txt', { type: 'text/plain' }), + new File([Uint8Array.of(3)], 'last.png', { type: 'image/png' }), + ]) + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[drafts[1]!.id]?.status).toBe('ready') + }) + const sending = b.root.sendSession(session, 'ordered', drafts.map(draft => draft.id), 'steer') + const echo = b.beginSubmission.mock.calls[0]?.[0] + expect(echo?.mode).toBe('steer') + expect(echo?.attachments.map(attachment => attachment.type === 'image' + ? { type: attachment.type, name: attachment.value.name } + : { type: attachment.type, value: attachment.value })).toEqual([ + { type: 'image', name: 'first.png' }, + { type: 'file', value: { attachmentId: 'mixed-file', name: 'notes.txt', bytes: 1 } }, + { type: 'image', name: 'last.png' }, + ]) + await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) + expect(b.prompt.mock.calls[0]?.[0]).toEqual([ + { type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'first.png' }, + { type: 'file', receiptId: 'mixed-file-receipt' }, + { type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'last.png' }, + { type: 'text', text: 'ordered' }, + ]) + b.retire.onRetire?.({ + reason: 'observed', + attachments: [ + { + attachmentId: 'image-first' as never, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + { attachmentId: 'mixed-file' as never, name: 'notes.txt', bytes: 1 }, + { + attachmentId: 'image-last' as never, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + ], + }) + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(b.root.resolveDraftAttachments(drafts.map(draft => draft.id))).toEqual([]) + } finally { + b.restore() + } + await b.runtime.dispose() + }) + it('passes each delivery mode before image serialization', async () => { const b = await echoBench() try { @@ -241,17 +614,23 @@ describe('sendSession submission echo', () => { const seedImageUrl = vi.fn(() => true) b.runtime.ctx.provide('uiConversation') b.runtime.ctx.set('uiConversation', { seedImageUrl }) - const [attachment] = b.root.createDraftImages([ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(9)], 'seeded.png', { type: 'image/png' }), ]) const session = b.runtime.sessions.binding('s1')!.session const sending = b.root.sendSession(session, '', [attachment!.id], 'queue') await vi.waitFor(() => { expect(b.prompt).toHaveBeenCalledOnce() }) - const ref = { attachmentId: 'att-1' } + const ref = { + attachmentId: 'att-1' as never, + mediaType: 'image/png' as const, + bytes: 1, + width: 1, + height: 1, + } b.retire.onRetire?.({ reason: 'observed', attachments: [ref] }) await expect(sending).resolves.toEqual({ kind: 'success' }) expect(seedImageUrl).toHaveBeenCalledWith('s1', ref, 'blob:echo-1') - expect(b.root.draftImages([attachment!.id])).toEqual([]) + expect(b.root.resolveDraftAttachments([attachment!.id])).toEqual([]) expect(b.revoked).not.toHaveBeenCalled() // Failed retirement keeps nothing to do; a second retire of released ids is a no-op. b.retire.onRetire?.({ reason: 'observed', attachments: [ref] }) @@ -261,20 +640,31 @@ describe('sendSession submission echo', () => { await b.runtime.dispose() }) - it('keeps the drafts registered when the echo retires as failed (composer restore path)', async () => { + it('keeps mixed image and file drafts when the echo retires as failed', async () => { const b = await echoBench() try { + const session = b.runtime.sessions.binding('s1')!.session + ;(session as { uploadFile?: unknown }).uploadFile = vi.fn(() => Promise.resolve({ + ok: true as const, + value: { + receiptId: 'kept-file-receipt' as never, + file: { attachmentId: 'kept-file' as never, name: 'kept.txt', bytes: 1 }, + }, + })) b.prompt.mockResolvedValueOnce({ ok: false, error: new RemoteError('session/attachment-invalid', 'nope', { reason: 'nope' }), } as never) - const [attachment] = b.root.createDraftImages([ + const attachments = b.root.createDrafts(session.sessionId, [ new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }), + new File([Uint8Array.of(8)], 'kept.txt', { type: 'text/plain' }), ]) - const session = b.runtime.sessions.binding('s1')!.session - await expect(b.root.sendSession(session, '失败', [attachment!.id], 'queue')) + await vi.waitFor(() => { + expect(b.root.fileUploads.getSnapshot()[attachments[1]!.id]?.status).toBe('ready') + }) + await expect(b.root.sendSession(session, '失败', attachments.map(attachment => attachment.id), 'queue')) .resolves.toEqual({ kind: 'error' }) b.retire.onRetire?.({ reason: 'failed' }) - expect(b.root.draftImages([attachment!.id])).toHaveLength(1) + expect(b.root.resolveDraftAttachments(attachments.map(attachment => attachment.id))).toHaveLength(2) expect(b.revoked).not.toHaveBeenCalled() } finally { b.restore() @@ -294,7 +684,7 @@ describe('sendSession submission echo', () => { } vi.stubGlobal('FileReader', FailingReader) try { - const [attachment] = b.root.createDraftImages([ + const [attachment] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1)], 'broken.png', { type: 'image/png' }), ]) const session = b.runtime.sessions.binding('s1')!.session @@ -374,15 +764,17 @@ describe('draft image dimension probe', () => { } vi.stubGlobal('Image', InstantImage) try { - const [probed] = b.root.createDraftImages([ + const [probed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(1)], 'probed.png', { type: 'image/png' }), ]) expect(probed).toMatchObject({ width: 640, height: 480 }) vi.stubGlobal('Image', undefined) - const [unprobed] = b.root.createDraftImages([ + const [unprobed] = b.root.createDrafts(b.runtime.sessions.binding('s1')!.session.sessionId, [ new File([Uint8Array.of(2)], 'unprobed.png', { type: 'image/png' }), ]) - expect(unprobed?.width).toBeUndefined() + expect(unprobed?.kind).toBe('image') + if (unprobed?.kind !== 'image') throw new Error('image draft missing') + expect(unprobed.width).toBeUndefined() } finally { vi.unstubAllGlobals() created.mockRestore() diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index b431a51ff8..2c20f198c5 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -40,7 +40,7 @@ Range.prototype.getBoundingClientRect = () => ({ function fakeWiring() { const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) - const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandImages: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} images-unsupported` } }) + const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandAttachments: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } }) return { wiring: shell, sink, shell } } @@ -249,9 +249,11 @@ function mount( useInput={useInput} inputActions={inputActions} keyboard={wiring} - addImages={() => null} - removeImage={() => {}} - draftImages={() => []} + addFiles={() => null} + useFileUploads={bindSnapshotSelector(createSnapshotStore({}))} + retryFileUpload={undefined} + removeAttachment={() => {}} + resolveDraftAttachments={() => []} resolveSubmitMode={() => 'queue'} toggleCommandMenu={vi.fn()} useNotices={bindSnapshotSelector(wiring.notices)} diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index af1e72f47e..73d16d0235 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -170,9 +170,9 @@ describe('submit-machine: adjudication outcomes', () => { describe('submit-machine: claimed lifecycle', () => { it('the claim event enters claimed and snapshots hint and images bits', () => { const m = new SubmitMachine() - m.dispatch({ type: 'claim', claim: { ...claimOf('goal', 'set a goal'), images: true } }) + m.dispatch({ type: 'claim', claim: { ...claimOf('goal', 'set a goal'), attachments: true } }) expect(m.state.phase).toBe('claimed') - expect(m.state.claim).toMatchObject({ token: '/goal ', hint: 'set a goal', images: true }) + expect(m.state.claim).toMatchObject({ token: '/goal ', hint: 'set a goal', attachments: true }) }) it('claimed overwrites in place — no stack', () => { @@ -347,6 +347,14 @@ describe('decorations: scanTextRefs', () => { expect(scanTextRefs('/research @goal', lexicon)).toEqual([]) }) + it('a "/" token continued by a path never matches, even when the name is on the lexicon', () => { + expect(scanTextRefs('/goal/x /goal/ /goal.md', lexicon)).toEqual([]) + }) + + it('a "/" token glued to punctuation is not a reference: the host gesture is whitespace-bounded', () => { + expect(scanTextRefs('/goal。 then /goal, now', lexicon)).toEqual([]) + }) + it('word boundary: a trigger glued to text never matches', () => { expect(scanTextRefs('x/goal y@research', lexicon)).toEqual([]) }) diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index ae8f4dfde9..3c1683305b 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail and clickable final-response file references for Web", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.module.css b/packages/client/ui-deliverables/src/client/ProducedFiles.module.css index d89862c36e..29310fe389 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.module.css +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.module.css @@ -17,7 +17,6 @@ } .lane { - --produced-file-chip-max: 96px; --produced-file-gap: 8px; grid-column: 2; @@ -37,28 +36,53 @@ overflow: hidden; } -/* File names yield space to one another while the remainder stays readable. */ +/* File names render at natural width and only shrink (with ellipsis) when + the row truly runs out of space; the container-query bands still budget + 96px per chip when deciding how many chips to show. Same link language as + markdown anchors and file mentions: link-blue at rest with no underline, + dotted underline on hover; the leading glyph names the file's category + and rides the link color. */ .file { box-sizing: border-box; + display: inline-flex; + align-items: center; + gap: 5px; flex: 0 1 auto; min-width: 0; - max-width: var(--produced-file-chip-max); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; margin: 0; - padding: 0 8px; + padding: 0; border: none; - border-radius: 6px; - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-secondary); + border-radius: 4px; + background: none; + color: var(--dsw-alias-link); font: inherit; + font-weight: 500; + text-decoration: none; cursor: pointer; } -.file:hover { - color: var(--dsw-alias-label-primary); - text-decoration: underline; +/* Flex centering tracks box centers, but the 22px text box carries its + glyphs below center (the baseline sits low in the leading); the nudge + drops the glyph onto the text's visual center. */ +.fileIcon { + flex: none; + width: 1.1em; + height: 1.1em; + position: relative; + top: 1.2px; +} + +.fileName { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file:hover, +.file:focus-visible { + text-decoration: underline dotted; + text-underline-offset: 3px; } .file:focus-visible, @@ -84,7 +108,7 @@ } .lane:has(.more[data-shown='6']) > .showFolder { - display: block; + display: flex; } /* Each band budgets 96px per chip, 8px gaps, and 64px for the remainder. */ @@ -103,7 +127,7 @@ } .lane:has(.more[data-shown='5']) > .showFolder { - display: block; + display: flex; } } @@ -122,7 +146,7 @@ } .lane:has(.more[data-shown='4']) > .showFolder { - display: block; + display: flex; } } @@ -141,7 +165,7 @@ } .lane:has(.more[data-shown='3']) > .showFolder { - display: block; + display: flex; } } @@ -160,7 +184,7 @@ } .lane:has(.more[data-shown='2']) > .showFolder { - display: block; + display: flex; } } @@ -179,12 +203,14 @@ } .lane:has(.more[data-shown='1']) > .showFolder { - display: block; + display: flex; } } .showFolder { justify-self: start; + align-items: center; + gap: 5px; margin: 0; padding: 0 2px; border: none; diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index d54dc729fb..035bca4900 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import { LinkIcon, classifyLinkPath } from '@deepseek-ai/dsh-client-ui-primitives' import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { basename } from './turn-deliverables.ts' @@ -57,7 +58,8 @@ export function ProducedFiles({ aria-label={t('produced.open', { name: path })} onClick={() => { openFile(path) }} > - {basename(path)} + + {basename(path)} ))} {shown.map((_, index) => { @@ -77,6 +79,7 @@ export function ProducedFiles({ className={css.showFolder} onClick={() => { openFile('.') }} > + {t('produced.showInFolder')} )} diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 1779a0c4c8..c230af8586 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 4e3029493a..71841dee4f 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 096dec8b0d..1290b0416d 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/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-goal/README.md -README.md: 1a2c65759f5983fbece83f538ee9ee09902dd020 -README.zh.md: feebeaff0b5e7e9258f15637d0a78a897e0f24c1 +README.md: 8556575b00e1d3073f251d83f3dee0bafb4c8969 +README.zh.md: 22d0417bc2d37719150ec46aad849ad2bce8b460 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 1a2c65759f..8556575b00 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation` and the goal domain package; the s ### The command-input bubble -Each durable `/goal` run projects as a right-aligned monospace user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. +Each durable `/goal` run projects as a right-aligned user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row; the leading `/goal` token renders as a command reference chip in the code face through ui-primitives `projectUserText`, and the objective stays plain body text. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. ### Failures diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index feebeaff0b..22d0417bc2 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 指令输入气泡 -每条持久的 `/goal` 运行都投影为一个右对齐的等宽用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 +每条持久的 `/goal` 运行都投影为一个右对齐的用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前;开头的 `/goal` token 经 ui-primitives 的 `projectUserText` 以等宽代码字体渲染为指令引用 chip,目标文本保持正文字体。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 ### 失败 diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index ee50245f4e..bc3ad62e70 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.module.css b/packages/client/ui-goal/src/client/GoalCommandInputView.module.css index f403730eff..42fff6430e 100644 --- a/packages/client/ui-goal/src/client/GoalCommandInputView.module.css +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.module.css @@ -23,10 +23,10 @@ border-radius: 22px; background: var(--dsw-specific-bubble); color: var(--dsw-alias-label-primary); - font: var(--dsw-font-markdown-code); - /* The command echo is a user message, not dense code-in-prose: keep the - code family from the token but read at the body size, riding the - Settings font-size axis like the user bubble (14/22 at the default). */ + /* The command echo is a user message: it inherits the body face like the + user bubble and rides the same Settings font-size axis (14/22 at the + default); the `/goal` command chip, set in the code face by the shared + projection, is what marks the line as a command. */ font-size: var(--dsh-content-font-size, 14px); line-height: calc(22px + var(--dsh-content-font-delta, 0px)); white-space: pre-wrap; diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx index 6d2345042b..8d9742d45d 100644 --- a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx @@ -1,18 +1,28 @@ import { memo } from 'react' -import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { projectUserText } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { GoalCommandInputData } from './goal-command-input.ts' +import { GOAL_COMMAND, type GoalCommandInputData } from './goal-command-input.ts' import css from './GoalCommandInputView.module.css' type GoalCommandInputViewProps = PropsRuntime<'conversation.chat.node', 'command-input'> & PropsLocale<'goal'> -/** Right-aligned `/goal` input bubble without ordinary message actions. */ +/** + * Right-aligned `/goal` input bubble without ordinary message actions. The + * echoed line decorates its leading `/goal` token as a command chip — the run + * this Node projects is the fact that that token was a command — and keeps + * the objective, `/goal` mentions included, as plain text. + */ export const GoalCommandInputView = memo(function GoalCommandInputView({ node, t, }: GoalCommandInputViewProps) { const data: GoalCommandInputData = node.data + // Only the leading token is the executed command; the rest of the line is + // the objective, where a further `/goal` is prose. + const split = data.text.search(/\s/u) + const head = split === -1 ? data.text : data.text.slice(0, split) + const rest = split === -1 ? '' : data.text.slice(split) return (
- + {projectUserText(head, [], [GOAL_COMMAND], 'command')} + {rest !== '' && projectUserText(rest, [])}
diff --git a/packages/client/ui-goal/src/client/goal-command-input.ts b/packages/client/ui-goal/src/client/goal-command-input.ts index 526dc0958d..742959a349 100644 --- a/packages/client/ui-goal/src/client/goal-command-input.ts +++ b/packages/client/ui-goal/src/client/goal-command-input.ts @@ -5,6 +5,9 @@ import type { ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-ui-conversation/client' +/** The command name whose runs this projection owns. */ +export const GOAL_COMMAND = 'goal' + /** Goal-owned human command input projected independently of model messages. */ export interface GoalCommandInputData { readonly commandId: CommandId @@ -36,7 +39,7 @@ export function goalCommandText(event: SessionEvent<'command/run'>): string { export const goalCommandInputDefinition: ConversationNodeDefinition = { kind: 'goal-command-input', target: 'chat', - match: event => event.type === 'command/run' && event.data.name === 'goal' + match: event => event.type === 'command/run' && event.data.name === GOAL_COMMAND ? { id: String(event.data.commandId), role: 'start' } : null, start: (_context, match) => { diff --git a/packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts b/packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts new file mode 100644 index 0000000000..41b8fcaa4f --- /dev/null +++ b/packages/client/ui-goal/tests/goal-command-input-styles.client.spec.ts @@ -0,0 +1,31 @@ +/** + * The command-input bubble's typography as CSS text. jsdom has no layout, so + * this reads the declarations that make the bubble share the user bubble's + * face and size axis: the shared projection sets the `/goal` chip in the code + * face, so the bubble itself must not pin a different family. + */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const css = readFileSync( + fileURLToPath(new URL('../src/client/GoalCommandInputView.module.css', import.meta.url)), + 'utf8', +).replace(/\/\*[\s\S]*?\*\//g, ' ') + +function declarations(selector: string): string[] { + const rule = new RegExp(`(?:^|\\})\\s*${selector.replace(/[.[\]():*+^$\\]/g, '\\$&')}\\s*\\{([^{}]*)\\}`).exec(css) + if (rule === null) throw new Error(`GoalCommandInputView.module.css has no \`${selector}\` rule`) + return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean) +} + +describe('GoalCommandInputView.module.css typography', () => { + it('types the command bubble like the user bubble: no family override, same size axis', () => { + const bubble = declarations('.bubble') + expect(bubble.some(declaration => /^font(-family)?:/.test(declaration))).toBe(false) + expect(bubble).toEqual(expect.arrayContaining([ + 'font-size: var(--dsh-content-font-size, 14px)', + 'line-height: calc(22px + var(--dsh-content-font-delta, 0px))', + ])) + }) +}) diff --git a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx index d149cd7f4f..ad03572d4c 100644 --- a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx +++ b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx @@ -133,5 +133,39 @@ describe('goal command input projection', () => { expect(bubble.textContent).toBe('/goal ship it') expect(within(bubble).queryByRole('button')).toBeNull() + // The executed command token reads as a reference chip; the objective stays plain text. + const chips = [...bubble.querySelectorAll('[data-ref-chip]')] + expect(chips.map(chip => [chip.getAttribute('data-ref-chip'), chip.textContent])).toEqual([['command', '/goal']]) + }) + + it('renders a bare /goal as one command chip and nothing else', () => { + const t = makeTranslate(zh, commonZh) + const props = { + node: { + key: 'goal-command-input:bare', + data: { commandId: 'command-goal', text: '/goal', time: 1_700_000_000_000 }, + }, + t, + } as unknown as Parameters[0] + const view = render() + const bubble = view.getByRole('group', { name: '指令输入' }) + expect(bubble.textContent).toBe('/goal') + expect([...bubble.querySelectorAll('[data-ref-chip]')].map(chip => chip.textContent)).toEqual(['/goal']) + }) + + it('decorates only the leading command token: a /goal inside the objective stays plain', () => { + const t = makeTranslate(zh, commonZh) + const props = { + node: { + key: 'goal-command-input:two', + data: { commandId: 'command-goal', text: '/goal 检查 /goal 的语法', time: 1_700_000_000_000 }, + }, + t, + } as unknown as Parameters[0] + const view = render() + const bubble = view.getByRole('group', { name: '指令输入' }) + expect(bubble.textContent).toBe('/goal 检查 /goal 的语法') + const chips = [...bubble.querySelectorAll('[data-ref-chip]')] + expect(chips.map(chip => chip.textContent)).toEqual(['/goal']) }) }) diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index aab8fad6da..d768577314 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/src/client/index.ts b/packages/client/ui-input-trigger/src/client/index.ts index 4860b5ce9d..9141210c0e 100644 --- a/packages/client/ui-input-trigger/src/client/index.ts +++ b/packages/client/ui-input-trigger/src/client/index.ts @@ -25,7 +25,7 @@ export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CandidateRequest, ClientSessionContext, CommandClaim, ConsumeTokenRequest, HeaderRequest, InsertReferenceRequest, PickOutcome, PickVia, ReferenceCodec, ReferenceInsert, InputTriggerCandidate, InputTriggerCrumb, InputTriggerPick, - InputTriggerSource, SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, TokenSpan, TriggerChar, + InputTriggerSource, SubmitAttachment, SubmitEnvelope, SubmitOutcome, TokenSpan, TriggerChar, TriggerGuard, TriggerPosition, } from '../types.ts' export type { DetectTrigger, ExactMatch, MenuEvent, MenuReduce, MenuState, TriggerHit } from '../core/contract.ts' diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index bd862a71a1..acbe2a65a3 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -14,7 +14,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, - InsertReferenceRequest, InsertTextRequest, PickOutcome, ReferenceInsert, SubmitImageAttachment, + InsertReferenceRequest, InsertTextRequest, PickOutcome, ReferenceInsert, SubmitAttachment, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -95,8 +95,8 @@ export interface HeaderRequest { * presence to accept or refuse a whole submission. */ export interface SubmitEnvelope { - /** Number of image attachments accompanying the draft. */ - readonly images: number + /** Number of attachments accompanying the draft. */ + readonly attachments: number } /** Candidate request passed to a source. The signal is superseded on query change / menu close. */ 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 0d081edffc..a1243a7513 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -1074,7 +1074,7 @@ describe('adjudicate', () => { return Promise.resolve('handled') }), ]) - const result = await controller.adjudicate('/goal make it fast', new AbortController().signal, { images: 0 }) + const result = await controller.adjudicate('/goal make it fast', new AbortController().signal, { attachments: 0 }) expect(result).toEqual({ claim }) expect(calls).toEqual(['first:/goal make it fast', 'second:/goal make it fast']) }) @@ -1085,7 +1085,7 @@ describe('adjudicate', () => { enterSource('@', 'subagent', atHook), enterSource('/', 'command', () => Promise.resolve(undefined)), ]) - await expect(controller.adjudicate('/xyz', new AbortController().signal, { images: 0 })).resolves.toBeUndefined() + await expect(controller.adjudicate('/xyz', new AbortController().signal, { attachments: 0 })).resolves.toBeUndefined() expect(atHook).not.toHaveBeenCalled() }) @@ -1101,7 +1101,7 @@ describe('adjudicate', () => { return Promise.resolve('handled') }), ]) - const envelope = { images: 2 } + const envelope = { attachments: 2 } await controller.adjudicate('/goal', new AbortController().signal, envelope) expect(envelopes).toEqual([envelope, envelope]) expect(envelopes[0]).toBe(envelope) @@ -1112,7 +1112,7 @@ describe('adjudicate', () => { enterSource('/', 'command', () => Promise.reject(new Error('warmup failed'))), enterSource('/', 'late', () => Promise.resolve('handled')), ]) - await expect(controller.adjudicate('/goal x', new AbortController().signal, { images: 0 })) + await expect(controller.adjudicate('/goal x', new AbortController().signal, { attachments: 0 })) .rejects.toThrow('warmup failed') }) @@ -1121,7 +1121,7 @@ describe('adjudicate', () => { const { controller } = controllerBench([enterSource('/', 'command', hook)]) const abort = new AbortController() abort.abort(new Error('attempt released')) - await expect(controller.adjudicate('/goal', abort.signal, { images: 0 })).rejects.toThrow('attempt released') + await expect(controller.adjudicate('/goal', abort.signal, { attachments: 0 })).rejects.toThrow('attempt released') expect(hook).not.toHaveBeenCalled() }) }) diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 2189543418..035d243e22 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index dcc1512f79..51d2ee2c0e 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 561f19f1cc..042eff33d2 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 534e126c21..36d7da89ba 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", "description": "Model selection over the shared model catalog, Session projection, and session.selectModel", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 7e20cdb8db..20ebdac995 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-permission-presets", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index bab20c585d..f7676ccb7a 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 9b7c3a7285..ccd55950f1 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: 78cb6caec665687feb1c18c65e1afe47c6e01ea1 -README.zh.md: 1450de83f88e4e33be4616ebd1f70588dd19e8d2 +README.md: ce18a574e0058b3e7e6f50e91fa2671af6563faa +README.zh.md: 527f91a6b932ea05d8d6262058eba37de67c65ce diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 78cb6caec6..ce18a574e0 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -29,11 +29,11 @@ Compose feature UI from these atoms whenever the web client needs a standard con ### Controls and icons -`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionIndicator`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. +`Button`, `Pill`, `Input`, `Menu`, `Modal`, `Tooltip`, `DisclosureRow`, `StateDot`, `HoverCard`, `Toast`, `ConnectionIndicator`, `RiskConfirmation`, and the `OnboardingSurface` first-run takeover cover the common interaction shapes. The `ic_ds_*` icon set and `FishLogo`/`BrandWordmark` marks fill brand and inline-icon slots. `LinkIcon` draws the leading category glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, all riding `currentColor` — and `classifyLinkPath` derives a file path's category from its extension. `ConnectionIndicator` renders a warning-colored disconnected action, a connecting label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Every state reserves the widest supplied label and uses fixed icon and text columns, so copy changes do not move or resize the control. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order ([ranking decision](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)). ### Rendering agent output -`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `MessageText` remains the literal-text primitive for user-authored content. +`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax ([incremental renderer](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md), [streaming fence highlighting](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)). `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. ### Localizing copy diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 1450de83f8..527f91a6b9 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -29,11 +29,11 @@ kind: "package-library" ### 控件与图标 -`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionIndicator`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 +`Button`、`Pill`、`Input`、`Menu`、`Modal`、`Tooltip`、`DisclosureRow`、`StateDot`、`HoverCard`、`Toast`、`ConnectionIndicator`、`RiskConfirmation` 与首次运行接管层 `OnboardingSurface` 覆盖常见的交互形态。`ic_ds_*` 图标集与 `FishLogo`/`BrandWordmark` 标记填充品牌与行内图标 slot。`LinkIcon` 为可点击产物链接绘制前置分类图形——地球、文件夹、代码、图片、文档或纸张,全部随 `currentColor`——`classifyLinkPath` 按扩展名推导文件路径的类别。`ConnectionIndicator` 可渲染警告色的断联操作、以独立于 retry 时序的 500ms 节奏推进一至三个点的连接中状态,或成功色的恢复状态。所有状态都为最长的输入 label 预留空间,并使用固定的图标列和文字列,因此文案变化不会移动控件或改变其宽度。它的 owner 提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。 `Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。 `rankByName` 是 `/` 菜单命令源与 skill 源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序([排名决策](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md))。 ### 渲染 agent 输出 -`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`MessageText` 仍是用户创作内容的字面文本原语。 +`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM([增量渲染器](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)、[流式 fence 高亮](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 ### 本地化文案 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 34acb87346..8690e9ad84 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/src/DocumentFileIcon.tsx b/packages/client/ui-primitives/src/DocumentFileIcon.tsx new file mode 100644 index 0000000000..743a01a3ff --- /dev/null +++ b/packages/client/ui-primitives/src/DocumentFileIcon.tsx @@ -0,0 +1,41 @@ +import { useId } from 'react' + +/** + * Render the DeepSeek Web document glyph used by generic-file cards. + * @param props - optional CSS class for sizing and placement. + * @returns a decorative document SVG with an instance-safe gradient id. + */ +export function DocumentFileIcon({ className }: { readonly className?: string | undefined }) { + const gradientId = useId() + return ( + + + + + + + + + + + + ) +} diff --git a/packages/client/ui-primitives/src/LinkIcon.tsx b/packages/client/ui-primitives/src/LinkIcon.tsx new file mode 100644 index 0000000000..093e8e4cad --- /dev/null +++ b/packages/client/ui-primitives/src/LinkIcon.tsx @@ -0,0 +1,154 @@ +/** + * Leading category glyph for clickable artifact links (anchors, file + * mentions, produced-file chips). One component keyed by link category; + * every glyph renders fill="currentColor" and so rides the link's own + * color. Glyphs stay private to this module: the public surface is the + * category vocabulary, not the individual glyph components, so consumers + * cannot compose a glyph outside a link. Design sources: + * ic_globe_language_outline_20, ic_code_outline_20, ic_folder_outline_20, + * ic_photo_outline_20, ic_paper_doc_outline_20, ic_paper_outline_20. + */ +import type { ReactNode } from 'react' +import type { IconProps } from './icons/props.ts' + +/** + * Link categories with distinct leading glyphs. `url` and `folder` are + * destination categories the consumer states directly; the rest are file + * categories `classifyLinkPath` derives from a path. Code, web, and data + * files share the `code` glyph by design. + */ +export type LinkIconKind = 'url' | 'folder' | 'code' | 'image' | 'document' | 'other' + +/** Props for {@link LinkIcon}: the category plus the shared icon sizing seat. */ +export interface LinkIconProps extends IconProps { + kind: LinkIconKind +} + +/** Code, web, and data extensions: all three categories share the code glyph. */ +const CODE_EXTENSIONS = new Set([ + 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'cts', 'mts', 'css', 'scss', 'sass', 'less', + 'html', 'htm', 'vue', 'svelte', 'astro', 'json', 'jsonc', 'json5', 'yaml', 'yml', + 'toml', 'xml', 'ini', 'env', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', + 'py', 'pyi', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'c', 'cc', 'cpp', 'cxx', + 'h', 'hh', 'hpp', 'cs', 'php', 'swift', 'sql', 'csv', 'tsv', 'proto', 'graphql', + 'gql', 'lua', 'r', 'pl', 'scala', 'clj', 'cljs', 'ex', 'exs', 'erl', 'hs', 'dart', +]) + +const IMAGE_EXTENSIONS = new Set([ + 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'avif', 'bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', +]) + +const DOCUMENT_EXTENSIONS = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']) + +/** + * Derive a file path's link-icon category from its extension. Unknown and + * missing extensions fall to `other` (the plain-paper glyph). + * @param path - File path as the producing tool spelled it (either separator). + * @returns The file's glyph category; never `url` or `folder`. + */ +export function classifyLinkPath(path: string): LinkIconKind { + const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) + const dot = name.lastIndexOf('.') + if (dot < 0) return 'other' + const extension = name.slice(dot + 1).toLowerCase() + if (CODE_EXTENSIONS.has(extension)) return 'code' + if (IMAGE_EXTENSIONS.has(extension)) return 'image' + return DOCUMENT_EXTENSIONS.has(extension) ? 'document' : 'other' +} + +const GlobeGlyph = ({ size, className }: IconProps) => ( + + + +) + +const CodeGlyph = ({ size, className }: IconProps) => ( + + + + + +) + +const FolderGlyph = ({ size, className }: IconProps) => ( + + + + +) + +const PhotoGlyph = ({ size, className }: IconProps) => ( + + + + +) + +const PaperGlyph = ({ size, className }: IconProps) => ( + + + +) + +const PaperDocGlyph = ({ size, className }: IconProps) => ( + + + + + +) + +/** Local exhaustiveness helper — this package does not depend on `dsh-llm`. */ +/* v8 ignore next 3 -- closed-union backstop; only reached if a kind is forged */ +function assertNever(value: never): never { + throw new Error(`unreachable link icon kind: ${String(value)}`) +} + +/** + * Render the leading glyph for one clickable artifact link. + * @param props - The link category, optional size (default 14px — the inline + * link text size these glyphs sit beside), and optional CSS class. + * @returns The category's SVG glyph, riding currentColor. + */ +export function LinkIcon({ kind, size = 14, className }: LinkIconProps): ReactNode { + switch (kind) { + case 'url': return + case 'folder': return + case 'code': return + case 'image': return + case 'document': return + case 'other': return + /* v8 ignore next -- closed-union backstop; only reached if a kind is forged */ + default: return assertNever(kind) + } +} diff --git a/packages/client/ui-primitives/src/WebBlock.module.css b/packages/client/ui-primitives/src/WebBlock.module.css index 3f04723e60..514403ab28 100644 --- a/packages/client/ui-primitives/src/WebBlock.module.css +++ b/packages/client/ui-primitives/src/WebBlock.module.css @@ -52,14 +52,27 @@ } .sourceLink { - color: var(--dsw-alias-state-business-primary); + color: var(--dsw-alias-link); font-size: 14px; + font-weight: 500; line-height: 20px; word-break: break-word; + text-decoration: none; } -.sourceLink:hover { - text-decoration: underline; +/* Leading URL glyph inside source and fetch links; rides the link color. + 1.1em follows each link's own font size. */ +.linkIcon { + width: 1.1em; + height: 1.1em; + vertical-align: -0.25em; + margin-right: 5px; +} + +.sourceLink:hover, +.sourceLink:focus-visible { + text-decoration: underline dotted; + text-underline-offset: 3px; } .snippet { @@ -95,15 +108,19 @@ } .fetchUrl { - color: var(--dsw-alias-state-business-primary); + color: var(--dsw-alias-link); font-family: var(--ds-font-family-code); font-size: 13px; + font-weight: 500; line-height: 19px; word-break: break-all; + text-decoration: none; } -.fetchUrl:hover { - text-decoration: underline; +.fetchUrl:hover, +.fetchUrl:focus-visible { + text-decoration: underline dotted; + text-underline-offset: 3px; } .fetchMeta { diff --git a/packages/client/ui-primitives/src/WebBlock.tsx b/packages/client/ui-primitives/src/WebBlock.tsx index c070151a88..93cd334c50 100644 --- a/packages/client/ui-primitives/src/WebBlock.tsx +++ b/packages/client/ui-primitives/src/WebBlock.tsx @@ -1,5 +1,6 @@ import clsx from 'clsx' import { MarkdownText, type MarkdownLabels } from './markdown/MarkdownText.tsx' +import { LinkIcon } from './LinkIcon.tsx' import css from './WebBlock.module.css' /** @@ -111,6 +112,7 @@ function SafeLink({ url, label, className }: { url: string; label: string; class if (href === undefined) return {label} return ( + {label} ) diff --git a/packages/client/ui-primitives/src/file-size.ts b/packages/client/ui-primitives/src/file-size.ts new file mode 100644 index 0000000000..16d5ecacf9 --- /dev/null +++ b/packages/client/ui-primitives/src/file-size.ts @@ -0,0 +1,16 @@ +/** Compact human-readable byte counts shared by attachment presenters. @module @deepseek-ai/dsh-client-ui-primitives/file-size */ + +/** + * Byte count as compact user-facing size text (`312B`, `4.2KB`, `1.5MB`, `2.4GB`). + * @param bytes - exact byte count. + * @returns whole-unit text with one decimal below ten of the chosen unit. + */ +export function fileSizeText(bytes: number): string { + if (bytes < 1024) return `${bytes}B` + const kb = bytes / 1024 + if (kb < 1024) return `${kb < 10 ? kb.toFixed(1) : Math.round(kb)}KB` + const mb = kb / 1024 + if (mb < 1024) return `${mb < 10 ? mb.toFixed(1) : Math.round(mb)}MB` + const gb = mb / 1024 + return `${gb < 10 ? gb.toFixed(1) : Math.round(gb)}GB` +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index c5f88e5eee..1f33f81a98 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -28,12 +28,17 @@ export { BrandWordmark } from './BrandWordmark.tsx' export type { BrandWordmarkProps } from './BrandWordmark.tsx' export { ReferenceIcon } from './ReferenceIcon.tsx' export type { ReferenceIconKind, ReferenceIconProps } from './ReferenceIcon.tsx' +export { LinkIcon, classifyLinkPath } from './LinkIcon.tsx' +export type { LinkIconKind, LinkIconProps } from './LinkIcon.tsx' export { projectUserText } from './user-text.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' export { Toast } from './Toast.tsx' +export { fileSizeText } from './file-size.ts' +export { DocumentFileIcon } from './DocumentFileIcon.tsx' export { writeClipboard } from './clipboard.ts' export { relativeTime } from './relative-time.ts' +export { rankByName } from './rank-by-name.ts' export type { RelativeTime, RelativeTimeUnit } from './relative-time.ts' export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' @@ -57,7 +62,6 @@ export type { CodeBlockProps } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels } from './markdown/MarkdownText.tsx' -export { MessageText } from './markdown/MessageText.tsx' export { extractMarkdownPlainText } from './markdown/plain-text.ts' export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts' export * from './icons/index.tsx' diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 0d46ab172a..d29c50c99a 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -57,9 +57,10 @@ .markdown a { /* deepsuite markdown.css uses brand-text (blue in newDesign); this sheet - keeps design-platform brand-text as near-black, so links use the blue - business-primary alias instead. */ - color: var(--dsw-alias-state-business-primary); + keeps design-platform brand-text as near-black, so links use the + dedicated link alias instead. */ + color: var(--dsw-alias-link); + font-weight: 500; transition: box-shadow var(--ds-transition-duration) var(--ds-ease-in-out); position: relative; text-decoration: none; @@ -75,13 +76,27 @@ .markdown a:hover, .markdown a:focus { outline: none; - text-decoration: underline var(--dsw-alias-state-business-primary); + text-decoration: underline dotted var(--dsw-alias-link); + text-underline-offset: 3px; } .markdown a:focus-visible { box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); } +/* Leading link-category glyph inside anchors, file mentions, and + URL-promoted inline code: outline glyphs ride the link's currentColor. + 1.1em keeps the glyph proportional to the surrounding text's size (it + shrinks with the code chip's 0.875em); the small negative offset seats + its optical center on the cap height. */ +.linkIcon { + flex: none; + width: 1.1em; + height: 1.1em; + vertical-align: -0.25em; + margin-right: 5px; +} + .markdown :where(ul, ol) { margin: 16px 0; padding-left: 18px; @@ -151,6 +166,7 @@ font-family: var(--ds-font-family-code); font-size: 0.875em !important; background-color: var(--dsw-alias-markdown-inline-code); + border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 0 5px; } @@ -282,8 +298,8 @@ } /* Inline file mention: the same link language this sheet gives anchors (and - thereby URL-promoted inline code) — link-blue at rest, underline only on - hover/focus. An underline at rest reads badly inside the code chip, where + thereby URL-promoted inline code) — link-blue at rest, dotted underline only + on hover/focus. An underline at rest reads badly inside the code chip, where it collides with monospace descenders and the pill background. */ .fileMention { margin: 0; @@ -291,7 +307,8 @@ border: none; background: none; font: inherit; - color: var(--dsw-alias-state-business-primary); + font-weight: 500; + color: var(--dsw-alias-link); text-decoration: none; cursor: pointer; } @@ -299,6 +316,6 @@ .fileMention:hover, .fileMention:focus { outline: none; - text-decoration: underline var(--dsw-alias-state-business-primary); + text-decoration: underline dotted var(--dsw-alias-link); text-underline-offset: 3px; } diff --git a/packages/client/ui-primitives/src/markdown/MessageText.module.css b/packages/client/ui-primitives/src/markdown/MessageText.module.css deleted file mode 100644 index c6a9c9b245..0000000000 --- a/packages/client/ui-primitives/src/markdown/MessageText.module.css +++ /dev/null @@ -1,9 +0,0 @@ -.text { - white-space: pre-wrap; - word-break: break-word; - /* Font metrics inherit from the consumer's container (bubble 16/24, - assistant flow 16/28) — a generic text primitive must not pin its own - size, or every consumer's line grid breaks (44px bubble spec regression). */ - font-size: inherit; - line-height: inherit; -} diff --git a/packages/client/ui-primitives/src/markdown/MessageText.tsx b/packages/client/ui-primitives/src/markdown/MessageText.tsx deleted file mode 100644 index cafe9ab3c0..0000000000 --- a/packages/client/ui-primitives/src/markdown/MessageText.tsx +++ /dev/null @@ -1,7 +0,0 @@ -// MessageText is the literal-text primitive for user and steering content; assistant output uses MarkdownText. - -import css from './MessageText.module.css' - -export function MessageText({ text }: { text: string }) { - return
{text}
-} diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 950d85be52..9757ddc301 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -24,6 +24,7 @@ import type {} from 'mdast-util-math' import { normalizeUri } from 'micromark-util-sanitize-uri' import { CodeBlock } from './CodeBlock.tsx' import { renderTexToReact } from './katex.tsx' +import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx' import type { PositionedBlock } from './incremental.ts' import css from './MarkdownText.module.css' @@ -263,6 +264,7 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte aria-label={mention.label} onClick={mention.open} > + {value} @@ -287,7 +289,7 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte case 'table': return renderTable(node, key, context) case 'link': - return renderAnchor(node.url, renderChildren(node.children, { ...context, inLink: true }), key) + return renderAnchor(node.url, renderChildren(node.children, { ...context, inLink: true }), key, !anchorWrapsOnlyImages(node.children)) case 'linkReference': return renderLinkReference(node, key, context) case 'image': @@ -465,8 +467,17 @@ function renderTableRow( return {cells} } +/** + * True when an anchor's markdown children are all images, so the anchor is a + * clickable picture (badge, thumbnail): the leading URL glyph would dangle + * beside the image instead of leading link text, so those anchors skip it. + */ +function anchorWrapsOnlyImages(children: Md.PhrasingContent[]): boolean { + return children.length > 0 && children.every(child => child.type === 'image' || child.type === 'imageReference') +} + /** Anchor over an already-authored href: allowlisted or unwrapped, external links get the safe attributes. */ -function renderSafeLink(href: string, children: ReactNode[], key: Key): ReactNode { +function renderSafeLink(href: string, children: ReactNode[], key: Key, glyph = true): ReactNode { const safeHref = sanitizeUrl(href) if (safeHref === '') return {children} const external = ['http:', 'https:'].includes(new URL(safeHref).protocol) @@ -476,14 +487,15 @@ function renderSafeLink(href: string, children: ReactNode[], key: Key): ReactNod href={safeHref} {...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})} > + {glyph && } {children} ) } /** Anchor over a parsed markdown destination, which hast normalized before the allowlist saw it. */ -function renderAnchor(url: string, children: ReactNode[], key: Key): ReactNode { - return renderSafeLink(normalizeUri(url), children, key) +function renderAnchor(url: string, children: ReactNode[], key: Key, glyph = true): ReactNode { + return renderSafeLink(normalizeUri(url), children, key, glyph) } /** @@ -539,7 +551,8 @@ function renderLinkReference( // not an anchor, so mentions inside it stay live. return {'['}{renderChildren(node.children, context)}{referenceSuffix(node)} } - return renderAnchor(definition.url, renderChildren(node.children, { ...context, inLink: true }), key) + const rendered = renderChildren(node.children, { ...context, inLink: true }) + return renderAnchor(definition.url, rendered, key, !anchorWrapsOnlyImages(node.children)) } function renderImageReference( diff --git a/packages/client/ui-primitives/src/rank-by-name.ts b/packages/client/ui-primitives/src/rank-by-name.ts new file mode 100644 index 0000000000..97d6903728 --- /dev/null +++ b/packages/client/ui-primitives/src/rank-by-name.ts @@ -0,0 +1,79 @@ +/** + * Shared ranking for `/` menu candidates: the query must be a + * case-insensitive ordered subsequence of the candidate name. Prefix hits + * rank first, then the strongest alignment score, then the source order of + * the input. Decision record: + * .agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md + */ + +/** One match with its stable source position. */ +interface Ranked { + readonly item: T + readonly index: number + readonly prefix: boolean + readonly score: number +} + +/** Extra weight for name starts and separator boundaries. */ +function boundaryBonus(name: string, index: number): number { + return index === 0 || name.charAt(index - 1) === '-' || name.charAt(index - 1) === '_' ? 8 : 0 +} + +/** + * Score the strongest ordered-subsequence alignment in O(name × query). + * Boundary and adjacent matches earn weight; skipped and leading characters + * cost weight. Undefined when the query is not a subsequence of the name. + */ +function alignmentScore(name: string, query: string): number | undefined { + if (query.length > name.length) return undefined + const noMatch = Number.NEGATIVE_INFINITY + let previous = Array(name.length).fill(noMatch) + for (let index = 0; index < name.length; index++) { + if (name.charAt(index) === query.charAt(0)) previous[index] = 1 + boundaryBonus(name, index) - index + } + for (let queryIndex = 1; queryIndex < query.length; queryIndex++) { + const current = Array(name.length).fill(noMatch) + // Sweep the previous row once: `left` is its score one character back + // (the adjacent continuation), `leftLeft` two back (the earliest gapped one). + let left = noMatch + let leftLeft = noMatch + let bestGapped = noMatch + for (const [index, prior] of previous.entries()) { + if (leftLeft !== noMatch) bestGapped = Math.max(bestGapped, leftLeft + index - 2) + if (name.charAt(index) === query.charAt(queryIndex)) { + const bonus = 1 + boundaryBonus(name, index) + let score = noMatch + if (left !== noMatch) score = left + bonus + 4 + if (bestGapped !== noMatch) score = Math.max(score, bestGapped + bonus + 1 - index) + current[index] = score + } + leftLeft = left + left = prior + } + previous = current + } + let best = noMatch + for (const score of previous) best = Math.max(best, score) + return best === noMatch ? undefined : best +} + +/** + * Rank named items by a menu query. + * @param items - candidates in source order (a host catalog, then client contributions). + * @param rawQuery - the text typed after the trigger, matched case-insensitively. + * @returns the matching items: prefix hits first, then by alignment score, + * then in source order. The input list itself for an empty query. + */ +export function rankByName(items: readonly T[], rawQuery: string): readonly T[] { + const query = rawQuery.toLowerCase() + if (query === '') return items + const ranked: Ranked[] = [] + items.forEach((item, index) => { + const name = item.name.toLowerCase() + const score = alignmentScore(name, query) + if (score !== undefined) ranked.push({ item, index, prefix: name.startsWith(query), score }) + }) + ranked.sort((left, right) => + Number(right.prefix) - Number(left.prefix) || right.score - left.score || left.index - right.index) + return ranked.map(match => match.item) +} diff --git a/packages/client/ui-primitives/src/user-text.module.css b/packages/client/ui-primitives/src/user-text.module.css index 3e6f8ca4f0..3654d6f990 100644 --- a/packages/client/ui-primitives/src/user-text.module.css +++ b/packages/client/ui-primitives/src/user-text.module.css @@ -20,6 +20,14 @@ white-space: nowrap; } +/* Skill and command tokens read as code: the theme's code family at the + consumer's own size and line height, so the chip sits on the bubble line + like the plain runs around it. `@` chips keep the body face — a file or + session name is a label, not code. */ +.slashChip { + font-family: var(--dsw-font-markdown-code-font-family); +} + /* Inline reference glyphs (always ReferenceIcon svgs) ride the consumer's own font: 1em keeps the glyph at the text's size in the bubble (14px + user setting), the queue preview's fixed 13px line, and any future consumer — a diff --git a/packages/client/ui-primitives/src/user-text.tsx b/packages/client/ui-primitives/src/user-text.tsx index 494f49a868..f0b89cfbb4 100644 --- a/packages/client/ui-primitives/src/user-text.tsx +++ b/packages/client/ui-primitives/src/user-text.tsx @@ -2,19 +2,29 @@ * Display projection of reference forms in sent user text (bubble and queue * rows). The logged model text remains the single truth; this is presentation * only, and every part renders inline so a single-line message never breaks - * across lines. Three decoration sources, by precedence: the wire session form + * across lines. Four decoration sources, by precedence: the wire session form * `@[label](dsh-session:...)` folds to its label; exact session labels - * supplied by an adjacent recall decorate their bare `@label` mention; and - * plain `/name` / `@name` word-boundary tokens decorate by shape alone (sent - * tokens were validated at compose time). + * supplied by an adjacent recall decorate their bare `@label` mention; plain + * `@name` word-boundary tokens decorate by shape alone; and a plain `/name` + * token decorates only when the caller names it — a skill the host actually + * loaded for that message (ui-chat reads the step's `skill-invocation` + * injections) or the command a command-input bubble echoes — so `/123` or a + * stray `/word` stays plain text. A `/name` token is whitespace-bounded like + * the host skill gesture (`dsh-tool-skill`): it ends at whitespace or the + * text end, so slash paths (`/nfs-hg/xxx`, `/plan.md`) and punctuation-glued + * tokens (`/plan。`) stay plain even for a loaded name. */ import type { ReactNode } from 'react' +import clsx from 'clsx' import { ReferenceIcon } from './ReferenceIcon.tsx' import css from './user-text.module.css' /** The wire form a session chip serializes to; label is the display text. */ const SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu +/** Sentence punctuation a bare `@name` token may carry without being part of the reference. */ +const TRAILING_PUNCTUATION_RE = /[.,;:!?,。;:!?]+$/u + interface DecorationRange { readonly start: number readonly end: number @@ -29,9 +39,18 @@ interface DecorationRange { * Split one sent text into inline plain runs and reference chips. * @param text - the logged model text of the message or queue row. * @param sessionLabels - exact session mention labels associated by an adjacent recall. + * @param slashNames - names a `/name` token may decorate as: the skills the + * host loaded for this message, or the command a command bubble echoes + * (unsent queue rows pass none). + * @param slashKind - the chip kind those tokens render as. * @returns inline nodes covering the whole text. */ -export function projectUserText(text: string, sessionLabels: readonly string[]): ReactNode { +export function projectUserText( + text: string, + sessionLabels: readonly string[], + slashNames: readonly string[] = [], + slashKind: 'skill' | 'command' = 'skill', +): ReactNode { const ranges: DecorationRange[] = [] SESSION_WIRE_RE.lastIndex = 0 let wire: RegExpExecArray | null @@ -52,15 +71,18 @@ export function projectUserText(text: string, sessionLabels: readonly string[]): start = text.indexOf(label, start + label.length) } } - const re = /(^|\s)(\/[\w-]+|@"[^"\n]+"|@[^\s]+)/gu + // A `/` token ends at whitespace or the text end like the host skill + // gesture; only `@` tokens shed sentence punctuation below. + const re = /(^|\s)(\/[\w-]+(?=\s|$)|@"[^"\n]+"|@[^\s]+)/gu let m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { const tokenStart = m.index + (m[1] as string).length // (^|\s) captures '' at line start const rawLabel = m[2] as string // non-optional alternation capture const label = rawLabel.startsWith('@"') ? rawLabel - : rawLabel.replace(/[.,;:!?,。;:!?]+$/gu, '') + : rawLabel.replace(TRAILING_PUNCTUATION_RE, '') if (label.length <= 1) continue + if (label.startsWith('/') && !slashNames.includes(label.slice(1))) continue ranges.push({ start: tokenStart, end: tokenStart + label.length, label, kind: 'plain' }) } const rankOf = (range: DecorationRange): number => range.kind === 'session' ? 0 : 1 @@ -88,8 +110,8 @@ export function projectUserText(text: string, sessionLabels: readonly string[]): parts.push( {referenceKind !== undefined && ( diff --git a/packages/client/ui-primitives/tests/file-size.client.spec.ts b/packages/client/ui-primitives/tests/file-size.client.spec.ts new file mode 100644 index 0000000000..cdfcb44876 --- /dev/null +++ b/packages/client/ui-primitives/tests/file-size.client.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { fileSizeText } from '../src/file-size.ts' + +describe('fileSizeText', () => { + it('formats bytes and both sides of the KB, MB, and GB rounding thresholds', () => { + expect(fileSizeText(312)).toBe('312B') + expect(fileSizeText(4.25 * 1024)).toBe('4.3KB') + expect(fileSizeText(12.5 * 1024)).toBe('13KB') + expect(fileSizeText(1.5 * 1024 * 1024)).toBe('1.5MB') + expect(fileSizeText(12.5 * 1024 * 1024)).toBe('13MB') + expect(fileSizeText(2447 * 1024 * 1024)).toBe('2.4GB') + expect(fileSizeText(12.5 * 1024 * 1024 * 1024)).toBe('13GB') + }) +}) diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/cjk-strong-and-inline-code-url.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/cjk-strong-and-inline-code-url.settled.txt index 4b1229e903..1544726d41 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/cjk-strong-and-inline-code-url.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/cjk-strong-and-inline-code-url.settled.txt @@ -10,6 +10,8 @@

+ diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/cjk-strong-and-inline-code-url.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/cjk-strong-and-inline-code-url.streaming.txt index 4b1229e903..1544726d41 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/cjk-strong-and-inline-code-url.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/cjk-strong-and-inline-code-url.streaming.txt @@ -10,6 +10,8 @@

+ diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.settled.txt index ee98bcd47a..8fda020b99 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.settled.txt @@ -18,6 +18,8 @@

#text "Footnote a body with " +

#text "Footnote a body with " +

+

#text "relative dropped and js dropped and " +

+

#text "[spaces encoded](" +

+ + linked badge + #text " image-only anchors carry no leading glyph." diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.streaming.txt index 57a4480080..933fa5f541 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.streaming.txt @@ -1,25 +1,41 @@

+

#text "relative dropped and js dropped and " +

+

#text "[spaces encoded](" +

+ + linked badge + #text " image-only anchors carry no leading glyph." diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt index 5cc4257ab7..8772ffc627 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt @@ -2,12 +2,18 @@

#text "A " +

diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.streaming.txt index 5cc4257ab7..8772ffc627 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.streaming.txt @@ -2,12 +2,18 @@

#text "A " +

diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt index 99a45c4e77..f7b93793f4 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt @@ -25,6 +25,8 @@ + diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt index 99a45c4e77..f7b93793f4 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt @@ -25,6 +25,8 @@ + diff --git a/packages/client/ui-primitives/tests/link-icon.client.spec.tsx b/packages/client/ui-primitives/tests/link-icon.client.spec.tsx new file mode 100644 index 0000000000..170ba6f726 --- /dev/null +++ b/packages/client/ui-primitives/tests/link-icon.client.spec.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { LinkIcon, classifyLinkPath, type LinkIconKind } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) + +describe('classifyLinkPath', () => { + it.each([ + ['src/markdown/render.tsx', 'code'], + ['site/index.html', 'code'], + ['data/export.CSV', 'code'], + ['shots/hero.png', 'image'], + ['report.pdf', 'document'], + ['deck.pptx', 'document'], + ['notes.unknownext', 'other'], + ['Makefile', 'other'], + ['C:\\work\\summary.docx', 'document'], + ['archive.tar/.hidden', 'other'], + ] as [string, LinkIconKind][])('%s → %s', (path, kind) => { + expect(classifyLinkPath(path)).toBe(kind) + }) +}) + +describe('LinkIcon', () => { + const kinds: LinkIconKind[] = ['url', 'folder', 'code', 'image', 'document', 'other'] + + it.each(kinds)('%s renders a distinct aria-hidden svg with currentColor fills only', (kind) => { + const { container } = render() + const svg = container.querySelector('svg')! + expect(svg).not.toBeNull() + expect(svg.getAttribute('aria-hidden')).toBe('true') + const markup = container.innerHTML + expect(markup).not.toMatch(/#[0-9a-fA-F]{3,8}"/) + expect(markup).toContain('currentColor') + }) + + it('every kind draws its own glyph', () => { + const paths = kinds.map((kind) => { + const { container } = render() + return container.querySelector('path')!.getAttribute('d') + }) + expect(new Set(paths).size).toBe(kinds.length) + }) + + it('defaults to the 14px inline link seat; size and className land on the svg', () => { + const { container } = render() + expect(container.querySelector('svg')!.getAttribute('width')).toBe('14') + const sized = render() + const svg = sized.container.querySelector('svg')! + expect(svg.getAttribute('width')).toBe('20') + expect(svg.getAttribute('height')).toBe('20') + expect(svg.classList.contains('x')).toBe(true) + }) +}) diff --git a/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx index f2d0a7b4e3..62f7d9f85a 100644 --- a/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx @@ -172,6 +172,8 @@ const CORPUS: Record = { ' and bare autolink https://autolink.example.com literal.', '', '[spaces encoded](https://example.com/a b)', + '', + '[![linked badge](https://example.com/badge.png)](https://example.com/target) image-only anchors carry no leading glyph.', ].join('\n'), 'images': [ '![https image](https://example.com/secure.png "img title")', diff --git a/packages/client/ui-primitives/tests/markdown.client.spec.tsx b/packages/client/ui-primitives/tests/markdown.client.spec.tsx index eb6b450924..a16d0464cb 100644 --- a/packages/client/ui-primitives/tests/markdown.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.client.spec.tsx @@ -1,21 +1,12 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' -import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives' import { JsonBlock, MarkdownText } from './markdown-test-components.tsx' import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts' import { mathCompatibility } from '../src/markdown/mathCompatibility.ts' afterEach(cleanup) -describe('MessageText', () => { - it('renders the text verbatim', () => { - const { container } = render() - expect(container.textContent).toBe('# line1\n`line2`') - expect(container.querySelector('h1')).toBeNull() - }) -}) - describe('MarkdownText', () => { it('renders CommonMark and GFM elements as semantic DOM', () => { const markdown = [ diff --git a/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts b/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts new file mode 100644 index 0000000000..89ed5c7733 --- /dev/null +++ b/packages/client/ui-primitives/tests/rank-by-name.client.spec.ts @@ -0,0 +1,41 @@ +/** + * Shared `/` menu ranker: case-insensitive ordered-subsequence matching, + * prefix hits first, alignment score next, source order for ties. + */ +import { describe, expect, it } from 'vitest' +import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives' + +const named = (...names: string[]) => names.map(name => ({ name })) +const names = (items: readonly { name: string }[]) => items.map(item => item.name) + +describe('rankByName', () => { + it('returns the input list itself for an empty query', () => { + const items = named('b', 'a') + expect(rankByName(items, '')).toBe(items) + }) + + it('matches case-insensitive subsequences and ranks prefixes, boundaries, adjacency, gaps, then source order', () => { + const items = named('q-xylophone', 'qx-long', 'fabulous', 'foo-bar', 'zuv', 'zu1v', 'yu1v', 'zu12v') + expect(names(rankByName(items, 'QX'))).toEqual(['qx-long', 'q-xylophone']) + expect(names(rankByName(items, 'fb'))).toEqual(['foo-bar', 'fabulous']) + expect(names(rankByName(items, 'uv'))).toEqual(['zuv', 'zu1v', 'yu1v', 'zu12v']) + expect(names(rankByName(items, 'zzz'))).toEqual([]) + expect(names(rankByName(items, 'query-longer-than-every-name'))).toEqual([]) + }) + + it('a prefix hit outranks a stronger non-prefix alignment', () => { + // 'z_a_b' aligns both characters on separator boundaries and outscores + // every non-prefix rival; a name that starts with the query still wins. + expect(names(rankByName(named('z_a_b', 'xabc'), 'ab'))).toEqual(['z_a_b', 'xabc']) + expect(names(rankByName(named('z_a_b', 'abc'), 'ab'))).toEqual(['abc', 'z_a_b']) + }) + + it('takes the stronger of an adjacent and a gapped alignment for the same character', () => { + expect(names(rankByName(named('aab', 'ab'), 'ab'))).toEqual(['ab', 'aab']) + }) + + it('returns the ranked items with their payload intact', () => { + const items = [{ name: 'goal', description: 'g' }, { name: 'plan', description: 'p' }] + expect(rankByName(items, 'pl')).toEqual([{ name: 'plan', description: 'p' }]) + }) +}) diff --git a/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts b/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts index d80d4f5c13..575b705f12 100644 --- a/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts +++ b/packages/client/ui-primitives/tests/user-text-styles.client.spec.ts @@ -24,4 +24,13 @@ describe('user-text.module.css font-size axis', () => { 'height: 1em', ])) }) + + it('sets slash chips in the code face at the consumer size', () => { + // Skill and command tokens read as code — the family the theme publishes + // for code — while the size and line height stay the consumer's, so the + // chip rides the bubble line like the plain runs around it. + const slashChip = declarations('.slashChip') + expect(slashChip.some(declaration => /^font-family: var\(--dsw-font-/.test(declaration))).toBe(true) + expect(slashChip.some(declaration => /^(font|font-size|line-height):/.test(declaration))).toBe(false) + }) }) diff --git a/packages/client/ui-primitives/tests/user-text.client.spec.tsx b/packages/client/ui-primitives/tests/user-text.client.spec.tsx index 6e1c2e309f..b65bb8f43d 100644 --- a/packages/client/ui-primitives/tests/user-text.client.spec.tsx +++ b/packages/client/ui-primitives/tests/user-text.client.spec.tsx @@ -8,12 +8,17 @@ import { describe, expect, it } from 'vitest' import { render } from '@testing-library/react' import { projectUserText } from '../src/user-text.tsx' -const project = (text: string, labels: readonly string[] = []) => - render(

).container.querySelector('[data-host]')! +const project = ( + text: string, + labels: readonly string[] = [], + slashNames: readonly string[] = [], + slashKind: 'skill' | 'command' = 'skill', +) => + render(
{projectUserText(text, labels, slashNames, slashKind)}
).container.querySelector('[data-host]')! describe('projectUserText', () => { it('keeps a decorated single-line message on one line: every part is inline', () => { - const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试']) + const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试'], ['dsh-acp-test']) expect(host.querySelectorAll('div').length).toBe(0) expect(host.textContent).toBe('反反复复 /dsh-acp-test 执行几个命令测试') const chips = host.querySelectorAll('[data-ref-chip]') @@ -55,13 +60,39 @@ describe('projectUserText', () => { expect(host.querySelectorAll('[data-ref-chip="session"]').length).toBe(2) }) - it('strips trailing punctuation and skips degenerate tokens', () => { - const host = project('用 /plan。 试试 @。') - const chips = [...host.querySelectorAll('[data-ref-chip]')] - expect(chips.map(c => c.textContent)).toEqual(['/plan']) + it('keeps a punctuation-glued slash token plain and skips degenerate tokens', () => { + // The host skill gesture ends at whitespace or the text end, so `/plan。` + // never loads a skill; the bubble must not suggest otherwise. + const host = project('用 /plan。 试试 @。', [], ['plan']) + expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0) expect(host.textContent).toBe('用 /plan。 试试 @。') }) + it('decorates a slash token only when the host resolved it as a skill in that step', () => { + const bare = project('/123') + expect(bare.querySelectorAll('[data-ref-chip]').length).toBe(0) + expect(bare.textContent).toBe('/123') + const unresolved = project('用 /plan 看看') + expect(unresolved.querySelectorAll('[data-ref-chip]').length).toBe(0) + const resolved = project('用 /plan 看看', [], ['plan']) + expect([...resolved.querySelectorAll('[data-ref-chip]')].map(c => [c.getAttribute('data-ref-chip'), c.textContent])) + .toEqual([['skill', '/plan']]) + }) + + it('marks a resolved slash token as a command chip when the caller says so', () => { + const host = project('/goal ship it\nsecond line', [], ['goal'], 'command') + const chips = [...host.querySelectorAll('[data-ref-chip]')] + expect(chips.map(c => [c.getAttribute('data-ref-chip'), c.textContent])).toEqual([['command', '/goal']]) + expect(host.textContent).toBe('/goal ship it\nsecond line') + }) + + it('leaves slash paths undecorated even for a resolved name: a /name token ends at whitespace', () => { + const text = '测试一下ui,不用管我:\n/nfs-hg/xxx/yyy 与 /root-dir/ 和 /plan.md' + const host = project(text, [], ['nfs-hg', 'root-dir', 'plan']) + expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0) + expect(host.textContent).toBe(text) + }) + it('prefers the longer recall label when one nests inside another', () => { const host = project('@会话一 收尾', ['会话', '会话一']) const chips = [...host.querySelectorAll('[data-ref-chip="session"]')] diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 6c4446f84f..1fba6938f9 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-reference", "description": "Unified Web @file and @session reference source", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 84ed2d1f41..63d0c71784 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-renderer", "description": "Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index 62e5aa506d..843bd9f681 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-schedule", "description": "Read-only active Schedule catalog in the Web Session header", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index f4cae67051..8f3f47c77f 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-session", "description": "Session Controller adapter for React and session-scoped slots", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 593aa4a76a..1eea375c47 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", "description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 85d3a9c670..fdf48278b4 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-models", "description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 76dbdf62b4..a56eba039f 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index ef75045009..66c4db2ad5 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugins", "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 87bd3db954..71688e8d67 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", "description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index c268c1be9f..2e07515091 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 001cfc22ab..fbda419e61 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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-skill/README.md -README.md: 6f5bd109a66b061db3ab5069b15bb4f101039e73 -README.zh.md: 61941ef1b5966d9155d724873b25e03d38d214e1 +README.md: a98194f7e590eb8a68bd329495896e9f2df1b506 +README.zh.md: c4f444f1c749f0697e8883166a0f4ea8ca2009ba diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 6f5bd109a6..a98194f7e5 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -29,7 +29,7 @@ Type `/` in the composer and pick a skill from the suggestions, or type `/name` ### What the source offers -Ordinary-session candidates come from the `skills/list` Remote; the host serves every user-invocable skill, and a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Results filter by `startsWith(query)`. A failed `skills/list` call is logged and folded into a silent menu-group drop — the menu shows only pending/ready states. +Ordinary-session candidates come from the `skills/list` Remote; the host serves every user-invocable skill, and a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Results rank through the `/` menu's shared name ranker, `rankByName` from ui-primitives: the query matches a case-insensitive ordered subsequence of the skill name, prefix hits rank first, and ties keep the host order ([ranking decision](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)). A failed `skills/list` call is logged and folded into a silent menu-group drop — the menu shows only pending/ready states. ### The skill tool row diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 61941ef1b5..c4f444f1c7 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### source 提供什么 -普通会话的候选来自 `skills/list` Remote;宿主提供每一个用户可调用的 skill,`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。结果按 `startsWith(query)` 过滤。`skills/list` 调用失败时会被记录并静默丢弃该菜单组——菜单只显示 pending/ready 状态。 +普通会话的候选来自 `skills/list` Remote;宿主提供每一个用户可调用的 skill,`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。结果经 `/` 菜单共享的名字排序器(ui-primitives 的 `rankByName`)排名:查询作为不区分大小写的有序子序列匹配 skill 名,前缀命中排最前,同分保持宿主顺序([排名决策](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md))。`skills/list` 调用失败时会被记录并静默丢弃该菜单组——菜单只显示 pending/ready 状态。 ### skill 工具行 diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index c131ba0ab1..28bfd18750 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 44712b7286..e15a748ca1 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -35,6 +35,7 @@ import type { SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: pulls the SlotRegistry service merge (ctx.slots). @@ -142,8 +143,9 @@ export function apply(ctx: ClientContext): void { const skills = await fetchCatalog(session.sessionId) // Superseded keystroke: the shared fetch stays warm, this caller yields. if (signal.aborted) return [] - return skills - .filter(skill => skill.name.startsWith(query)) + // The same ranking as the command group of this menu: case-insensitive + // ordered subsequence, prefix hits first. + return rankByName(skills, query) .map(skill => ({ name: skill.name, // The user-only marker rides the description (the menu's only diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 1c812e460e..82ca97029c 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -5,7 +5,7 @@ * the source behavior contract driven directly on the captured source with * real ClientSessionContext projections — sessionId addressing, the * session-keyed catalog cache (single-flight per key, scope-birth warm - * prewarm, connection/reset clear), startsWith filtering, RPC-failure + * prewarm, connection/reset clear), shared fuzzy name ranking, RPC-failure * rejection, pick → plain-text outcome (the plain-text-reference decision: * .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md), * the synchronous @@ -167,7 +167,7 @@ describe('apply', () => { }) describe('candidates: sessionId addressing', () => { - it('lists via {sessionId} and filters by startsWith(query)', async () => { + it('lists via {sessionId} and ranks case-insensitive subsequence matches with prefixes first', async () => { const { list, payloads } = countingList() const { source } = await bench(list) const items = await source.candidates(proj('s1'), req('co')) @@ -177,6 +177,11 @@ describe('candidates: sessionId addressing', () => { { name: 'commit-helper', description: 'commit flow' }, { name: 'code-review', description: 'review flow' }, ]) + const names = async (query: string) => (await source.candidates(proj('s1'), req(query))).map(c => c.name) + // 'de' prefixes deploy and is a subsequence of code-review: the prefix ranks first. + await expect(names('de')).resolves.toEqual(['deploy', 'code-review']) + await expect(names('REV')).resolves.toEqual(['code-review']) + await expect(names('zzz')).resolves.toEqual([]) }) it('rejects on a failed result (the slash shell owns the menu-side fold)', async () => { diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index f22ef1855d..8b30874f9c 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-slots", "description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 4e284f09cd..51c0a4ea2b 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 6f0fbd4c72..c97f73260a 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 5dd0948769..bc4712b223 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -207,12 +207,13 @@ body { --dsw-alias-label-primary: var(--dsw-static-neutral-bluish-1000); --dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-700); --dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-600); + --dsw-alias-link: var(--dsw-static-deepseek-500); --dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-100); --dsw-alias-markdown-code-block-banner: var(--dsw-static-neutral-bluish-50); --dsw-alias-markdown-code-block: var(--dsw-static-neutral-bluish-50); --dsw-alias-markdown-code-segment-selected: var(--dsw-static-neutral-bluish-00); --dsw-alias-markdown-code-segment-unselected: var(--dsw-static-neutral-bluish-75); - --dsw-alias-markdown-inline-code: var(--dsw-static-neutral-bluish-100); + --dsw-alias-markdown-inline-code: var(--dsw-static-neutral-50); --dsw-alias-markdown-placeholder: var(--dsw-static-neutral-bluish-60); --dsw-alias-markdown-tag: var(--dsw-static-neutral-bluish-75); --dsw-alias-scrollbar-bg-l1: var(--dsw-static-neutral-200); @@ -299,12 +300,13 @@ body[data-ds-dark-theme] { --dsw-alias-label-primary: var(--dsw-static-neutral-bluish-50); --dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-300); --dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-400); + --dsw-alias-link: var(--dsw-static-deepseek-400); --dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-800); --dsw-alias-markdown-code-block-banner: var(--dsw-static-neutral-bluish-850); --dsw-alias-markdown-code-block: var(--dsw-static-neutral-bluish-900); --dsw-alias-markdown-code-segment-selected: var(--dsw-static-neutral-bluish-800); --dsw-alias-markdown-code-segment-unselected: var(--dsw-static-neutral-bluish-900); - --dsw-alias-markdown-inline-code: var(--dsw-static-neutral-bluish-850); + --dsw-alias-markdown-inline-code: var(--dsw-static-neutral-800); --dsw-alias-markdown-placeholder: var(--dsw-static-neutral-bluish-850); --dsw-alias-markdown-tag: var(--dsw-static-neutral-bluish-850); --dsw-alias-scrollbar-bg-l1: var(--dsw-static-neutral-700); diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 7a01c24928..cd9d2cc49a 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index ed386b8321..be039beb46 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -391,9 +391,9 @@ describe('DetailsPanel diff Output section', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useProjection={(() => undefined)} 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 092a7ce506..5f172891c0 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -312,9 +312,9 @@ describe('DetailsPanel Output section (read)', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useProjection={(() => undefined)} diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index bc5213d02e..963bf18e49 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -396,9 +396,9 @@ describe('DetailsPanel Output section (search)', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useProjection={(() => undefined)} diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index 064c25d4ec..03ec730732 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -525,7 +525,10 @@ describe('DetailsPanel Output section', () => { useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })} useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} + inputActions={{ + setDraft: () => {}, addAttachments: () => true, removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, + }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} @@ -713,7 +716,10 @@ describe('DetailsPanel Output section', () => { useChat={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })} useTrajectory={useEmptyTrajectory} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} + inputActions={{ + setDraft: () => {}, addAttachments: () => true, removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, + }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index f510ae050f..473ac1c7a4 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -243,9 +243,9 @@ describe('DetailsPanel web Output section', () => { useInput={(() => { throw new Error('unused') })} inputActions={{ setDraft: () => {}, - addImages: () => true, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => true, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }} useProjection={(() => undefined)} diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index edc665438f..2fe93a711e 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: d947787c123fca77dc7978456f4cfdc4e99951bf -README.zh.md: 3b281c6c1e90108d66f050e07a3b08208ed8c802 +README.md: 3f4509c4bcd282e2bc06ca3c00de5308783501b8 +README.zh.md: 75d00e677109c7fa60563d6fc60bac47a13ee006 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index d947787c12..3f4509c4bc 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-trajectory` is the Trajectory view of the dsh web client: it renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records, plus an interactive timing overview. Thick rules mark Turn boundaries, compact inline markers identify Steps, and selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images from user, assistant, or tool content. The view is a pure consumer: it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `conversation.view` slot ring, and provides no service and declares no Context merge. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data. Long ledgers open at the current tail, page older history on demand, and mount only the visible row window. +`dsh-client-ui-trajectory` is the Trajectory view of the dsh web client: it renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records, plus an interactive timing overview. Thick rules mark Turn boundaries, compact inline markers identify Steps, and selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, durable images, and file-attachment summaries from user, assistant, or tool content. The view is a pure consumer: it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `conversation.view` slot ring, and provides no service and declares no Context merge. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data. Long ledgers open at the current tail, page older history on demand, and mount only the visible row window. ## Table of Contents @@ -29,7 +29,7 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit ### Inspecting records -Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A record without text labels its row with the image count. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. +Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. ### The timing overview @@ -47,7 +47,7 @@ The view is a pure projection: Trajectory-owned Definitions assemble business re ### Virtual rows -Long ledgers initially derive React data from 50 target Nodes ending at the mount-time tail. Later Nodes extend that anchored window without evicting its prefix, and the existing load control reveals earlier resident Nodes before requesting another Session page. Virtualization mounts 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. 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. +Long ledgers initially derive React data from 50 target Nodes ending at the mount-time tail. Later Nodes extend that anchored window without evicting its prefix, and the existing load control reveals earlier resident Nodes before requesting another Session page. Virtualization mounts 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. The virtualizer owns bottom following for structural appends; non-virtual ledgers use a direct tail-position write. 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. ### Layout diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 3b281c6c1e..75d00e6771 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-trajectory` 是 dsh Web 客户端的 Trajectory 视图:它渲染按轮次组织的事件记录表,其中可选择用户、助手、工具与嵌套子工具记录,并带交互式时间概览。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤;选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时,以及用户、助手或工具内容中的持久图片。该视图是纯消费方:它注册 target 专属 Event Definition、Trajectory view builder 以及对话 `conversation.view` slot 环中的一个视图标签页,不提供 service,也不声明 Context 合并。带类型的 `trajectory` locale namespace 拥有所有产品编写的 ledger、timeline、inspector、tooltip 与无障碍文案;事件内容、工具名称、标识符与 provider 诊断保持原始数据。长记录表打开时定位于当前尾部、按需加载更早历史,并且只挂载可见行窗口。 +`dsh-client-ui-trajectory` 是 dsh Web 客户端的 Trajectory 视图:它渲染按轮次组织的事件记录表,其中可选择用户、助手、工具与嵌套子工具记录,并带交互式时间概览。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤;选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时,以及用户、助手或工具内容中的持久图片与文件附件摘要。该视图是纯消费方:它注册 target 专属 Event Definition、Trajectory view builder 以及对话 `conversation.view` slot 环中的一个视图标签页,不提供 service,也不声明 Context 合并。带类型的 `trajectory` locale namespace 拥有所有产品编写的 ledger、timeline、inspector、tooltip 与无障碍文案;事件内容、工具名称、标识符与 provider 诊断保持原始数据。长记录表打开时定位于当前尾部、按需加载更早历史,并且只挂载可见行窗口。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### 检查记录 -选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留 snapshot。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。没有文本的记录会用图片数量标记其行。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 +选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留 snapshot。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 ### 时间概览 @@ -47,7 +47,7 @@ kind: "package-reference" ### 虚拟行 -长记录表最初只从挂载时尾部结束的 50 个 target Node 派生 React 数据。后续 Node 会扩展这个固定起点的窗口而不会逐出其前缀;现有加载控件会先显露更早的驻留 Node,再请求下一个 Session 页面。虚拟化只挂载可见行窗口加少量缓冲;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键与 ARIA 索引在向前补页后保持不变。仅含内容更新的流式帧会保持虚拟行的键与高度、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。 +长记录表最初只从挂载时尾部结束的 50 个 target Node 派生 React 数据。后续 Node 会扩展这个固定起点的窗口而不会逐出其前缀;现有加载控件会先显露更早的驻留 Node,再请求下一个 Session 页面。虚拟化只挂载可见行窗口加少量缓冲;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键与 ARIA 索引在向前补页后保持不变。虚拟化器负责结构性追加后的底部跟随;非虚拟记录表会直接写入末尾位置。仅含内容更新的流式帧会保持虚拟行的键与高度、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。 ### 布局 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index f5e2189efa..82e2b2d081 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-trajectory", "description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 81a428aa35..c7c592e235 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1909,6 +1909,7 @@ export function TrajectoryTable({ overscan: VIRTUAL_OVERSCAN_ROWS, scrollMargin: virtualScrollMargin, scrollEndThreshold: BOTTOM_FOLLOW_THRESHOLD_PX, + followOnAppend: 'auto', }) const virtualIndexByRecordId = useMemo(() => { const indexes = new Map() @@ -2284,8 +2285,7 @@ export function TrajectoryTable({ return } if (!followsTableTail.current) return - if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' }) - else pane.scrollTop = pane.scrollHeight + if (!virtualizationEnabled) pane.scrollTop = pane.scrollHeight }, [ historyLoading, historyStartSeq, diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index a811e915f2..d8076b5797 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -125,10 +125,15 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick< const preview = previewContent(node.content) const previewMarkdown = preview === '' ? undefined : preview const images = imageBlockCount(node.content) - return { - text: previewMarkdown === undefined && images > 0 + const files = fileBlockCount(node.content) + const attachmentSummary = [ + previewMarkdown === undefined && images > 0 ? t('layout.imageOnly', { count: images }) - : '', + : undefined, + files > 0 ? t('layout.fileAttachments', { count: files }) : undefined, + ].filter((value): value is string => value !== undefined).join(' · ') + return { + text: attachmentSummary, ...(previewMarkdown === undefined ? {} : { previewMarkdown }), sourceSeq: node.seq, messageSource: node.source, @@ -848,6 +853,10 @@ function imageBlockCount(content: readonly { type: string }[]): number { return content.filter(block => block.type === 'image').length } +function fileBlockCount(content: readonly { type: string }[]): number { + return content.filter(block => block.type === 'file').length +} + function stringifySourceValue(value: unknown): string { const json = JSON.stringify(value, null, 2) return json || String(value) diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index ddc5cea7c7..6bdc621413 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -174,6 +174,7 @@ export const zh = { 'layout.compacted': '上下文已压缩', 'layout.toolCallOnly': '仅工具调用', 'layout.imageOnly': '图片 ×{count}', + 'layout.fileAttachments': '文件 ×{count}', 'layout.initialSystemPrompt': '初始系统提示词', 'layout.systemPromptUpdated': '系统提示词已更新', 'layout.toolsUpdated': '工具已更新', @@ -366,6 +367,7 @@ export const en: Record = { 'layout.compacted': 'Context compacted', 'layout.toolCallOnly': 'Tool call only', 'layout.imageOnly': 'Images ×{count}', + 'layout.fileAttachments': 'Files ×{count}', 'layout.initialSystemPrompt': 'Initial System Prompt', 'layout.systemPromptUpdated': 'System Prompt Updated', 'layout.toolsUpdated': 'Tools Updated', diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index 4b75d04d1e..f9130ab4f7 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -669,3 +669,46 @@ describe('durable image attachments', () => { expect(block?.content).toContain('https://example.com/a.png') }) }) + +describe('durable file attachments', () => { + const attachment = { + attachmentId: `sha256:${'d'.repeat(64)}`, + name: 'notes.pdf', + bytes: 2447 * 1024 * 1024, + } + + it('labels file-only and mixed-text user records without rendering file cards', () => { + const nodes = [ + { + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'file', attachment }, { type: 'file', attachment }], + }, + { + kind: 'user', seq: 2, time: 2_000, source: null, + content: [{ type: 'text', text: 'review these' }, { type: 'file', attachment }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const users = turns.flatMap(turn => turn.groups).flatMap(group => group.cells) + .filter(cell => cell.kind === 'user') + expect(users[0]?.text).toBe('Files ×2') + expect(users[0]?.previewMarkdown).toBeUndefined() + expect(users[1]).toMatchObject({ text: 'Files ×1', previewMarkdown: 'review these' }) + }) + + it('keeps image and file counts in a file-only attachment summary', () => { + const image = { + attachmentId: `sha256:${'e'.repeat(64)}`, + mediaType: 'image/png', + bytes: 68, + width: 640, + height: 320, + } + const nodes = [{ + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'image', attachment: image }, { type: 'file', attachment }], + }] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + expect(turns[0]?.groups[0]?.cells[0]?.text).toBe('Images ×1 · Files ×1') + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index fba1a7b77e..e8288cb0fa 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -206,13 +206,13 @@ function standaloneProps( ): StandaloneBaseProps { const trajectory = historySnapshot(nodes) const input = createSnapshotStore({ - draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], + draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], }) const inputActions: InputActions = { setDraft: () => {}, - addImages: () => false, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => false, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, } return { @@ -328,13 +328,13 @@ function mount(fixture: Awaited>) { createSnapshotStore(tabsOf(slots)), ) const useInput = bindSnapshotSelector(createSnapshotStore({ - draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], + draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], })) const inputActions: InputActions = { setDraft: vi.fn(), - addImages: vi.fn(() => false), - removeImage: vi.fn(), - pruneImages: vi.fn(), + addAttachments: vi.fn(() => false), + removeAttachment: vi.fn(), + pruneAttachments: vi.fn(), submit: vi.fn(), } const standardProps = { diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 07953f0f50..66db0afecf 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-user-questions", "description": "Web ask_user_question composer takeover and plan-review presentation UI", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 35575aeb4d..e303ae5aa8 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -94,7 +94,7 @@ const trajectoryState: TrajectoryState = { } const inputState: InputState = { draft: '', - imageIds: [], + attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], @@ -119,9 +119,9 @@ const kit: Omit = { useInput: selector => selector(inputState), inputActions: { setDraft: () => { throw new Error('unused') }, - addImages: () => { throw new Error('unused') }, - removeImage: () => { throw new Error('unused') }, - pruneImages: () => { throw new Error('unused') }, + addAttachments: () => { throw new Error('unused') }, + removeAttachment: () => { throw new Error('unused') }, + pruneAttachments: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') }, }, useStore: selector => selector(questionDraftStore.getSnapshot()), diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index e44f492b10..42313fc802 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -93,7 +93,7 @@ const trajectoryState: TrajectoryState = { } const inputState: InputState = { draft: '', - imageIds: [], + attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], @@ -118,9 +118,9 @@ const kitBase: Omit = useInput: selector => selector(inputState), inputActions: { setDraft: () => { throw new Error('unused') }, - addImages: () => { throw new Error('unused') }, - removeImage: () => { throw new Error('unused') }, - pruneImages: () => { throw new Error('unused') }, + addAttachments: () => { throw new Error('unused') }, + removeAttachment: () => { throw new Error('unused') }, + pruneAttachments: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') }, }, // The seat's key domain is question ∪ common. diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index 39a02d9a77..2bb8d5fdb5 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workflow-run", "description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index f2d1489bd9..190f136f70 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -184,9 +184,15 @@ } .memberButton .memberLabel { - color: var(--dsw-alias-state-business-primary); - text-decoration: underline; - text-underline-position: from-font; + color: var(--dsw-alias-link); + font-weight: 500; + text-decoration: none; +} + +.memberButton:hover .memberLabel, +.memberButton:focus-visible .memberLabel { + text-decoration: underline dotted; + text-underline-offset: 3px; } .dotSlot { diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 772c90a571..123d08ef84 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -318,9 +318,9 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi useInput: () => { throw new Error('unused') }, inputActions: { setDraft: () => {}, - addImages: () => false, - removeImage: () => {}, - pruneImages: () => {}, + addAttachments: () => false, + removeAttachment: () => {}, + pruneAttachments: () => {}, submit: () => {}, }, useWorkspaces: selector => selector(panelWorkspace), diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 2f684bac15..451bc1276e 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: 174d8368683f49d91dda1e35c413605b364a44e5 -README.zh.md: 1f589be35548374a562a56832672bc9add85b850 +README.md: c7e0e7e53886b6ae1a4de7c40ed6041a61280446 +README.zh.md: 695e89acb967924f0f617b7c17b6581ee38f8ba5 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 174d836868..c7e0e7e538 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -33,7 +33,7 @@ View options combine grouping with one browser-persisted Session order per accou ### Search -Collapsed search is one header action beside the view and add actions: activating it expands the field across the header. A non-blank 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. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20 and opens the selected Session without clearing the query. +Collapsed search is one header action beside the view and add actions: activating it expands the field across the header. A non-blank 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. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20. Choosing a result clears and collapses search, opens the Session, and scrolls its row into view in the configured browsing mode; grouped browsing also expands its Workspace and the full Session list when required. ### Managing sessions diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 1f589be355..695e89acb9 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 搜索 -折叠搜索是视图和添加操作旁的一枚区头按钮:激活后输入框会扩展并占据区头。非空白查询会以单一扁平结果列表替代任一浏览模式——不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,打开所选 Session 时不会清除查询。 +折叠搜索是视图和添加操作旁的一枚区头按钮:激活后输入框会扩展并占据区头。非空白查询会以单一扁平结果列表替代任一浏览模式——不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果。选择结果会清空并收起搜索、打开 Session,并在当前浏览模式中将其行滚动到可见区域;分组浏览还会按需展开所属 Workspace 和完整 Session 列表。 ### 管理会话 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 3636ec465d..91cc118845 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workspace", "description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 0f917100ff..0c2101bd0a 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -5,7 +5,7 @@ * except workspace Rename/Delete and session Rename/Fork/Archive; the session * and workspace hover cards are suppressed while a menu is open. */ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { HoverCard, IconAlarmClockOutline16, IconArchiveOutline20, IconBranchOutline16, @@ -370,12 +370,15 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { * @param props.onRename - open the session rename dialog (id + current title). * @param props.onFork - fork a session at its last completed turn. * @param props.onArchive - archive a session by id. + * @param props.onReveal - scroll this row into view after search navigation, then acknowledge it. * @param props.drag - optional draggable-row wiring. * @param props.flat - omit the empty status slot in the hierarchy-free flat list. * @param props.t - the browser root's locale seat. * @returns the session row. */ -export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, flat = false, t }: { +export function SessionNodeItem({ + node, currentId, now, onOpen, onRename, onFork, onArchive, onReveal, drag, flat = false, t, +}: { node: SessionNode currentId: string | undefined now: number @@ -386,6 +389,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork onFork: (id: SessionNode['id']) => void /** Archive this session (row menu action; commits without a dialog). */ onArchive: (id: SessionNode['id']) => void + /** Scroll this row into view after search navigation, then acknowledge it. */ + onReveal?: (() => void) | undefined /** Present only on draggable rows (workspace-group sessions outside search). */ drag?: RowDragProps | undefined /** The row is rendered without a parent Workspace header. */ @@ -399,6 +404,12 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork const primaryStatus = statuses[0] const showStatus = primaryStatus.state !== 'done' || row.completed const [menuOpen, setMenuOpen] = useState(false) + const rowRef = useRef(null) + useEffect(() => { + if (onReveal === undefined) return + rowRef.current?.scrollIntoView({ block: 'nearest' }) + onReveal() + }, [onReveal]) // Archive hides the row through the registry-global archive set and never // touches the session log, so it is not styled as destructive and needs no // confirmation dialog. @@ -411,6 +422,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork // Figma session cell: pad 8, status slot 16, then a 4px title gap. const ownRow = (
> /** Persist one Workspace group's zero-or-five-session state. */ @@ -262,19 +266,28 @@ type SessionTreeProps = Pick< onSessionArchive: (sessionId: SessionNode['id']) => void /** Session order behavior: fixed after edits, or additionally promoted by user activity. */ orderBy: SessionOrderBy + /** One Session chosen from search that must be exposed and scrolled into view. */ + revealSessionId?: SessionId | undefined + /** Acknowledge that the chosen Session row has been revealed. */ + onSessionRevealed: (sessionId: SessionId) => void } /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */ function SessionTree({ useSessions, useSessionPendingInteraction, startSession, open, forkSession, workspaces, archivedSessionIds, + workspaceReady, onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertWorkspaceBefore, insertSessionBefore, orderBy, groupExpansion, setGroupExpanded, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t, + revealSessionId, onSessionRevealed, }: SessionTreeProps) { const list = useSessions(s => s) const pendingInteractions = useSessionPendingInteraction(s => s) const current = list.current + const revealGroup = revealSessionId === undefined || !workspaceReady + ? undefined + : owningGroupKey(workspaces, revealSessionId) const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) // Transient drag marker state; the selected mode owns the resulting order. const [drag, setDrag] = useState(null) @@ -284,10 +297,9 @@ function SessionTree({ const previousOrderBy = useRef(orderBy) const nativeDragActive = drag !== null || workspaceDrag !== null useNativeDragAcceptance(nativeDragActive) - const currentGroup = current === undefined + const currentGroup = current === undefined || !workspaceReady ? undefined - : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) - ?? UNGROUPED_KEY + : owningGroupKey(workspaces, current) useEffect(() => { if (current === undefined || currentGroup === undefined || Object.hasOwn(groupExpansion, currentGroup)) return setGroupExpanded(currentGroup, true) @@ -347,6 +359,17 @@ function SessionTree({ }), [list, orderedWorkspaces, archivedSessionIds, pendingInteractions, expandedGroups, sessionOrderByAccount], ) + useEffect(() => { + if (revealGroup === undefined || groupExpansion[revealGroup] === true) return + setGroupExpanded(revealGroup, true) + }, [groupExpansion, revealGroup, setGroupExpanded]) + useEffect(() => { + if (revealSessionId === undefined || revealGroup === undefined) return + const group = groups.find(candidate => candidate.key === revealGroup) + if (group === undefined || !group.expanded || !group.sessions.some(row => row.id === revealSessionId)) return + if (collapsedSessionRows(group.sessions).rows.some(row => row.id === revealSessionId)) return + setExpandedSessionGroups(keys => keys.includes(revealGroup) ? keys : [...keys, revealGroup]) + }, [groups, revealGroup, revealSessionId]) const now = Date.now() const commitSessionDrag = (activeDrag: DragState, over: NonNullable): void => { if (sessionDropCommitted.current) return @@ -564,6 +587,9 @@ function SessionTree({ onRename={onSessionRename} onFork={forkSession} onArchive={onSessionArchive} + onReveal={node.id === revealSessionId && group.key === revealGroup + ? () => { onSessionRevealed(node.id) } + : undefined} drag={dragProps} t={t} /> @@ -594,7 +620,8 @@ function SessionTree({ function FlatList({ useSessions, useSessionPendingInteraction, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, - orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t, + orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, + revealSessionId, onSessionRevealed, t, }: Pick< SessionTreeProps, | 'useSessions' @@ -609,6 +636,8 @@ function FlatList({ | 'sessionUpdatedAtByAccount' | 'syncSessionOrderAccount' | 'setSessionOrder' + | 'revealSessionId' + | 'onSessionRevealed' | 't' >) { const list = useSessions(s => s) @@ -683,6 +712,9 @@ function FlatList({ onRename={onSessionRename} onFork={forkSession} onArchive={onSessionArchive} + onReveal={node.id === revealSessionId + ? () => { onSessionRevealed(node.id) } + : undefined} flat drag={{ start: () => { @@ -827,6 +859,7 @@ export function WorkspaceBrowser({ const home = useHostInfo(info => info.home) const workspaces = useWorkspaces(state => state.items) const workspacePhase = useWorkspaces(state => state.phase) + const workspaceStreamState = useWorkspaces(state => state.state) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) // Live occupancy of this surface's directory-flow hole (the same source the // flow reads): a composition without a picking affordance can add nothing. @@ -841,9 +874,9 @@ export function WorkspaceBrowser({ return current !== undefined && state.byId[current]?.blank === true ? current : undefined }) const currentBlankAccount = currentBlankSessionId === undefined + || workspacePhase !== 'ready' ? undefined - : (workspaces.find(workspace => workspace.sessionIds.includes(currentBlankSessionId)) - ?.workspaceId as string | undefined) ?? UNGROUPED_KEY + : owningGroupKey(workspaces, currentBlankSessionId) const promotedBlank = useRef<{ sessionId: SessionId; accountKey: string } | undefined>(undefined) useEffect(() => { if (currentBlankSessionId === undefined || currentBlankAccount === undefined) { @@ -874,6 +907,7 @@ export function WorkspaceBrowser({ // does not silently drop an in-progress filter. const [query, setQuery] = useState('') const [searchExpanded, setSearchExpanded] = useState(false) + const [revealSessionId, setRevealSessionId] = useState(undefined) const normalizedQuery = sanitizeSearchQuery(query).trim() const [remoteSearch, setRemoteSearch] = useState({ query: '', @@ -889,6 +923,19 @@ export function WorkspaceBrowser({ const wsPlusRef = useRef(null) const composingRef = useRef(false) + const openSearchResult = (sessionId: SessionId): void => { + setRevealSessionId(sessionId) + setQuery('') + setSearchExpanded(false) + open(sessionId) + } + const acknowledgeSessionReveal = (sessionId: SessionId): void => { + setRevealSessionId(current => current === sessionId ? undefined : current) + } + useEffect(() => { + if (normalizedQuery !== '') setRevealSessionId(undefined) + }, [normalizedQuery]) + // Rail search = expand + land in the search box: the flag arms before the // expand request; once the shell flips wide the input mounts and takes focus. const [searchOnExpand, setSearchOnExpand] = useState(false) @@ -1206,7 +1253,7 @@ export function WorkspaceBrowser({ ) @@ -1238,6 +1287,7 @@ export function WorkspaceBrowser({ onSessionArchive={onSessionArchive} forkSession={forkSession} workspaces={workspaces} + workspaceReady={workspacePhase === 'ready' && workspaceStreamState !== 'loading'} groupExpansion={groupExpansion} setGroupExpanded={actions.setGroupExpanded} sessionOrderByAccount={sessionOrderByAccount} @@ -1250,6 +1300,8 @@ export function WorkspaceBrowser({ insertWorkspaceBefore={insertWorkspaceBefore} insertSessionBefore={insertSessionBefore} orderBy={orderBy} + revealSessionId={revealSessionId} + onSessionRevealed={acknowledgeSessionReveal} home={home} t={t} onRenameRequest={(workspaceId, currentTitle) => { diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index ec921306f9..37c6d50e38 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -20,6 +20,20 @@ import { /** Group key for Sessions outside every Workspace. */ export const UNGROUPED_KEY = '' +/** + * Resolve the Workspace browser group that owns one Session. + * @param workspaces - authoritative Workspace membership. + * @param sessionId - Session whose browser group is required. + * @returns owning Workspace id, or {@link UNGROUPED_KEY} when no Workspace accounts for it. + */ +export function owningGroupKey( + workspaces: readonly WorkspaceView[], + sessionId: SessionId, +): string { + return (workspaces.find(workspace => workspace.sessionIds.includes(sessionId)) + ?.workspaceId as string | undefined) ?? UNGROUPED_KEY +} + /** Pending interaction kinds with dedicated Workspace-row presentation. */ export type SessionPendingInteractionStatus = 'approval' | 'plan-review' | 'question' type SessionPendingInteractions = ReadonlyMap @@ -287,8 +301,7 @@ export function deriveGroups( const descendants = indexSubagentDescendants(list.byId) const currentGroup = list.current === undefined ? undefined - : (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined) - ?? UNGROUPED_KEY + : owningGroupKey(workspaces, list.current) const groups: GroupNode[] = [] for (const g of groupByWorkspace(list, workspaces, archived, view.ungroupedOrder)) { const expanded = expandedGroups.has(g.key) diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index 1451b1450d..8f5326134b 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -5,7 +5,7 @@ import type { SessionPendingInteractionBase } from '@deepseek-ai/dsh-client-ui-s import type { ScheduleId, ScheduleRecord } from '@deepseek-ai/dsh-schedule/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - deriveFlat, deriveGroups, deriveSearchResults, workspaceLabel, + deriveFlat, deriveGroups, deriveSearchResults, owningGroupKey, workspaceLabel, UNGROUPED_KEY, } from '../src/client/tree.ts' import { createWorkspaceViewStore } from '../src/client/stores.ts' @@ -40,6 +40,14 @@ const schedule = (id: string, scheduledAt: string): ScheduleRecord => ({ scheduledAt, }) +describe('owningGroupKey', () => { + it('returns the owning Workspace id or the Ungrouped key', () => { + const workspaces = [workspace('first', ['owned'])] + expect(owningGroupKey(workspaces, sid('owned'))).toBe('first') + expect(owningGroupKey(workspaces, sid('loose'))).toBe(UNGROUPED_KEY) + }) +}) + describe('deriveGroups', () => { it('keeps Host Workspace and sessionIds order without Client recency sorting', () => { const sessions = list(summary('newer', 20), summary('older', 10)) 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 8ee31d37c9..99437341f3 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -16,7 +16,13 @@ import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx' import { zh } from '../src/client/locales.ts' afterEach(cleanup) -beforeEach(() => { localStorage.clear(); createWorkspaceViewStore().create().actions.setOrderBy('manual') }) +const scrollIntoView = vi.fn() +beforeEach(() => { + localStorage.clear() + createWorkspaceViewStore().create().actions.setOrderBy('manual') + Element.prototype.scrollIntoView = scrollIntoView + scrollIntoView.mockClear() +}) // The seat's key domain is workspace ∪ common; the stub mirrors the real // lookup chain (namespace, then common vocabulary, then the key). @@ -664,7 +670,7 @@ describe('WorkspaceBrowser', () => { expect(input.value).toBe('kept') }) - it('adds Host content hits with context, shows the result bound, and opens without clearing the query', async () => { + it('opens a Host content hit, exits search, and reveals its hidden grouped row', async () => { vi.useFakeTimers() try { const open = vi.fn() @@ -672,12 +678,19 @@ describe('WorkspaceBrowser', () => { items: [{ sessionId: sid('body-hit'), snippet: '…the waterfall token appears here…' }], hasMore: true, })) - mount({ + const b = mount({ useSessions: hook(sessionState([ + summary('newest-1', 6), + summary('newest-2', 5), + summary('newest-3', 4), + summary('newest-4', 3), + summary('newest-5', 2), summary('body-hit', 1, { displayTitle: 'Research notes' }), ])), useWorkspaces: hook(workspaceState([ - workspace('research', ['body-hit'], 'Research Workspace'), + workspace('research', [ + 'newest-1', 'newest-2', 'newest-3', 'newest-4', 'newest-5', 'body-hit', + ], 'Research Workspace'), ])), open, searchSessions, @@ -696,12 +709,150 @@ describe('WorkspaceBrowser', () => { expect(screen.getByText('仅显示前 20 条结果,请缩小搜索范围。')).toBeTruthy() fireEvent.click(screen.getByRole('treeitem')) expect(open).toHaveBeenCalledWith(sid('body-hit')) - expect(input.value).toBe('waterfall token') + expect(input.value).toBe('') + expect(screen.queryByRole('tree', { name: '搜索结果' })).toBeNull() + expect(screen.getByRole('tree', { name: '会话' })).toBeTruthy() + expect(b.store.getSnapshot().groupExpansion).toEqual({ research: true }) + const targetRow = screen.getByText('Research notes').closest('[role="treeitem"]') + expect(targetRow).toBeTruthy() + expect(screen.getByRole('button', { name: '收起' })).toBeTruthy() + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' }) + expect(scrollIntoView.mock.instances.at(-1)).toBe(targetRow) } finally { vi.useRealTimers() } }) + it('waits for authoritative Workspace membership before revealing a grouped search result', async () => { + const sessions = sessionState([ + summary('newest-1', 6), + summary('newest-2', 5), + summary('newest-3', 4), + summary('newest-4', 3), + summary('newest-5', 2), + summary('target', 1, { displayTitle: 'Needle session' }), + ]) + const pending = { ...workspaceState([]), phase: 'pending' as const, state: 'loading' as const } + const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(pending) }) + const input = screen.getByPlaceholderText('搜索会话…') + fireEvent.change(input, { target: { value: 'needle' } }) + fireEvent.click(screen.getByRole('treeitem')) + + expect(scrollIntoView).not.toHaveBeenCalled() + expect(b.store.getSnapshot().groupExpansion).toEqual({}) + + rerender(b, { + useWorkspaces: hook(workspaceState([workspace('research', [ + 'newest-1', 'newest-2', 'newest-3', 'newest-4', 'newest-5', 'target', + ])])), + }) + await waitFor(() => { + expect(b.store.getSnapshot().groupExpansion).toEqual({ research: true }) + expect(screen.getByText('Needle session')).toBeTruthy() + }) + const targetRow = screen.getByText('Needle session').closest('[role="treeitem"]') + expect(scrollIntoView.mock.instances.at(-1)).toBe(targetRow) + expect(screen.getByRole('button', { name: '收起' })).toBeTruthy() + }) + + it('waits for the reconnect baseline before resolving reveal membership', async () => { + const sessions = sessionState([ + summary('newest-1', 6), + summary('newest-2', 5), + summary('newest-3', 4), + summary('newest-4', 3), + summary('newest-5', 2), + summary('target', 1, { displayTitle: 'Needle session' }), + ]) + const reconnecting = { + ...workspaceState([workspace('stale', [...sessions.ids])]), + state: 'loading' as const, + } + const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(reconnecting) }) + const input = screen.getByPlaceholderText('搜索会话…') + fireEvent.change(input, { target: { value: 'needle' } }) + fireEvent.click(screen.getByRole('treeitem')) + + expect(b.store.getSnapshot().groupExpansion).toEqual({}) + expect(scrollIntoView).not.toHaveBeenCalled() + + rerender(b, { + useWorkspaces: hook(workspaceState([workspace('current', [...sessions.ids])])), + }) + await waitFor(() => { + expect(b.store.getSnapshot().groupExpansion).toEqual({ current: true }) + expect(screen.getByText('Needle session')).toBeTruthy() + }) + const targetRow = screen.getByText('Needle session').closest('[role="treeitem"]') + expect(scrollIntoView.mock.instances.at(-1)).toBe(targetRow) + expect(b.store.getSnapshot().groupExpansion).not.toHaveProperty('stale') + }) + + it('keeps the bounded group projection when the revealed result is already within it', () => { + const sessions = sessionState([ + summary('target', 6, { displayTitle: 'Needle session' }), + summary('second', 5), + summary('third', 4), + summary('fourth', 3), + summary('fifth', 2), + summary('hidden', 1), + ]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('research', [...sessions.ids])])), + }) + const input = screen.getByPlaceholderText('搜索会话…') + fireEvent.change(input, { target: { value: 'needle' } }) + fireEvent.click(screen.getByRole('treeitem')) + + expect(screen.getByText('Needle session')).toBeTruthy() + expect(screen.queryByText('hidden')).toBeNull() + expect(screen.getByRole('button', { name: '展开其余 1 个会话' })).toBeTruthy() + expect(scrollIntoView).toHaveBeenCalledOnce() + }) + + it('cancels a pending row reveal when a new search begins', () => { + const sessions = sessionState([summary('target', 1, { displayTitle: 'Needle session' })]) + const pending = { ...workspaceState([]), phase: 'pending' as const, state: 'loading' as const } + const b = mount({ useSessions: hook(sessions), useWorkspaces: hook(pending) }) + const input = screen.getByPlaceholderText('搜索会话…') + fireEvent.change(input, { target: { value: 'needle' } }) + fireEvent.click(screen.getByRole('treeitem')) + + fireEvent.change(input, { target: { value: 'another query' } }) + fireEvent.change(input, { target: { value: '' } }) + rerender(b, { useWorkspaces: hook(workspaceState([workspace('research', ['target'])])) }) + + expect(b.store.getSnapshot().groupExpansion).toEqual({}) + expect(scrollIntoView).not.toHaveBeenCalled() + }) + + it('returns search navigation to the flat list and scrolls to the selected row', () => { + const open = vi.fn() + const b = mount({ + useSessions: hook(sessionState([ + summary('target', 2, { displayTitle: 'Needle session' }), + summary('other', 1), + ])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['target', 'other'])])), + open, + }) + fireEvent.click(screen.getByRole('button', { name: '视图选项' })) + fireEvent.click(screen.getByRole('menuitem', { name: '单列表' })) + const input = screen.getByPlaceholderText('搜索会话…') + fireEvent.change(input, { target: { value: 'needle' } }) + fireEvent.click(screen.getByRole('treeitem')) + + expect(open).toHaveBeenCalledWith(sid('target')) + expect(input.value).toBe('') + expect(screen.queryByRole('tree', { name: '搜索结果' })).toBeNull() + const targetRow = screen.getByText('Needle session').closest('[role="treeitem"]') + expect(targetRow).toBeTruthy() + expect(screen.queryByText('alpha')).toBeNull() + expect(scrollIntoView.mock.instances.at(-1)).toBe(targetRow) + expect(b.store.getSnapshot().groupExpansion).toEqual({}) + }) + it('bounds programmatic search input to a schema-valid request without splitting an astral character', async () => { vi.useFakeTimers() try { diff --git a/packages/client/web/package.json b/packages/client/web/package.json index aefb202344..af4d66d271 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-web", "description": "Web boot kernel: static module table, Cordis loader, framework-free boot page, and UI-renderer handoff", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime-worker-thread/package.json b/packages/code-runtime/code-runtime-worker-thread/package.json index 9d50c46558..186c40fe21 100644 --- a/packages/code-runtime/code-runtime-worker-thread/package.json +++ b/packages/code-runtime/code-runtime-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-worker-thread", "description": "Worker-thread implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index e7139eea47..4e6201d0dc 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime", "description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json index 18cfaa6399..f04b58da40 100644 --- a/packages/compaction/command-compact/package.json +++ b/packages/compaction/command-compact/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-compact", "description": "Human-facing slash command for explicit session compaction", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index 9fc5be9ccd..4b76fc0bc0 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-basic", "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index 4d11d90947..d67a10e588 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-tool-result-pruner", "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json index 812bccde32..874c150e92 100644 --- a/packages/compaction/compaction/package.json +++ b/packages/compaction/compaction/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction", "description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 53fbce5f55..dca221aa1a 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-instructions", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index 6f48106cd0..1eb8175cf0 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference-local", "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index 278b403457..1dbfe31d78 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference", "description": "File-reference discovery contract and shared @file grammar", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 9fddcc31f5..29e83c5c66 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-reference", "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index cf16b64849..13c94aaee1 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-time-context", "description": "Opt-in durable per-step context with the current time and elapsed time", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index a3e43fc3d4..fa7f040bf4 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tmux-context", "description": "Opt-in durable per-step context with this agent's tmux pane and window location", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index 7f5ee5468c..a6579bb5ce 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-default-model", "description": "Default model selection shared by Agent entry points", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index dd3fe864d6..c5da4c8ee9 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop", "description": "The concrete agent loop plugin for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 48ee170fec..1da7449e1b 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -42,6 +42,15 @@ async function mountPersistentHarness(root: string, adapter: MockAdapter, compre return ctx } +/** Remove every `session.lock` under the root: the POSIX forfeit-by-unlink escape hatch, without importing backend internals. */ +async function removeSessionLocks(dir: string): Promise { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name) + if (entry.isDirectory()) await removeSessionLocks(path) + else if (entry.name === 'session.lock') await rm(path, { force: true }) + } +} + /** Seed one stored session through the persistence seam (header minted by the store). */ async function seedStoredSession(ctx: Context, sessionId: SessionId, events: readonly SessionEvent[]): Promise { const detached = ctx.sessions.prepare(sessionId) @@ -930,7 +939,11 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx2.fiber.dispose() }) - it('a pending idle inject() survives persist + resume without a synthetic turn', async () => { + // The crash simulation removes the wedged lifecycle's lock file, which only + // POSIX's orphan-inode forfeit honors; Windows pins the name until the + // process exits, and cross-process crash release is pinned by the jsonl + // two-process e2e. + it.skipIf(process.platform === 'win32')('a pending idle inject() survives persist + resume without a synthetic turn', async () => { const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent @@ -939,6 +952,12 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', a1.inject(createUserMessage({ content: [{ type: 'text', text: 'background job 42 finished' }], source: { kind: 'plugin', plugin: 'tool-bash' } })) await a1.whenIdle() await ctx1.sessions.flush(a1.session) + // Simulate a wedged first lifecycle: a graceful dispose would durably + // discard the pending inject, and the still-open kernel write lock would + // otherwise exclude the second lifecycle. Removing the lock file orphans + // the held inode so the resumer locks a fresh one (the documented + // forfeit-by-unlink escape hatch). + await removeSessionLocks(root) // Lifecycle 2: resume; the injected context is still pending and becomes // model-visible when the next turn admits it. diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index 2267d4bc77..9be6d2f497 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-tool-presentation", "description": "Agent-plane presentation selector: composes one agent's tools as PTC mode, native, or both", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 60ffb53923..065f505420 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent", "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 1854ef3094..b2c3649773 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-scope", "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/package.json b/packages/core/session/package.json index a2c84ee17b..006fe2693a 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session", "description": "Event-sourced session store for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 7d5b9d05ae..d743b7681b 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-system-prompt", "description": "System prompt assembly registry for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 58183c984f..4c1e01f423 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tools", "description": "Tool registry and execution pipeline for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json index 389df2fc32..f12cdbcf6a 100644 --- a/packages/credentials/authorization/package.json +++ b/packages/credentials/authorization/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-authorization", "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 4ac8f288aa..67b1e09781 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials-local", "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index 322e686d97..f5276743fb 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials", "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index 41aefa8d3d..701a9f79b3 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-e2b", "description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index 013dbe0cd5..21fad29c32 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-e2b", "description": "E2B filesystem implementation for DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index 9d51e54a62..d1d40d16ce 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-e2b", "description": "E2B subprocess implementation for DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index ca4c1024b9..cefca03207 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-profile", "description": "Private profile bundle enabling Agent Teams over dsh-base", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/agent-team-web-profile/package.json b/packages/experimental/agent-team-web-profile/package.json index 1ce662a2f1..0707387952 100644 --- a/packages/experimental/agent-team-web-profile/package.json +++ b/packages/experimental/agent-team-web-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-web-profile", "description": "Private Web profile layer for Agent Teams Remote and UI plugins", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index ee4f069335..7e31c91a7c 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team", "description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index 1ba7deeac4..8b9f874627 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-client-ui-agent-team", "description": "Web Agent Teams roster, task board, and teammate navigation", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/code-runtime-python/package.json b/packages/experimental/code-runtime-python/package.json index a82ca841b3..150b71cb1a 100644 --- a/packages/experimental/code-runtime-python/package.json +++ b/packages/experimental/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", diff --git a/packages/experimental/inspector/package.json b/packages/experimental/inspector/package.json index ce0d712a0f..0423299bc5 100644 --- a/packages/experimental/inspector/package.json +++ b/packages/experimental/inspector/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-inspector", "description": "Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/inspector/tests/port-selection.host.spec.ts b/packages/experimental/inspector/tests/port-selection.host.spec.ts index 3b400b29f3..1a4f72f6f4 100644 --- a/packages/experimental/inspector/tests/port-selection.host.spec.ts +++ b/packages/experimental/inspector/tests/port-selection.host.spec.ts @@ -1,9 +1,52 @@ /** Host Worker port-selection behavior. */ import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' import { afterEach, describe, expect, it } from 'vitest' import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' +async function closeServer(server: Server): Promise { + if (!server.listening) return + await new Promise((resolve) => { server.close(() => { resolve() }) }) +} + +async function listen(server: Server, port: number): Promise { + return await new Promise((resolve, reject) => { + const onError = (error: Error): void => { reject(error) } + server.once('error', onError) + server.listen(port, '127.0.0.1', () => { + server.off('error', onError) + const address = server.address() + if (address === null || typeof address === 'string') { + reject(new Error('test server did not bind a TCP port')) + return + } + resolve(address) + }) + }) +} + +async function bindWithAvailableSuccessor(): Promise { + for (let attempt = 0; attempt < 32; attempt += 1) { + const candidate = createServer() + const address = await listen(candidate, 0) + if (address.port === 65_535) { + await closeServer(candidate) + continue + } + const probe = createServer() + try { + await listen(probe, address.port + 1) + return candidate + } catch { + await closeServer(candidate) + } finally { + await closeServer(probe) + } + } + throw new Error('test could not reserve an occupied port with a bindable successor') +} + describe('Inspector endpoint port selection', () => { let blocker: Server | undefined let inspector: InspectorHandle | undefined @@ -11,21 +54,12 @@ describe('Inspector endpoint port selection', () => { afterEach(async () => { await inspector?.close() inspector = undefined - if (blocker?.listening === true) { - await new Promise((resolve) => { blocker!.close(() => { resolve() }) }) - } + if (blocker !== undefined) await closeServer(blocker) blocker = undefined }) it('advances from an occupied starting port and publishes the selected port', async () => { - blocker = createServer() - await new Promise((resolve, reject) => { - blocker!.once('error', reject) - blocker!.listen(0, '127.0.0.1', () => { - blocker!.off('error', reject) - resolve() - }) - }) + blocker = await bindWithAvailableSuccessor() const occupiedAddress = blocker.address() if (occupiedAddress === null || typeof occupiedAddress === 'string') { throw new Error('test server did not bind a TCP port') diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index 590a05e50f..978c2922e6 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-tool-agent-team", "description": "Scoped model-facing Agent Teams tools over ctx.agentTeams", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json index 5f75d03a5d..3c8250ff6c 100644 --- a/packages/experimental/webworker-packer/package.json +++ b/packages/experimental/webworker-packer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-packer", "description": "Build-time packer for the browser runtime's base VFS image and ordered data-overlay archives", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-runtime/README.i18n.yaml b/packages/experimental/webworker-runtime/README.i18n.yaml index 74f6ba36d3..10a2ecc5d2 100644 --- a/packages/experimental/webworker-runtime/README.i18n.yaml +++ b/packages/experimental/webworker-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/experimental/webworker-runtime/README.md -README.md: 87d53937bee73d7e771ad957ea6c5d953965169f -README.zh.md: d11c074ca02bab12a9f72be17b035389507e05cb +README.md: b5c9370bbff33e4385e08c299fc9c1a7669f8e5e +README.zh.md: 04eee0bfb133bf020fc8989ddb98e54c1dfadf34 diff --git a/packages/experimental/webworker-runtime/README.md b/packages/experimental/webworker-runtime/README.md index 87d53937be..b5c9370bbf 100644 --- a/packages/experimental/webworker-runtime/README.md +++ b/packages/experimental/webworker-runtime/README.md @@ -28,7 +28,7 @@ Three artifacts from one tsdown pipeline: - **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the base image and any ordered data overlays (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. Overlays may replace files only under `home/` and `workspace/`; they cannot replace the base manifest, configuration, or modules. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`. - **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and native/binary package replacements. `node:module` supplies `createRequire().resolve` and `.resolve.paths()` over the image package root, so unchanged packages can discover manifests without evaluating their modules. The global `process` shim carries Node detection fields including `title`, preventing Worker execution from entering DOM-only branches. The pack-time parser reports statically named module requests, including module-scope direct calls of the form `createRequire(import.meta.url)('pkg')` through a named `node:module` or `module` import, to the packer's reachability walk. Stored, CommonJS-obtained, and rebased `createRequire` calls require image entry seeds. VFS mutations drive `node:fs` callback, polling, and promise watchers; open descriptors retain file identity and access mode across rename, replacement, and unlink; `readable-stream` supplies the stream state machine used by file streams and unchanged image packages such as Chokidar and readdirp. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). - **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). Worker platform executables preserve native-package protocols such as Landlock without replacing their JavaScript packages or coupling their implementations to `node:child_process`; ordinary commands use the package's evaluator and coreutils command table. The grammar is `@yarnpkg/parsers`' `parseShell`, while `execSync`/`fork` still refuse because they need a real process. -- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport and the API client. +- **`lib/client.js` (page half)** — startup has two independent stages. `chooseWorkerHostSource({ image?, fixtureManifest? })` optionally owns the boot barrier and fixture manifest: without `preview-fixture` it waits at the source chooser, while a valid query selects directly; either path returns ordered overlays. `connectWorkerHost(worker, { image?, overlays? })` remains the public base-runtime connector; callers that skip the chooser get an empty overlay list. `apps/web` invokes both and supplies its statically bundled Worker. The opening `init` frame carries the base and ordered overlay URLs, the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. Script preload rows are advisory and skipped because `/plugins` resources resolve only through the tunnel; `loadBundle` fetches each combo on first demand, embeds its tunnel-only source map as a Base64 data URL, and executes the script as a Blob. The tunnel also exposes fetch-shaped transport, the independent file-upload carrier, and the API client. Request frames preserve Blob bodies through structured clone and transfer `ReadableStream` ownership. The Host Worker streams both forms into the route, so neither browser thread creates a complete byte array for a generic-file upload. Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the pre-boot chooser plus Worker activation in headless Chromium. The empty selection exercises first-run startup. The `vfs-example` overlay supplies ordinary workspace files and plaintext persistence artifacts for cold Workspace/Session discovery, tool presentation, subagent navigation, and history paging without a model request. The chooser reserves WebFS as a separate user-authorized source; that provider does not read the built-in fixture. diff --git a/packages/experimental/webworker-runtime/README.zh.md b/packages/experimental/webworker-runtime/README.zh.md index d11c074ca0..04eee0bfb1 100644 --- a/packages/experimental/webworker-runtime/README.zh.md +++ b/packages/experimental/webworker-runtime/README.zh.md @@ -28,7 +28,7 @@ kind: "package-library" - **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载基础镜像和按序排列的数据 overlays(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。Overlay 只能替换 `home/` 与 `workspace/` 下的文件,不能替换基础 manifest、配置或模块。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。 - **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS、隧道和浏览器原语,浏览器做不到的走结构化 stub(调用即在 console 报错并抛出),native/binary 包则替换执行后端。`node:module` 在镜像 package 根之上提供 `createRequire().resolve` 与 `.resolve.paths()`,使未修改的包无需执行目标模块即可发现 manifest。全局 `process` shim 带有包括 `title` 在内的 Node 环境识别字段,避免 Worker 执行误入仅适用于 DOM 的分支。pack 期解析器会把名称静态可知的模块请求报告给 packer 的可达性遍历,其中包括通过 `node:module` 或 `module` 具名导入在模块作用域直接发起的 `createRequire(import.meta.url)('pkg')` 调用。保存、经 CommonJS 获取或另设基准的 `createRequire` 调用需要镜像入口种子。VFS mutation 驱动 `node:fs` 的 callback、polling 和 promise watcher;打开的 descriptor 在 rename、replacement 和 unlink 后仍保留文件身份与访问模式;`readable-stream` 提供文件流以及 Chokidar、readdirp 等未修改镜像包所用的流状态机。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。 - **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。Worker 平台 executable 在不替换 JavaScript 包、也不把具体实现耦合进 `node:child_process` 的情况下保持 Landlock 等 native 包协议;普通命令使用本包的求值器与 coreutils 命令表。语法来自 `@yarnpkg/parsers` 的 `parseShell`,而 `execSync`/`fork` 依然拒绝,因为它们需要真进程。 -- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形传输与 API 客户端。 +- **`lib/client.js`(页面半)**——启动分为相互独立的两段。`chooseWorkerHostSource({ image?, fixtureManifest? })` 可选地拥有 boot barrier 与 fixture manifest:没有 `preview-fixture` 时停在来源选择面板,合法 query 则直接选择;两条路径都返回按序排列的 overlays。`connectWorkerHost(worker, { image?, overlays? })` 仍是公开的基础运行态连接器;调用方跳过选择器时 overlay 列表为空。`apps/web` 调用这两段并提供静态打包的 Worker。开局 `init` 帧携带基础镜像与按序排列的 overlay URL,boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。脚本 preload 行只是提示,因此会被跳过:`/plugins` 资源只能经 tunnel 解析,`loadBundle` 会在首次需要时获取 combo、把仅 tunnel 可达的 sourcemap 内嵌为 Base64 data URL,再以 Blob 执行脚本。Tunnel 还暴露 fetch 形式的传输、独立文件上传载体和 API 客户端。请求帧通过结构化克隆保留 Blob 请求体,并转移 `ReadableStream` 的所有权。Host Worker 将两种请求体都逐块送入路由,因此通用文件上传不会在任何浏览器线程创建完整字节数组。 验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 pre-boot 选择面板与 Worker 激活。空白选择验证首次启动;`vfs-example` overlay 提供普通 workspace 文件与明文 persistence 产物,无需模型请求即可验证 Workspace/Session 冷发现、工具呈现、subagent 导航和历史分页。选择面板为 WebFS 保留独立的用户授权来源;该 provider 不读取内置 fixture。 diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index 90e78feb89..261524be98 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-runtime", "description": "Browser-only harness runtime: in-memory VFS, module transform and loader, postMessage tunnel, and the dedicated Web Worker assembly, with the Node-compatibility layer that lets the host tree run unchanged", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "private": true, "repository": { "type": "git", @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index 5bf98cc640..6afb527705 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -119,10 +119,14 @@ async function localizeSourceMap(source: string, bundleUrl: string, fetch: Tunne } } -/** Normalize a RequestInit body to a transferable ArrayBuffer. */ -function toBodyBuffer(body: RequestInit['body']): ArrayBuffer | undefined { +/** Keep opaque Blobs and transferable streams intact; normalize other bodies to bytes. */ +function toTunnelBody( + body: RequestInit['body'], +): ArrayBuffer | Blob | ReadableStream | undefined { if (body === undefined || body === null) return undefined if (typeof body === 'string') return encoder.encode(body).buffer + if (body instanceof Blob) return body + if (body instanceof ReadableStream) return body as ReadableStream if (body instanceof ArrayBuffer) return body if (ArrayBuffer.isView(body)) { return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength) @@ -197,21 +201,21 @@ export class WorkerTunnel { // must not reach the worker, where a write-shaped route would still run. if (signal?.aborted === true) throw new DOMException('The operation was aborted.', 'AbortError') const id = this.nextId++ + const body = init?.body === undefined || init.body === null ? undefined : toTunnelBody(init.body) const frame: RequestFrame = { t: 'req', id, method: init?.method ?? 'GET', url: new URL(input, globalThis.location.origin).toString(), headers: Object.fromEntries(new Headers(init?.headers).entries()), - ...(init?.body === undefined || init.body === null - ? {} - : { body: toBodyBuffer(init.body) }), + ...(body === undefined ? {} : { body }), } const response = new Promise((resolve, reject) => { this.unary.set(id, { resolve, reject }) }) this.inFlight.set(id, `${frame.method} ${frame.url}`) - this.worker.postMessage(frame) + if (body instanceof ReadableStream) this.worker.postMessage(frame, [body]) + else this.worker.postMessage(frame) if (signal === undefined || signal === null) return await response const raced = this.rejectOnAbort(id, signal) try { diff --git a/packages/experimental/webworker-runtime/src/client/index.ts b/packages/experimental/webworker-runtime/src/client/index.ts index 7f4af6e1a9..467f915902 100644 --- a/packages/experimental/webworker-runtime/src/client/index.ts +++ b/packages/experimental/webworker-runtime/src/client/index.ts @@ -9,6 +9,7 @@ * @module @deepseek-ai/dsh-experimental-webworker-runtime/client */ import { IMAGE_FILE_NAME } from '../image-layout.ts' +import type { ClientFileUploadHooks } from '@deepseek-ai/dsh-client-file-upload/types' import { PREVIEW_FIXTURE_MANIFEST_FILE } from '../fixture-manifest.ts' import { WorkerTunnel, type TunnelFetch } from './client.ts' import { applyIndexInjections } from './apply-injections.ts' @@ -33,6 +34,11 @@ interface ClientTransportGlobal { } } +/** Upload hook consumed by the independent Client file-upload service. */ +interface ClientFileUploadGlobal { + __DSH_FILE_UPLOAD__?: ClientFileUploadHooks +} + /** Inputs for {@link connectWorkerHost}. */ export interface WorkerHostConnectOptions { /** @@ -151,6 +157,9 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn // the privileged surface stays reachable off loopback authorities. ownsHost: true, } + ;(globalThis as ClientFileUploadGlobal).__DSH_FILE_UPLOAD__ = { + fetch: (input, init) => tunnel.fetch(input, init), + } await applyIndexInjections(payload.injections, src => tunnel.loadBundle(src)) ready.resolve() return { worker, tunnel, loadBundle: (url: string) => tunnel.loadBundle(url) } diff --git a/packages/experimental/webworker-runtime/src/module-proxies.ts b/packages/experimental/webworker-runtime/src/module-proxies.ts index 3bb59ef366..8fef2b62b6 100644 --- a/packages/experimental/webworker-runtime/src/module-proxies.ts +++ b/packages/experimental/webworker-runtime/src/module-proxies.ts @@ -65,6 +65,7 @@ export const MODULE_PROXIES: Record = { 'node:worker_threads': './node/builtin_modules/mock/worker_threads.ts', 'node:sqlite': './node/builtin_modules/mock/sqlite.ts', // External npm replacements, named after the package each stands in for. + 'fs-ext': './node/external_packages/fs-ext.ts', 'koffi': './node/external_packages/koffi.ts', 'sharp': './node/external_packages/sharp.ts', 'node-pty': './node/external_packages/node-pty.ts', diff --git a/packages/experimental/webworker-runtime/src/node/builtins.ts b/packages/experimental/webworker-runtime/src/node/builtins.ts index 9f7947dc06..ac44024aec 100644 --- a/packages/experimental/webworker-runtime/src/node/builtins.ts +++ b/packages/experimental/webworker-runtime/src/node/builtins.ts @@ -45,6 +45,7 @@ import * as nodeNet from './builtin_modules/mock/net.ts' import * as nodeSqlite from './builtin_modules/mock/sqlite.ts' import * as nodeVm from './builtin_modules/mock/vm.ts' import * as nodeWorkerThreads from './builtin_modules/mock/worker_threads.ts' +import * as fsExt from './external_packages/fs-ext.ts' import * as koffi from './external_packages/koffi.ts' import * as nodePty from './external_packages/node-pty.ts' import * as piAi from './external_packages/pi-ai.ts' @@ -85,6 +86,7 @@ const BUILTINS: Record = { /** External npm packages replaced wholesale (structural not-implemented stubs and fakes). */ const EXTERNALS: Record = { + 'fs-ext': () => fsExt, 'koffi': () => koffi, 'sharp': () => sharp, 'node-pty': () => nodePty, diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/fs-ext.ts b/packages/experimental/webworker-runtime/src/node/external_packages/fs-ext.ts new file mode 100644 index 0000000000..049aa5c2b5 --- /dev/null +++ b/packages/experimental/webworker-runtime/src/node/external_packages/fs-ext.ts @@ -0,0 +1,42 @@ +/** + * `fs-ext` stub: the kernel file-lock bridge the JSONL session backend uses + * for cross-process write exclusion. The worker is a single-process + * deployment whose in-process write claim already excludes every writer, so + * both flock faces succeed immediately; every other entry is loud because + * nothing in the worker reaches it. + */ +import { notImplementedFail } from '../notImplementedFail.ts' + +const MODULE = 'fs-ext' + +/** + * Asynchronous flock face; the single-process worker grants every lock. + * @param _fd - file descriptor (unused). + * @param _flags - lock flags (unused). + * @param callback - completion callback, invoked with no error. + */ +export function flock(_fd: number, _flags: unknown, callback: (error: null) => void): void { + queueMicrotask(() => { callback(null) }) +} + +/** + * Synchronous flock face; the single-process worker grants every lock. + */ +export function flockSync(): void {} + +/** Unreached in the worker; loud refusal. */ +export const fcntl = notImplementedFail(MODULE, 'fcntl') +/** Unreached in the worker; loud refusal. */ +export const fcntlSync = notImplementedFail(MODULE, 'fcntlSync') +/** Unreached in the worker; loud refusal. */ +export const seek = notImplementedFail(MODULE, 'seek') +/** Unreached in the worker; loud refusal. */ +export const seekSync = notImplementedFail(MODULE, 'seekSync') +/** Unreached in the worker; loud refusal. */ +export const statVFS = notImplementedFail(MODULE, 'statVFS') + +/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */ +export const __esModule = true + +/** The fs-ext face its consumers read. */ +export default { flock, flockSync, fcntl, fcntlSync, seek, seekSync, statVFS } diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts b/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts index 528e6f6885..453e9864f6 100644 --- a/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts +++ b/packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts @@ -10,6 +10,7 @@ export const REPLACED_EXTERNAL_PACKAGES: readonly string[] = [ '@earendil-works/pi-ai', '@vscode/ripgrep', + 'fs-ext', 'koffi', 'node-pty', 'sharp', diff --git a/packages/experimental/webworker-runtime/src/transport/frames.ts b/packages/experimental/webworker-runtime/src/transport/frames.ts index a04a2cec5a..01d399d4e8 100644 --- a/packages/experimental/webworker-runtime/src/transport/frames.ts +++ b/packages/experimental/webworker-runtime/src/transport/frames.ts @@ -7,14 +7,14 @@ /** Request identifier minted by the page. */ export type TunnelRequestId = string | number -/** One request; `body` carries the raw bytes for methods that have one. */ +/** One request; `body` carries cloneable or transferable raw bytes. */ export interface TunnelRequestFrame { readonly t: 'req' readonly id: TunnelRequestId readonly method: string readonly url: string readonly headers: Readonly> - readonly body?: ArrayBuffer | undefined + readonly body?: ArrayBuffer | Blob | ReadableStream | undefined } /** Open one Gateway Remote stream over the worker-local carrier. */ @@ -171,8 +171,11 @@ export function parseInboundFrame(data: unknown): TunnelInboundFrame { if (typeof value === 'string') headers[key.toLowerCase()] = value } const body = frame.body - if (body !== undefined && !(body instanceof ArrayBuffer)) { - throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer`) + if (body !== undefined + && !(body instanceof ArrayBuffer) + && !(body instanceof Blob) + && !(body instanceof ReadableStream)) { + throw new Error(`webworker tunnel: request ${String(id)} body must be an ArrayBuffer, Blob, or ReadableStream`) } return { t: 'req', id, method: frame.method, url: frame.url, headers, body } } diff --git a/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts b/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts index a10634e493..a45b4164d8 100644 --- a/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts +++ b/packages/experimental/webworker-runtime/src/transport/synthetic-http.ts @@ -66,7 +66,25 @@ export function createSyntheticExchange(frame: TunnelRequestFrame, sink: Respons headers: frame.headers, destroy: (): void => { aborted = true }, async *[Symbol.asyncIterator](): AsyncGenerator { - if (frame.body === undefined || frame.body.byteLength === 0) return + if (frame.body === undefined) return + if (frame.body instanceof Blob) { + for await (const chunk of frame.body.stream()) { + if (aborted) return + if (chunk.byteLength > 0) yield chunk + } + return + } + if (frame.body instanceof ReadableStream) { + for await (const chunk of frame.body) { + if (aborted) return + if (!(chunk instanceof Uint8Array)) { + throw new TypeError('webworker tunnel: request stream produced a non-Uint8Array chunk') + } + if (chunk.byteLength > 0) yield chunk + } + return + } + if (aborted || frame.body.byteLength === 0) return yield new Uint8Array(frame.body) }, } @@ -133,6 +151,7 @@ export function createSyntheticExchange(frame: TunnelRequestFrame, sink: Respons if (finished) return aborted = true finished = true + emit('aborted') emit('close') }, } diff --git a/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts index 9519962309..4113bfd25b 100644 --- a/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/frames.spec.ts @@ -20,3 +20,23 @@ describe('tunnel init frame', () => { .toThrow(/array of string overlay urls/) }) }) + +describe('tunnel request bodies', () => { + it('accepts ArrayBuffer, Blob, and ReadableStream bodies and rejects other values', () => { + const bytes = Uint8Array.of(1, 2).buffer + const blob = new Blob(['large']) + expect(parseInboundFrame({ + t: 'req', id: 1, method: 'POST', url: '/bytes', headers: {}, body: bytes, + })).toMatchObject({ body: bytes }) + expect(parseInboundFrame({ + t: 'req', id: 2, method: 'POST', url: '/blob', headers: {}, body: blob, + })).toMatchObject({ body: blob }) + const stream = new ReadableStream() + expect(parseInboundFrame({ + t: 'req', id: 3, method: 'POST', url: '/stream', headers: {}, body: stream, + })).toMatchObject({ body: stream }) + expect(() => parseInboundFrame({ + t: 'req', id: 4, method: 'POST', url: '/bad', headers: {}, body: 'large', + })).toThrow('body must be an ArrayBuffer, Blob, or ReadableStream') + }) +}) diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts index bf97dbffa3..5f23d51653 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-client.spec.ts @@ -33,27 +33,43 @@ type StubListener = (event: { data?: unknown; message?: string }) => void function stubWorker(): { worker: Worker sent: { t: string; id: number }[] + transfers: Transferable[][] deliver: (frame: unknown) => void fail: (message: string) => void } { const listeners: StubListener[] = [] const errorListeners: StubListener[] = [] const sent: { t: string; id: number }[] = [] + const transfers: Transferable[][] = [] const worker = { addEventListener: (type: string, listener: StubListener) => { if (type === 'message') listeners.push(listener) if (type === 'error') errorListeners.push(listener) }, - postMessage: (frame: unknown) => { sent.push(frame as { t: string; id: number }) }, + postMessage: (frame: unknown, transfer?: Transferable[]) => { + sent.push(frame as { t: string; id: number }) + transfers.push(transfer ?? []) + }, } as unknown as Worker return { worker, sent, + transfers, deliver: (frame) => { for (const listener of listeners) listener({ data: frame }) }, fail: (message) => { for (const listener of errorListeners) listener({ message }) }, } } +// ReadableStream request bodies transfer ownership instead of copying chunks on the page. +{ + const { worker, sent, transfers } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const body = new ReadableStream({ start(controller) { controller.close() } }) + void tunnel.fetch('/upload', { method: 'POST', body, duplex: 'half' } as RequestInit) + check('a stream request body stays intact', (sent[0] as unknown as { body: unknown }).body, body) + check('a stream request body is transferred', transfers[0], [body]) +} + // The opening frame preserves overlay order for deterministic pre-boot mounts. { const { worker, sent } = stubWorker() @@ -100,6 +116,15 @@ function stubWorker(): { check('204 resolves with a null body', (await response).body, null) } +// Blob bodies remain Blob-backed across the page-to-Host Worker handoff. +{ + const { worker, sent } = stubWorker() + const tunnel = new WorkerTunnel(worker) + const body = new Blob(['large']) + void tunnel.fetch('/upload', { method: 'POST', body }) + check('a Blob request body stays opaque', (sent[0] as unknown as { body: unknown }).body, body) +} + // A streamed reply reassembles in order and closes. { const { worker, deliver } = stubWorker() diff --git a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts index 0a32e77753..7ca4ba6260 100644 --- a/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts +++ b/packages/experimental/webworker-runtime/tests/transport/tunnel-server.spec.ts @@ -2,6 +2,12 @@ import { describe, expect, it, vi } from 'vitest' import type { TunnelOutboundFrame } from '../../src/transport/frames.ts' import { TunnelServer, type TunnelSeams } from '../../src/transport/tunnel.ts' +function bytes(...values: number[]): Uint8Array { + const data = new Uint8Array(new ArrayBuffer(values.length)) + data.set(values) + return data +} + function harness(): { server: TunnelServer; frames: TunnelOutboundFrame[] } { const frames: TunnelOutboundFrame[] = [] const server = new TunnelServer({ @@ -60,6 +66,68 @@ describe('worker tunnel unary authentication', () => { }) }) +describe('worker tunnel Blob requests', () => { + it('streams an opaque Blob in bounded chunks inside the Host Worker route', async () => { + const frames: TunnelOutboundFrame[] = [] + const seen: Uint8Array[] = [] + const body = new Blob(['unused']) + body.arrayBuffer = () => Promise.reject(new Error('route must not aggregate the Blob')) + body.stream = () => new ReadableStream>({ + start(controller) { + controller.enqueue(bytes(108, 97, 114)) + controller.enqueue(bytes(103, 101)) + controller.close() + }, + }) + const server = new TunnelServer({ + port: { postMessage: (frame) => { frames.push(frame) } }, + requestListener: () => Promise.resolve(async (request, response) => { + for await (const chunk of request as AsyncIterable) seen.push(chunk) + const res = response as { writeHead(status: number): void; end(body: string): void } + res.writeHead(200) + res.end('stored') + }), + }) + server.serve(seams(async () => (async function *(): AsyncGenerator { yield undefined })())) + server.handleMessage({ + t: 'req', id: 9, method: 'POST', url: 'http://localhost/upload', headers: {}, body, + }) + await vi.waitFor(() => { expect(frames).toHaveLength(1) }) + expect(seen.map(chunk => new TextDecoder().decode(chunk))).toEqual(['lar', 'ge']) + expect(frames[0]).toMatchObject({ t: 'res', id: 9, status: 200 }) + }) +}) + +describe('worker tunnel ReadableStream requests', () => { + it('streams transferred chunks through the Host Worker route', async () => { + const frames: TunnelOutboundFrame[] = [] + const seen: Uint8Array[] = [] + const body = new ReadableStream({ + start(controller) { + controller.enqueue(bytes(111, 110, 101)) + controller.enqueue(bytes(116, 119, 111)) + controller.close() + }, + }) + const server = new TunnelServer({ + port: { postMessage: (frame) => { frames.push(frame) } }, + requestListener: () => Promise.resolve(async (request, response) => { + for await (const chunk of request as AsyncIterable) seen.push(chunk) + const res = response as { writeHead(status: number): void; end(body: string): void } + res.writeHead(200) + res.end('stored') + }), + }) + server.serve(seams(async () => (async function *(): AsyncGenerator { yield undefined })())) + server.handleMessage({ + t: 'req', id: 10, method: 'POST', url: 'http://localhost/upload', headers: {}, body, + }) + await vi.waitFor(() => { expect(frames).toHaveLength(1) }) + expect(seen.map(chunk => new TextDecoder().decode(chunk))).toEqual(['one', 'two']) + expect(frames[0]).toMatchObject({ t: 'res', id: 10, status: 200 }) + }) +}) + describe('worker tunnel logical streams', () => { it('drains a pre-boot open through the worker-local Gateway seam', async () => { const { server, frames } = harness() diff --git a/packages/experimental/webworker-runtime/tsconfig.json b/packages/experimental/webworker-runtime/tsconfig.json index f28341d750..6e76c8b1fc 100644 --- a/packages/experimental/webworker-runtime/tsconfig.json +++ b/packages/experimental/webworker-runtime/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../client/connection/tsconfig.host.json" }, + { + "path": "../../client/file-upload/tsconfig.host.json" + }, { "path": "../../host/webserver" }, diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index a5d96d2970..84fc670e75 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-client-runner", "description": "Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index bee111f642..df710cc1cc 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -443,7 +443,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'BeginSubmissionInput', - declaration: 'export interface BeginSubmissionInput {\n readonly mode: \'queue\' | \'steer\';\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}', + declaration: 'export interface BeginSubmissionInput {\n readonly mode: \'queue\' | \'steer\';\n readonly text: string;\n readonly attachments: readonly PendingSubmissionAttachment[];\n readonly onRetire?: (retirement: PendingSubmissionRetirement) => void;\n}', }, { name: 'BoundActions', @@ -627,19 +627,31 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PendingSubmission', - declaration: 'export interface PendingSubmission {\n readonly requestId: SessionRequestId;\n readonly placement: PendingSubmissionPlacement;\n readonly time: number;\n readonly text: string;\n readonly images: readonly PendingSubmissionImage[];\n}', + declaration: 'export interface PendingSubmission {\n readonly requestId: SessionRequestId;\n readonly placement: PendingSubmissionPlacement;\n readonly time: number;\n readonly text: string;\n readonly attachments: readonly PendingSubmissionAttachment[];\n}', + }, + { + name: 'PendingSubmissionAttachment', + declaration: 'export type PendingSubmissionAttachment = PendingSubmissionImageAttachment | PendingSubmissionFileAttachment;', + }, + { + name: 'PendingSubmissionFileAttachment', + declaration: 'export interface PendingSubmissionFileAttachment {\n readonly type: \'file\';\n readonly value: FileAttachmentRef;\n}', }, { name: 'PendingSubmissionImage', declaration: 'export interface PendingSubmissionImage {\n readonly previewUrl: string;\n readonly name?: string;\n readonly width?: number;\n readonly height?: number;\n}', }, + { + name: 'PendingSubmissionImageAttachment', + declaration: 'export interface PendingSubmissionImageAttachment {\n readonly type: \'image\';\n readonly value: PendingSubmissionImage;\n}', + }, { name: 'PendingSubmissionPlacement', declaration: 'export type PendingSubmissionPlacement = \'transcript\' | \'queued\' | \'steering\';', }, { name: 'PendingSubmissionRetirement', - declaration: 'export type PendingSubmissionRetirement = {\n readonly reason: \'observed\';\n readonly attachments: readonly ImageAttachmentRef[];\n} | {\n readonly reason: \'failed\';\n};', + declaration: 'export type PendingSubmissionRetirement = {\n readonly reason: \'observed\';\n readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[];\n} | {\n readonly reason: \'failed\';\n};', }, { name: 'ProjectionsFace', @@ -647,7 +659,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptContentPart', - declaration: 'export type PromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly mediaType: ImageMediaType;\n readonly data: string;\n readonly name?: string;\n};', + declaration: 'export type PromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly mediaType: ImageMediaType;\n readonly data: string;\n readonly name?: string;\n} | {\n readonly type: \'file\';\n readonly receiptId: Branded<\'file-upload-receipt-id\'>;\n};', }, { name: 'PromptError', 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 300857356d..78b9f15ce0 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:228', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:227', }, { key: 'conversation.chat.commandview', @@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:216', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:215', }, { key: 'conversation.chat.node', @@ -314,7 +314,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.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:197', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:196', }, { key: 'conversation.chat.turnTail', @@ -359,7 +359,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:222', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:221', }, { key: 'conversation.composer', @@ -408,7 +408,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:119', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:145', }, { key: 'conversation.composer.bar', @@ -444,7 +444,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.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:163', }, { key: 'conversation.composer.dock', @@ -497,7 +497,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157', }, { key: 'conversation.details.tool', @@ -533,7 +533,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.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:234', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:233', }, { key: 'conversation.hero.agentPreset', @@ -561,7 +561,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.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:151', }, { key: 'conversation.hero.brand.mark', @@ -587,7 +587,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:149', }, { key: 'conversation.hero.workspace', @@ -617,7 +617,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\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:147', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -652,15 +652,16 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ key: 'conversation.input.attachments', kind: 'single', scope: 'session-maybe', - summary: 'Optional draft-image rail and drop target.', - doc: 'Optional draft-image rail and drop target.', + summary: 'Optional draft-attachment rail and drop target.', + doc: 'Optional draft-attachment rail and drop target.', registerOptions: [], ownerProps: [ - '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft images in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add images now. */\n canAcceptDrop: boolean\n /** Add one dropped batch through the composer\'s validation path. */\n onAddImages: (files: readonly File[]) => void\n /** Remove one draft image through the Conversation service. */\n onRemoveImage: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', + '/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft attachments in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add attachments now. */\n canAcceptDrop: boolean\n /** Add one dropped batch through the composer\'s validation path. */\n onAddFiles: (files: readonly File[]) => void\n /** Remove one draft attachment through the Conversation service. */\n onRemoveAttachment: (id: DraftAttachmentId) => void\n /** Current per-draft upload states for file-kind attachments. */\n uploads: DraftFileUploads\n /** Restart one failed file upload. */\n onRetryFile: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}', ], ownerPropsReferences: [ 'ComposerAttachment', 'DraftAttachmentId', + 'DraftFileUploads', ], standardProps: [ 'useWorkspaces: SnapshotSelectorHook', @@ -683,7 +684,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.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:165', }, { key: 'conversation.input.dock', @@ -743,7 +744,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:153', }, { key: 'conversation.input.left', @@ -794,7 +795,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:159', }, { key: 'conversation.input.model', @@ -830,7 +831,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.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:147', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173', }, { key: 'conversation.input.overlay', @@ -884,7 +885,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:129', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:155', }, { key: 'conversation.input.plan', @@ -920,7 +921,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.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:145', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171', }, { key: 'conversation.input.right', @@ -971,7 +972,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:135', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161', }, { key: 'conversation.message.images', @@ -981,7 +982,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.', registerOptions: [], ownerProps: [ - '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}', + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n}', ], ownerPropsReferences: [ 'Message', @@ -1011,7 +1012,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.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:210', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:209', }, { key: 'conversation.session', @@ -1045,7 +1046,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.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:95', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121', }, { key: 'conversation.session.header', @@ -1079,7 +1080,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.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:97', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123', }, { key: 'conversation.session.header.actions', @@ -1137,7 +1138,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:105', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131', }, { key: 'conversation.session.header.lineage', @@ -1175,7 +1176,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.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:99', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125', }, { key: 'conversation.session.header.utilities', @@ -1230,7 +1231,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:111', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137', }, { key: 'conversation.trajectory.images', @@ -1240,7 +1241,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one group of durable record images in the Trajectory\nledger. The owner supplies image references, an authorized loader, and\nalignment. A registration replaces the shipped gallery; without one,\nimages are omitted.', registerOptions: [], ownerProps: [ - '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}', + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n}', ], ownerPropsReferences: [ 'Message', @@ -1328,7 +1329,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:117', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:143', }, { key: 'details', diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index b2f6921bc2..5db0c13745 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-host-runner", "description": "Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index 09c1bc7b3c..823396feee 100644 --- a/packages/extensions/tool-cordis/package.json +++ b/packages/extensions/tool-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-cordis", "description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 877fd07d0f..0ebb3f6692 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -482,6 +482,26 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'inputs', description: 'encoded images in owning-message order.' }], returns: 'durable normalized attachment references in the same order after every member succeeds.', }, + { + signature: 'async admitPromptContent( content: readonly AttachmentAdmissionPart[], ): Promise', + description: 'Admit one Host prompt and replace each uploaded image with its durable reference. Text and durable file references pass through unchanged. A prompt without image parts performs no storage operation.', + parameters: [{ name: 'content', description: 'prompt parts in message order after file receipt resolution.' }], + returns: 'admitted prompt parts in the same order as `content`.', + throws: ['AttachmentError when the image batch is refused.'], + }, + { + signature: 'admitEncodedFile(input: EncodedFileAttachment): Promise', + description: 'Decode and durably commit one canonical base64 file upload.', + parameters: [{ name: 'input', description: 'canonical base64 bytes and optional display name.' }], + returns: 'the durable content-addressed file reference.', + throws: ['AttachmentError when the encoding or storage operation is refused.'], + }, + { + signature: 'isAttachmentError(error: unknown): error is AttachmentError', + description: 'Identify a failure emitted by this attachment capability by its stable code.', + parameters: [{ name: 'error', description: 'value caught from an attachment operation.' }], + returns: 'whether the value is an attachment failure.', + }, { signature: 'abstract saveImage(input: SaveImageAttachment): Promise', description: 'Validate and durably commit one image before its owning session event is appended. The returned reference describes the persisted normalized image. When normalization reduces the raster, its `originalDimensions` records the orientation-applied input dimensions.', @@ -502,6 +522,31 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'an absolute host path, or undefined when this backend is not host-file-backed.', throws: ['an AttachmentError when the durable reference is invalid.'], }, + { + signature: 'saveFile(input: SaveFileAttachment): Promise', + description: 'Durably commit one file byte-for-byte before its owning session event is appended. Files carry no admission limits: any byte content and length is accepted, and the stored object is the exact submitted bytes. Backends without verbatim file storage keep this default rejection.', + parameters: [{ name: 'input', description: 'exact bytes and optional display name.' }], + returns: 'the durable content-addressed file reference.', + }, + { + signature: 'saveFileStream(input: SaveFileStreamAttachment): Promise', + description: 'Durably commit one file byte-for-byte from bounded chunks. Providers must apply backpressure and must not collect the complete file in memory. Backends without streamed verbatim storage keep this default rejection.', + parameters: [{ name: 'input', description: 'ordered exact bytes, optional cancellation, and display name.' }], + returns: 'the durable content-addressed file reference.', + }, + { + signature: 'async *readFileStream( ref: FileAttachmentRef, signal?: AbortSignal, ): AsyncIterable', + description: 'Read and verify one verbatim stored file as bounded chunks. Providers must not collect the complete file in memory. Backends without verbatim file reads keep this default rejection.', + parameters: [{ name: 'ref', description: 'durable reference from the session log.' }, { name: 'signal', description: 'optional cancellation for backend reads and verification work.' }], + returns: 'exact file bytes in order; integrity failures reject the iteration.', + }, + { + signature: 'fileHostPath(ref: FileAttachmentRef): string | undefined', + description: 'Locate the verbatim stored file object in the harness host filesystem.', + parameters: [{ name: 'ref', description: 'durable file reference.' }], + returns: 'an absolute host path, or undefined when this backend is not host-file-backed.', + throws: ['an AttachmentError when the durable reference is invalid.'], + }, { signature: 'readImageRequest( ref: ImageAttachmentRef, policy: ImageRequestPolicy, signal?: AbortSignal, ): Promise', description: 'Generate or read one deterministic model-request version from the stored normalized image.', @@ -625,6 +670,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'definition', description: 'discovery metadata and direct UI handler.' }], returns: 'the exact effect disposer that unregisters this definition.', }, + { + signature: 'registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void', + description: 'Register the sole authority that resolves staged file receipts for command submissions.', + parameters: [{ name: 'resolver', description: 'Session-aware receipt resolver.' }], + returns: 'disposer that removes this exact resolver.', + }, { signature: '@Remote list(agent: Agent): readonly CommandDescriptor[]', description: 'List the effective immutable command descriptors for one agent.', @@ -638,9 +689,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the scoped shadow or global definition.', }, { - signature: '@Remote async execute( agent: Agent, line: string, images: readonly EncodedImageAttachment[], signal: AbortSignal, ): Promise', - description: 'Parse and execute a known command without sending it to the model.\n\nA resolved command\'s lifecycle is logged: `command/run` is appended before the handler is invoked and `command/done` after settlement (a thrown or aborted handler settles as `kind: \'error\'`). Both are direct log-only appends — no turn wraps them, and persistence drains them at ordinary checkpoints. Admission misses (syntax or unknown name) log nothing — they never entered a handler. A `command/run` append failure fails the execution loud; a `command/done` append failure on the handler-failure path is contained so the handler\'s own error stays the reported failure.\n\nImage admission is enforced here, not in the composer: images sent to a command that does not declare `input.images`, an absent attachment store, and an exceeded attachment limit each settle as an error result before the handler runs, and a rejected batch publishes no durable object.', - parameters: [{ name: 'agent', description: 'exact receiving agent.' }, { name: 'line', description: 'complete slash-command line.' }, { name: 'images', description: 'base64-encoded composer images accompanying the line, in submission order; empty for a plain invocation.' }, { name: 'signal', description: 'cancellation signal owned by the UI request.' }], + signature: '@Remote async execute( agent: Agent, line: string, submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise', + description: 'Parse and execute a known command without sending it to the model.\n\nA resolved command\'s lifecycle is logged: `command/run` is appended before the handler is invoked and `command/done` after settlement (a thrown or aborted handler settles as `kind: \'error\'`). Both are direct log-only appends — no turn wraps them, and persistence drains them at ordinary checkpoints. Admission misses (syntax or unknown name) log nothing — they never entered a handler. A `command/run` append failure fails the execution loud; a `command/done` append failure on the handler-failure path is contained so the handler\'s own error stays the reported failure.\n\nAttachment admission is enforced here, not in the composer: attachments sent to a command that does not declare `input.attachments`, an absent attachment store, and an exceeded image limit each settle as an error result before the handler runs. Validation rejection starts no attachment writes; a storage failure can leave only unreachable content-addressed objects for deferred collection.', + parameters: [{ name: 'agent', description: 'exact receiving agent.' }, { name: 'line', description: 'complete slash-command line.' }, { name: 'submittedAttachments', description: 'encoded images and staged file receipts accompanying the line, in submission order; empty for a plain invocation.' }, { name: 'signal', description: 'cancellation signal owned by the UI request.' }], returns: 'the settled execution (result + lifecycle pairing id), or `undefined` when syntax or name does not resolve.', }, ], @@ -850,6 +901,48 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'fileUploads', + summary: 'Host service owning upload storage and Agent-scoped staged receipts.', + description: 'Host service owning upload storage and Agent-scoped staged receipts.', + methods: [ + { + signature: 'registerAgentResolver(resolve: AgentResolver): () => void', + description: 'Register the ordinary-Session resolver used when a raw upload addresses a cold Session.', + parameters: [{ name: 'resolve', description: 'resolver that returns the exact live Agent or throws a Remote error.' }], + returns: 'disposer removing this resolver.', + }, + { + signature: '@Remote(\'upload\') upload(agent: Agent, request: EncodedFileUploadRequest, signal: AbortSignal): Promise', + description: 'Persist one encoded upload and stage it under the Agent receiver selected by Typert.', + parameters: [{ name: 'agent', description: 'receiving Agent resolved from the Remote Agent scope.' }, { name: 'request', description: 'canonical base64 bytes and optional display name.' }, { name: 'signal', description: 'caller cancellation before storage begins.' }], + returns: 'the staged receipt and durable file reference.', + }, + { + signature: 'async uploadStream(request: { readonly sessionId: SessionId readonly data: AsyncIterable readonly signal?: AbortSignal readonly name?: string }): Promise', + description: 'Persist raw chunks for one Session without aggregating the upload.', + parameters: [{ name: 'request', description: 'Session identity, ordered bytes, cancellation, and optional display name.' }], + returns: 'the staged receipt and durable file reference.', + }, + { + signature: 'resolve(agent: Agent, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined', + description: 'Resolve one staged receipt inside its receiving Agent scope.', + parameters: [{ name: 'agent', description: 'receiving Agent.' }, { name: 'receiptId', description: 'opaque receipt minted for one completed upload.' }], + returns: 'durable file reference, or `undefined` for an unknown or foreign receipt.', + }, + { + signature: 'bindPrompt( agent: Agent, receiptIds: readonly FileUploadReceiptId[], requestId: string, ): PromptFileBinding', + description: 'Bind receipts while one prompt enters an Agent inbox. Disposal restores every prior binding unless the caller commits successful delivery.', + parameters: [{ name: 'agent', description: 'receiving Agent.' }, { name: 'receiptIds', description: 'distinct staged receipts referenced by the prompt.' }, { name: 'requestId', description: 'prompt identity later observed in queue or history.' }], + returns: 'binding kept after commit until queue or history observation retires its receipts.', + }, + { + signature: 'retirePrompt(agent: Agent, requestId: string): void', + description: 'Retire every receipt accepted by one removed queue occurrence.', + parameters: [{ name: 'agent', description: 'receiving Agent.' }, { name: 'requestId', description: 'prompt identity carried by the queue occurrence.' }], + }, + ], + }, { key: 'fs', summary: 'Abstract filesystem provider.', @@ -1154,6 +1247,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'provider', description: 'provider route named by a request header.' }, { name: 'model', description: 'exact model id named by the same header.' }], returns: 'the owning adapter\'s image pricing for the route, when declared.', }, + { + signature: 'fileRequestText(ref: FileAttachmentRef): string', + description: 'Resolve the exact text one durable file occurrence contributes to every provider request in the current execution environment.', + parameters: [{ name: 'ref', description: 'durable verbatim file reference from model history.' }], + returns: 'the same deterministic handle text used at adapter dispatch.', + }, { signature: 'async listModels(provider: string): Promise', description: 'Discover models advertised by one registered provider. Catalog membership is advisory and never changes routing or request validation.', @@ -2792,8 +2891,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'async create(path: string, title?: string): Promise', - description: 'Create or reuse a workspace for an existing directory. The path is canonicalized through `fs.realpath`; a nonexistent path rejects with the original error and a non-directory rejects. Repeated calls for the same canonical path return the existing entity without changing its title. A newly created workspace is prepended to the durable registry order. Different canonical paths may share a display title.', - parameters: [{ name: 'path', description: 'Existing directory to own, in any path spelling.' }, { name: 'title', description: 'Display title used only when a new record is created.' }], + description: 'Create or reuse a workspace for an existing directory. The fully qualified path is canonicalized through `fs.realpath`; a relative, nonexistent, or non-directory path rejects. Repeated calls for the same canonical path return the existing entity without changing its title. A newly created workspace is prepended to the durable registry order. Different canonical paths may share a display title.', + parameters: [{ name: 'path', description: 'Existing directory to own, in a fully qualified path spelling.' }, { name: 'title', description: 'Display title used only when a new record is created.' }], returns: 'the existing or newly durable workspace.', }, { @@ -2829,7 +2928,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: 'async resolveByPath(path: string): Promise', description: 'Resolve by canonical directory path without creating or mutating a workspace. A missing path rejects during `realpath`; an existing unowned directory returns `undefined`.', - parameters: [{ name: 'path', description: 'Existing directory path in any spelling.' }], + parameters: [{ name: 'path', description: 'Existing directory path in a fully qualified spelling.' }], returns: 'the workspace owning the canonical path, when one exists.', }, ], @@ -3374,6 +3473,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AdapterRegistrationHandle', declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', }, + { + name: 'AdmittedPromptContentPart', + declaration: 'export type AdmittedPromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly attachment: ImageAttachmentRef;\n} | {\n readonly type: \'file\';\n readonly attachment: FileAttachmentRef;\n};', + }, { name: 'Agent', declaration: 'export interface Agent {\n readonly id: SessionId;\n}', @@ -3422,6 +3525,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentPresetRow', declaration: 'export interface AgentPresetRow {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly isDefault: boolean;\n readonly name?: string;\n readonly description?: string;\n readonly broken?: string;\n}', }, + { + name: 'AgentResolver', + declaration: 'export type AgentResolver = (sessionId: SessionId) => Promise;', + }, { name: 'AgentSetup', declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise | void;', @@ -3518,6 +3625,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AssistantStreamRecord', declaration: 'export type AssistantStreamRecord = {\n readonly type: \'text-chunks\';\n readonly time0: number;\n readonly index: number;\n readonly dt: readonly number[];\n readonly texts: readonly string[];\n} | {\n readonly type: \'reasoning-chunks\';\n readonly time0: number;\n readonly index: number;\n readonly dt: readonly number[];\n readonly texts: readonly string[];\n} | {\n readonly type: \'tool-call-chunks\';\n readonly time0: number;\n readonly index: number;\n readonly dt: readonly number[];\n readonly id: ToolCallId;\n readonly name?: string;\n readonly args: readonly string[];\n} | {\n readonly type: \'chunk\';\n readonly time: number;\n readonly chunk: StreamChunk;\n};', }, + { + name: 'AttachmentAdmissionPart', + declaration: 'export type AttachmentAdmissionPart = PromptContentPart | {\n readonly type: \'file\';\n readonly attachment: FileAttachmentRef;\n};', + }, + { + name: 'AttachmentError', + declaration: 'export class AttachmentError extends Error {\n readonly code: AttachmentErrorCode;\n constructor(message: string, code: AttachmentErrorCode, options?: ErrorOptions);\n}', + }, + { + name: 'AttachmentErrorCode', + declaration: 'export type AttachmentErrorCode = typeof ATTACHMENT_ERROR_CODES[number];', + }, { name: 'AttachmentId', declaration: 'export type AttachmentId = Branded<\'AttachmentId\'>;', @@ -3642,22 +3761,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CommandExecution', declaration: 'export interface CommandExecution {\n readonly commandId: CommandId;\n readonly result: CommandResult;\n}', }, + { + name: 'CommandFileReceiptResolver', + declaration: 'export type CommandFileReceiptResolver = (agent: Agent, receiptId: string) => FileAttachmentRef | undefined;', + }, { name: 'CommandId', declaration: 'export type CommandId = Branded<\'CommandId\'>;', }, { name: 'CommandInputDescriptor', - declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n readonly images?: boolean;\n}', + declaration: 'export interface CommandInputDescriptor {\n readonly hint: string;\n readonly attachments?: boolean;\n}', }, { name: 'CommandInvocation', - declaration: 'export interface CommandInvocation {\n readonly commandId: CommandId;\n readonly agent: Agent;\n readonly rawInput: string;\n readonly attachments: readonly ImageBlock[];\n readonly signal: AbortSignal;\n}', + declaration: 'export interface CommandInvocation {\n readonly commandId: CommandId;\n readonly agent: Agent;\n readonly rawInput: string;\n readonly attachments: readonly (ImageBlock | FileBlock)[];\n readonly signal: AbortSignal;\n}', }, { name: 'CommandResult', declaration: 'export type CommandResult = {\n readonly kind: \'success\';\n readonly text?: string;\n readonly sourceEventSeq?: SessionSeq;\n} | {\n readonly kind: \'error\';\n readonly text: string;\n};', }, + { + name: 'CommandSubmitAttachment', + declaration: 'export type CommandSubmitAttachment = ({\n readonly type: \'image\';\n} & EncodedImageAttachment) | {\n readonly type: \'file\';\n readonly receiptId: string;\n};', + }, { name: 'CompactionAgentContext', declaration: 'export interface CompactionAgentContext {\n session: Session;\n options: {\n provider?: string;\n model?: string;\n };\n}', @@ -3688,7 +3815,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ContentBlockMap', - declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'image\': ImageBlock;\n \'tool-call\': ToolCallBlock;\n \'tool-result\': ToolResultBlock;\n}', + declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'image\': ImageBlock;\n \'file\': FileBlock;\n \'tool-call\': ToolCallBlock;\n \'tool-result\': ToolResultBlock;\n}', }, { name: 'ContentBlockType', @@ -3958,6 +4085,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'EditGoalRequest', declaration: 'export interface EditGoalRequest {\n readonly objective?: string;\n readonly maxGoalRounds?: number;\n}', }, + { + name: 'EncodedFileAttachment', + declaration: 'export interface EncodedFileAttachment {\n data: string;\n name?: string;\n}', + }, + { + name: 'EncodedFileUploadRequest', + declaration: 'export interface EncodedFileUploadRequest {\n readonly data: string;\n readonly name?: string;\n}', + }, { name: 'EncodedImageAttachment', declaration: 'export interface EncodedImageAttachment {\n mediaType: ImageMediaType;\n data: string;\n name?: string;\n}', @@ -3970,6 +4105,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'FiberState', declaration: 'export type FiberState = FiberStateEnum;', }, + { + name: 'FileAttachmentRef', + declaration: 'export interface FileAttachmentRef {\n attachmentId: AttachmentId;\n name: string;\n bytes: number;\n}', + }, + { + name: 'FileBlock', + declaration: 'export interface FileBlock {\n type: \'file\';\n attachment: FileAttachmentRef;\n}', + }, { name: 'FileDiff', declaration: 'export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n}', @@ -3982,6 +4125,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'FileReferenceCandidate', declaration: 'export interface FileReferenceCandidate {\n path: string;\n kind: \'file\' | \'directory\';\n}', }, + { + name: 'FileUploadReceiptId', + declaration: 'export type FileUploadReceiptId = Branded<\'file-upload-receipt-id\'>;', + }, + { + name: 'FileUploadValue', + declaration: 'export interface FileUploadValue {\n readonly receiptId: FileUploadReceiptId;\n readonly file: FileAttachmentRef;\n}', + }, { name: 'FinishReason', declaration: 'export type FinishReason = FinishReasonMap[keyof FinishReasonMap];', @@ -4304,7 +4455,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmRuntime', - declaration: 'export class LlmRuntime extends TypertRemoteService {\n constructor(ctx: Context);\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n @Remote\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n @Remote\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal): Promise;\n @Remote(\'discoverModels\')\n async remoteDiscoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal): Promise;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined;\n async listModels(provider: string): Promise;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise;\n stream(options: GenerateOptions): AsyncIterable;\n}', + declaration: 'export class LlmRuntime extends TypertRemoteService {\n constructor(ctx: Context);\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n @Remote\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n @Remote\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal): Promise;\n @Remote(\'discoverModels\')\n async remoteDiscoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal): Promise;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined;\n fileRequestText(ref: FileAttachmentRef): string;\n async listModels(provider: string): Promise;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise;\n stream(options: GenerateOptions) /* …truncated — full shape in source */', }, { name: 'LspHover', @@ -4574,6 +4725,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PromptContextOrderName', declaration: 'export type PromptContextOrderName = keyof typeof CONTEXT_ORDERS;', }, + { + name: 'PromptFileBinding', + declaration: 'export interface PromptFileBinding extends Disposable {\n commit(): void;\n}', + }, { name: 'PromptSection', declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly complete?: boolean;\n}', @@ -4714,6 +4869,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SandboxPolicyRequest', declaration: 'export interface SandboxPolicyRequest {\n session?: Session;\n mode?: SandboxMode;\n}', }, + { + name: 'SaveFileAttachment', + declaration: 'export interface SaveFileAttachment {\n data: Uint8Array;\n name?: string;\n}', + }, + { + name: 'SaveFileStreamAttachment', + declaration: 'export interface SaveFileStreamAttachment {\n data: AsyncIterable;\n signal?: AbortSignal;\n name?: string;\n}', + }, { name: 'SaveImageAttachment', declaration: 'export interface SaveImageAttachment {\n data: Uint8Array;\n mediaType: ImageMediaType;\n name?: string;\n}', diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index 7b33be2d8d..b8082543be 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-cordis", "description": "Cordis dynamic-plugin definition card: the keyed cordis_define tool row with its run/stop switch", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 5ca0cf252c..c3c6a8892c 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-feedback", "description": "Log-only session feedback producer and human-facing slash command", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index d5cdde2896..0b5c98ffd7 100644 --- a/packages/feedback/message-feedback/package.json +++ b/packages/feedback/message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-message-feedback", "description": "Lifecycle-bound per-message rating and note sidecar for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index 677d66b72c..63ec5ead39 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-local", "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-observation-policy/package.json b/packages/fs/fs-observation-policy/package.json index 14e067aea7..5f200342d5 100644 --- a/packages/fs/fs-observation-policy/package.json +++ b/packages/fs/fs-observation-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-observation-policy", "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service API)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 76dc9df57e..cb091195cb 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-sandbox", "description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index 435086a301..95ae891808 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -100,9 +100,14 @@ describe('workspace-write containment', () => { }) it('a write to the platform temp area lands (parity with the bash runner grant)', async () => { - const path = join(await mkdtemp(join(tmpdir(), 'dsh-fssbx-tmp-')), 'temp.txt') - await fs.writeText(await target(path), 'temp') - expect(await readFile(path, 'utf8')).toBe('temp') + const dir = await mkdtemp(join(tmpdir(), 'dsh-fssbx-tmp-')) + try { + const path = join(dir, 'temp.txt') + await fs.writeText(await target(path), 'temp') + expect(await readFile(path, 'utf8')).toBe('temp') + } finally { + await rm(dir, { recursive: true, force: true }) + } }) it('an absolute path outside the workspace is denied, no file created', async () => { diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 5fcb183245..831d5161a2 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs", "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 8dbcd78189..ddcb0f65e7 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs-search", "description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 73e0e495cf..8573dc403a 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs", "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 82cc4469c6..961cd397ec 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -132,8 +132,8 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxController): void ) } catch (error: unknown) { // A sandbox denial becomes the shared [sandbox: …] marker (the model - // recognizes it from bash); stale/not-observed failures gain their - // model-facing remedy; anything else passes through. + // recognizes it from bash); guarded mutation failures receive their + // stable model-facing diagnostic; anything else passes through. throw remediateFsError(sandbox.mapError(error, sandboxPolicy), target.displayPath) } ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec) diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index 5b7d066fba..de69826d8f 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -113,8 +113,8 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxController): void outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy) } catch (error: unknown) { // A sandbox denial becomes the shared [sandbox: …] marker (the model - // recognizes it from bash); stale/not-observed failures gain their - // model-facing remedy; anything else passes through. + // recognizes it from bash); guarded mutation failures receive their + // stable model-facing diagnostic; anything else passes through. throw remediateFsError(sandbox.mapError(error, sandboxPolicy), target.displayPath) } ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec) diff --git a/packages/fs/tool-fs/tests/error.spec.ts b/packages/fs/tool-fs/tests/error.spec.ts index e424c3e852..bab0182149 100644 --- a/packages/fs/tool-fs/tests/error.spec.ts +++ b/packages/fs/tool-fs/tests/error.spec.ts @@ -1,6 +1,6 @@ /** - * Unit tests for the model-facing error remediation: the remedy appended to - * guarded-mutation failures, code preservation, and passthrough behavior. + * Unit tests for model-facing guarded-mutation diagnostics: normalized unread + * failures, the stale-version remedy, code preservation, and passthrough. */ import { describe, expect, it } from 'vitest' diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index d989328728..5181e1408e 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-str-replace-editor", "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/goal/command-goal/README.i18n.yaml b/packages/goal/command-goal/README.i18n.yaml index 82e85ba55b..2b7e6db63f 100644 --- a/packages/goal/command-goal/README.i18n.yaml +++ b/packages/goal/command-goal/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/goal/command-goal/README.md -README.md: e602035323eae61df74df06569b934a641140ec0 -README.zh.md: 10438236c40693f97433ce554ad061df5bf47ea3 +README.md: d8f49882d404bfe68e203c7445001cb648d9282a +README.zh.md: 00db532e97a39175579c5299c7ea598b07be0440 diff --git a/packages/goal/command-goal/README.md b/packages/goal/command-goal/README.md index e602035323..d8f49882d4 100644 --- a/packages/goal/command-goal/README.md +++ b/packages/goal/command-goal/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-command-goal` gives the human `/goal` command over the persisted goal service: a user can create, edit, pause, resume, clear, and inspect the current goal directly from the UI, without involving the model. The command registers in its Cordis scope, so command adapters reading that scope discover and execute it, while command text and output stay in the UI — they never enter model requests. Every accepted mutation persists through the goal service's durable `goal/change` event. Image attachments may accompany a create or edit and are submitted as one ordinary user message so later goal rounds see them. Choose it for interactive deployments with a command adapter; headless and automation apps without one do not need it. +`dsh-command-goal` gives the human `/goal` command over the persisted goal service: a user can create, edit, pause, resume, clear, and inspect the current goal directly from the UI, without involving the model. The command registers in its Cordis scope, so command adapters reading that scope discover and execute it, while command text and output stay in the UI — they never enter model requests. Every accepted mutation persists through the goal service's durable `goal/change` event. Ordered image and file attachments may accompany a create or edit and are submitted as one ordinary user message so later goal rounds see them. Choose it for interactive deployments with a command adapter; headless and automation apps without one do not need it. ## Table of Contents @@ -44,9 +44,9 @@ Every sub-command runs against the current goal of the invoking agent; a bare `/ Control words (`clear`, `pause`, `resume`, `edit`) are recognized only when they occupy the complete input; any other non-empty suffix is an objective, so `/goal pause after verification` creates that literal objective. `edit` takes its replacement inline and refuses to replace an unfinished goal directly. Expected domain rejections become stable, direct command errors without exposing branded ids or revisions; unexpected implementation failures still fail dispatch so adapters can report them as command failures. -### Image attachments +### Attachments -`/goal` declares image support, so a composer may attach images to an invocation. Attachments only accompany an objective: on a successful create or edit the command submits one user followup carrying the admitted image blocks plus the fixed text `Reference images for the goal objective.`, so later goal rounds read them from ordinary session history without the goal domain storing attachment state. Every other sub-command, and any refused create or edit, returns a direct error and submits nothing, so the dispatching composer keeps the images. +`/goal` declares attachment support. Attachments accompany only an objective: after a successful create or edit, the command submits one user followup carrying the admitted image and file blocks in selection order plus the fixed text `Reference attachments for the goal objective.` Later goal rounds read that ordinary session history; the goal domain stores no attachment state. Every other sub-command, and any refused create or edit, returns a direct error before a domain mutation and leaves the dispatching composer's draft and cards intact. ### Compose it @@ -77,7 +77,7 @@ This section explains how the command parses input and renders output; the obser - **Grammar, not free text.** The parser recognizes only the exact control words (`clear`, `pause`, `resume`, `edit`) when they fill the whole input; every other non-empty suffix is an objective. `edit` alone is invalid, and `edit` refuses to replace an unfinished goal directly. - **Domain rejections become stable errors.** `GoalError` outcomes are converted into direct command errors with a fixed message; unexpected failures rethrow so adapters report a command failure rather than a domain result. Rendered output never exposes branded ids or revisions. -- **Attachments ride the objective.** On a successful create or edit the command submits one user followup carrying the admitted image blocks plus the fixed text `Reference images for the goal objective.`; every other path submits nothing, so the dispatching composer keeps the images. +- **Attachments accompany the objective.** On a successful create or edit, the command submits one user followup carrying the admitted image and file blocks in selection order plus the fixed text `Reference attachments for the goal objective.` Every other path submits nothing, so the dispatching composer keeps the draft and cards. ### Source map @@ -108,11 +108,11 @@ The command is a thin adapter over the goal domain; read these pages for the sta #### What the model sees -The slash input, mutation, and direct status/error output are absent from model requests. The goal domain records the mutation as `goal/change`; an enabled same-session driver may expose the resulting state in a later continuation prompt. Presentation text is never logged. When a create or edit carries image attachments, the model sees one ordinary user message: the image blocks followed by the text `Reference images for the goal objective.`; it precedes the next goal round in session history. +The slash input, mutation, and direct status/error output are absent from model requests. The goal domain records the mutation as `goal/change`; an enabled same-session driver may expose the resulting state in a later continuation prompt. Presentation text is never logged. When a create or edit carries attachments, the model sees one ordinary user message: the ordered image and file blocks followed by the text `Reference attachments for the goal objective.` It precedes the next goal round in session history. #### Token effect -Reading status, mutating a goal, or receiving a direct command error adds no model tokens. An enabled same-session driver may add later goal-round prompts. An objective's image attachments add one user message billed like any image prompt. +Reading status, mutating a goal, or receiving a direct command error adds no model tokens. An enabled same-session driver may add later goal-round prompts. An objective's attachments add one ordinary user message with the normal text, image, and file-handle costs. #### KV Cache effect diff --git a/packages/goal/command-goal/README.zh.md b/packages/goal/command-goal/README.zh.md index 10438236c4..00db532e97 100644 --- a/packages/goal/command-goal/README.zh.md +++ b/packages/goal/command-goal/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-command-goal` 为用户提供基于持久 goal 服务的 `/goal` 命令:用户可以直接在 UI 中创建、编辑、暂停、恢复、清除并查看当前 goal,无需模型参与。命令在其 Cordis scope 中注册,因此读取该 scope 的命令适配器能发现并执行它;命令文本与输出都留在 UI 中——绝不进入模型请求。每项被接受的变更都会通过 goal 服务的持久 `goal/change` 事件落盘。图片附件可以随 create 或 edit 一起提交,并以一条普通用户消息发出,供后续 Goal Round 读取。为挂载了命令适配器的交互式部署选择它;没有适配器的无头与自动化应用不需要它。 +`dsh-command-goal` 为用户提供基于持久 goal 服务的 `/goal` 命令:用户可以直接在 UI 中创建、编辑、暂停、恢复、清除并查看当前 goal,无需模型参与。命令在其 Cordis scope 中注册,因此读取该 scope 的命令适配器能发现并执行它;命令文本与输出都留在 UI 中,绝不进入模型请求。每项被接受的变更都会通过 goal 服务的持久 `goal/change` 事件落盘。有序的图片与文件附件可以随 create 或 edit 一起提交,并以一条普通用户消息发出,供后续 Goal Round 读取。为挂载了命令适配器的交互式部署选择它;没有适配器的无头与自动化应用不需要它。 ## 目录 @@ -44,9 +44,9 @@ kind: "package-reference" 只有控制词(`clear`、`pause`、`resume`、`edit`)占据完整输入时才被识别;其他任何非空后缀都是目标,因此 `/goal pause after verification` 会创建该字面目标。`edit` 内联接收替换内容,并拒绝直接替换未完成的 goal。可预期的领域拒绝会变成稳定的直接命令错误,不暴露带品牌类型的 id 或 revision;意外实现失败仍会让分发失败,使适配器能将其报告为命令失败。 -### 图片附件 +### 附件 -`/goal` 声明了图片支持,因此 composer 可以随调用附加图片。附件只随目标本身:create 或 edit 成功时,命令提交一条用户 followup 消息,携带已准入的图片块加固定文本 `Reference images for the goal objective.`,后续 Goal Round 从普通会话历史读取它们,goal 领域不存储附件状态。其他任何子命令、以及被拒绝的 create 或 edit,都直接返回错误且不提交任何消息,分发方 composer 保留图片。 +`/goal` 声明附件支持。附件只随目标本身:create 或 edit 成功后,命令提交一条用户 followup 消息,按选择顺序携带已准入的图片块与文件块,再附加固定文本 `Reference attachments for the goal objective.`。后续 Goal Round 从普通会话历史读取这些内容,goal 领域不存储附件状态。其他任何子命令以及被拒绝的 create 或 edit,都会在领域变更前返回直接错误,并保留 composer 的草稿和附件卡。 ### 组合方式 @@ -77,7 +77,7 @@ kind: "package-reference" - **语法,而非自由文本。** 解析器只在控制词(`clear`、`pause`、`resume`、`edit`)填满整个输入时识别它们;其他任何非空后缀都是目标。单独的 `edit` 无效,且 `edit` 拒绝直接替换未完成的 goal。 - **领域拒绝变成稳定错误。** `GoalError` 结果会转换为带固定消息的直接命令错误;意外失败会重新抛出,使适配器报告命令失败而非领域结果。渲染输出绝不暴露带品牌类型的 id 或 revision。 -- **附件随目标而行。** create 或 edit 成功时,命令提交一条用户 followup 消息,携带已准入的图片块加固定文本 `Reference images for the goal objective.`;其他路径都不提交任何消息,因此分发方 composer 保留图片。 +- **附件随目标提交。** create 或 edit 成功时,命令提交一条用户 followup 消息,按选择顺序携带已准入的图片块与文件块,再附加固定文本 `Reference attachments for the goal objective.`。其他路径不提交消息,因此分发方 composer 保留草稿和附件卡。 ### 源码地图 @@ -108,11 +108,11 @@ kind: "package-reference" #### 模型看到的内容 -斜杠输入、变更以及直接状态/错误输出不会进入模型请求。goal 领域把变更记录为 `goal/change`;已启用的同会话驱动器可以在后续续行提示词中暴露结果状态。呈现文本绝不会记录到日志中。当 create 或 edit 携带图片附件时,模型会看到一条普通用户消息:图片块后跟文本 `Reference images for the goal objective.`,在会话历史中位于下一个 Goal Round 之前。 +斜杠输入、变更以及直接状态或错误输出不会进入模型请求。goal 领域把变更记录为 `goal/change`;已启用的同会话驱动器可以在后续续行提示词中暴露结果状态。呈现文本不会记录到日志中。当 create 或 edit 携带附件时,模型会看到一条普通用户消息:有序的图片块与文件块后跟文本 `Reference attachments for the goal objective.`。它在会话历史中位于下一个 Goal Round 之前。 #### Token 影响 -读取状态、变更 goal 或收到直接命令错误不会增加模型 token。已启用的同会话驱动器可能增加后续 Goal Round 提示词。目标携带的图片附件会增加一条用户消息,其计费与任何图片提示词相同。 +读取状态、变更 goal 或收到直接命令错误不会增加模型 token。已启用的同会话驱动器可能增加后续 Goal Round 提示词。目标携带的附件会增加一条普通用户消息,产生常规文本、图片和文件句柄成本。 #### KV Cache 影响 diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index fa35205ae5..f0a7f052b2 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-goal", "description": "Human-facing slash command for persisted same-session goals", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/goal/command-goal/src/index.ts b/packages/goal/command-goal/src/index.ts index e1b6e79eed..b9e64ee7f2 100644 --- a/packages/goal/command-goal/src/index.ts +++ b/packages/goal/command-goal/src/index.ts @@ -108,15 +108,15 @@ function missingGoal(action: string): CommandResult { } /** - * Submit the invocation's admitted composer images as one model-visible user - * message ahead of the goal's next round. The images precede a fixed text + * Submit the invocation's admitted composer attachments as one model-visible user + * message ahead of the goal's next round. The attachments precede a fixed text * block naming their role, so a later goal round reads them from ordinary * session history without the goal domain storing attachment state. */ function submitObjectiveAttachments(invocation: CommandInvocation): void { if (invocation.attachments.length === 0) return invocation.agent.followup(createUserMessage({ - content: [...invocation.attachments, { type: 'text', text: 'Reference images for the goal objective.' }], + content: [...invocation.attachments, { type: 'text', text: 'Reference attachments for the goal objective.' }], source: { kind: 'user' }, })) } @@ -127,7 +127,7 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman if (invocation.attachments.length > 0 && command.kind !== 'create' && command.kind !== 'edit') { return { kind: 'error', - text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', + text: 'Attachments only accompany a goal objective: /goal or /goal edit .', } } try { @@ -190,7 +190,7 @@ export function apply(ctx: Context): void { ctx.commands.register({ name: 'goal', description: 'set or view the goal for a long-running task', - input: { hint: '[|clear|edit |pause|resume]', images: true }, + input: { hint: '[|clear|edit |pause|resume]', attachments: true }, handler: invocation => executeGoalCommand(ctx, invocation), }) } diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 1f1cdf0753..ed53189f7d 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -99,7 +99,7 @@ describe('@deepseek-ai/dsh-command-goal registration', () => { expect(test.ctx.commands.list(test.agent)).toContainEqual({ name: 'goal', description: 'set or view the goal for a long-running task', - input: { hint: '[|clear|edit |pause|resume]', images: true }, + input: { hint: '[|clear|edit |pause|resume]', attachments: true }, }) expect(test.ctx.commands.find(test.agent, 'goal')).toBeDefined() @@ -236,7 +236,7 @@ describe('/goal human command', () => { }) }) -describe('/goal image attachments', () => { +describe('/goal attachments', () => { const PNG = 'AAAA' /** Wire the fake store the executor admits through (once per harness). */ @@ -261,25 +261,35 @@ describe('/goal image attachments', () => { for (const input of inputs) refs.push(await saveImage(input)) return refs }, + saveFile(input: { data: Uint8Array; name?: string }) { + saved += 1 + return Promise.resolve({ + attachmentId: `att-${saved}`, bytes: input.data.byteLength, name: input.name ?? 'attachment', + }) + }, }) + test.ctx.commands.registerFileReceiptResolver((_agent, receiptId) => receiptId === 'receipt-notes' + ? { attachmentId: 'file-notes' as never, bytes: 5, name: 'notes.txt' } + : undefined) } - /** Run /goal with `count` composer images through the executor boundary. */ - async function runWithImages(test: Harness, suffix: string, count: number) { - const images = Array.from({ length: count }, (_, index) => ({ - mediaType: 'image/png' as const, data: PNG, name: `ref-${index + 1}.png`, - })) - const execution = await test.ctx.commands.execute(test.agent, `/goal${suffix}`, images, new AbortController().signal) + /** Run /goal with a mixed composer batch through the executor boundary. */ + async function runWithAttachments(test: Harness, suffix: string, includeFile = true) { + const attachments = [ + { type: 'image' as const, mediaType: 'image/png' as const, data: PNG, name: 'ref.png' }, + ...(includeFile ? [{ type: 'file' as const, receiptId: 'receipt-notes' }] : []), + ] + const execution = await test.ctx.commands.execute(test.agent, `/goal${suffix}`, attachments, new AbortController().signal) if (execution === undefined) throw new Error('goal command was not registered') return execution.result } - it('submits one user followup carrying the admitted images ahead of the round prompt', async () => { + it('submits one user followup carrying mixed attachments ahead of the round prompt', async () => { const test = await harness() provideStore(test) const followup = vi.fn() ;(test.agent as unknown as { followup: typeof followup }).followup = followup - const result = await runWithImages(test, ' rebuild the cathedral', 2) + const result = await runWithAttachments(test, ' rebuild the cathedral') expect(result.kind).toBe('success') expect(followup).toHaveBeenCalledTimes(1) const message = followup.mock.calls[0]?.[0] as { @@ -287,9 +297,10 @@ describe('/goal image attachments', () => { source: { kind: string } } expect(message.source).toEqual({ kind: 'user' }) - expect(message.content.map(block => block.type)).toEqual(['image', 'image', 'text']) - expect(message.content.at(-1)).toEqual({ type: 'text', text: 'Reference images for the goal objective.' }) - expect((message.content[0] as { attachment: { name: string } }).attachment.name).toBe('ref-1.png') + expect(message.content.map(block => block.type)).toEqual(['image', 'file', 'text']) + expect(message.content.at(-1)).toEqual({ type: 'text', text: 'Reference attachments for the goal objective.' }) + expect((message.content[0] as { attachment: { name: string } }).attachment.name).toBe('ref.png') + expect((message.content[1] as { attachment: { name: string } }).attachment.name).toBe('notes.txt') }) it('accompanies an edit and a post-complete recreate the same way', async () => { @@ -298,7 +309,7 @@ describe('/goal image attachments', () => { const followup = vi.fn() ;(test.agent as unknown as { followup: typeof followup }).followup = followup test.ctx.goals.create(test.agent, { objective: 'initial objective' }) - const result = await runWithImages(test, ' edit refined objective', 1) + const result = await runWithAttachments(test, ' edit refined objective') expect(result.kind).toBe('success') expect(followup).toHaveBeenCalledTimes(1) }) @@ -310,10 +321,10 @@ describe('/goal image attachments', () => { ;(test.agent as unknown as { followup: typeof followup }).followup = followup test.ctx.goals.create(test.agent, { objective: 'active objective' }) for (const suffix of [' pause', '', ' clear']) { - const result = await runWithImages(test, suffix, 1) + const result = await runWithAttachments(test, suffix, false) expect(result).toEqual({ kind: 'error', - text: 'Image attachments only accompany a goal objective: /goal or /goal edit .', + text: 'Attachments only accompany a goal objective: /goal or /goal edit .', }) } expect(followup).not.toHaveBeenCalled() @@ -326,7 +337,7 @@ describe('/goal image attachments', () => { const followup = vi.fn() ;(test.agent as unknown as { followup: typeof followup }).followup = followup test.ctx.goals.create(test.agent, { objective: 'existing objective' }) - const result = await runWithImages(test, ' replacement objective', 1) + const result = await runWithAttachments(test, ' replacement objective') expect(result.kind).toBe('error') expect(followup).not.toHaveBeenCalled() }) diff --git a/packages/goal/goal-round-driver/package.json b/packages/goal/goal-round-driver/package.json index 9fd840242c..5d92983dfa 100644 --- a/packages/goal/goal-round-driver/package.json +++ b/packages/goal/goal-round-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal-round-driver", "description": "Race-fenced same-session goal-round driver", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index b993422f28..4fea0e0ea4 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal", "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 73e3703fe6..7728e2e7d5 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-goal", "description": "Model-facing same-session goal tools with execution-time authority checks", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index 9ea544213b..0d78b4ebca 100644 --- a/packages/guard/repeat-tool-reminder/package.json +++ b/packages/guard/repeat-tool-reminder/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-repeat-tool-reminder", "description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index 31901ecb93..5894cdb73a 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-call-timeout-policy", "description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 6e19b86b0e..b6d7f06eda 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hook-protocol", "description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index b5f306a92a..ab21dca419 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-claude-code", "description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 6883eb43e5..b8780bcead 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-codex", "description": "Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index e1bb6176ef..1d0ef7641e 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-auto", "description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index cc1063a632..3372f690ff 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-browse", "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 33d6b5d039..f855f8805c 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-native", "description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index 9e7ba5bd7d..3cc0d9a1b5 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker", "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index 432cdc9563..c4d48497c6 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-frontend-static", "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving explicit index entries and static assets with traversal rejection and 404 misses", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 9df3be70b0..1e74c9f84b 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-plugin-inventory", "description": "Read-only Remote projection of current Cordis Loader plugin state", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index eeb08fe3f4..d69146aeec 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-webserver", "description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json index 1ac9451de7..2303d203bb 100644 --- a/packages/identity/anonymous-user-id/package.json +++ b/packages/identity/anonymous-user-id/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-anonymous-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/commands/README.i18n.yaml b/packages/interaction/commands/README.i18n.yaml index c5737c521f..90922a259a 100644 --- a/packages/interaction/commands/README.i18n.yaml +++ b/packages/interaction/commands/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/interaction/commands/README.md -README.md: 4f7a1f4475bf7edeb8e3bae6eec8b8a04b411c3d -README.zh.md: af9a23adf6a88a0b046760ece6e3cf1cdaac1fd6 +README.md: cad2e7c3fc9a17a21679376b1d1a921efb72918a +README.zh.md: abb538d6ad4988e3ad105b822e19365340fdfd1c diff --git a/packages/interaction/commands/README.md b/packages/interaction/commands/README.md index 4f7a1f4475..cad2e7c3fc 100644 --- a/packages/interaction/commands/README.md +++ b/packages/interaction/commands/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-commands` lets a user type `/command [input]` in an interactive Harness UI and run it directly against the receiving agent without creating a model message. Plugins register commands with a name, description, optional input hint and image-acceptance flag, and an abortable handler; interactive adapters discover and dispatch them per agent. A command-producing plugin mounted under an agent's context can register an exact agent-scoped command that shadows the global one of the same name. Each command run is recorded in the session log, and its result is rendered by the adapter, never entering model history. Slash commands ship with the `dsh` CLI and the Web client. +`dsh-commands` lets a user type `/command [input]` in an interactive Harness UI and run it directly against the receiving agent without creating a model message. Plugins register commands with a name, description, optional input hint and attachment-acceptance flag, and an abortable handler; interactive adapters discover and dispatch them per agent. A command-producing plugin mounted under an agent's context can register an exact agent-scoped command that shadows the global one of the same name. Each command run is recorded in the session log, and its result is rendered by the adapter, never entering model history. Slash commands ship with the `dsh` CLI and the Web client. ## Table of Contents @@ -53,13 +53,13 @@ A command line starts with a slash at byte zero, a lowercase name containing let A plain registration is global. A command-producing plugin mounted beneath an agent's own context declares its `commands` injection and registers an exact agent-scoped command, which shadows the global definition of the same name for that agent only. -### Image attachments +### Attachments -A command may declare `input.images` to accept composer image attachments. The executor enforces the declaration: images sent to a non-declaring command, an absent attachment store, or an over-limit batch each settle as an error before the handler runs. Admitted images reach the handler as frozen ordered `ImageBlock`s on `invocation.attachments`, and the handler owns their model-visible use — the registry never schedules them itself. +A command may declare `input.attachments` to accept composer images and generic files. The executor enforces the declaration: attachments sent to a non-declaring command, an absent attachment store, an unknown Session-scoped file-upload receipt, or an over-limit image batch each settle as an error before the handler runs. Images cross the command wire as base64 input; generic files cite receipts from their completed background uploads, so command submission never reads their bytes again. Admitted `ImageBlock`s and `FileBlock`s reach the handler as one frozen `invocation.attachments` array in the user's selection order, and the handler owns their model-visible use. ### Dispatching from an adapter -An interactive adapter calls `execute(agent, line, images, signal)` with the exact receiving agent, the full command line, and the submission's images. It returns the settled `CommandExecution` — the normalized result plus its lifecycle `commandId` — or `undefined` for invalid syntax or an unknown name. `list(agent)` and `find(agent, name)` serve discovery after agent-scoped shadowing. +An interactive adapter calls `execute(agent, line, attachments, signal)` with the exact receiving agent, the full command line, and the submission's ordered attachments. It returns the settled `CommandExecution` — the normalized result plus its lifecycle `commandId` — or `undefined` for invalid syntax or an unknown name. `list(agent)` and `find(agent, name)` serve discovery after agent-scoped shadowing. ### Cancellation @@ -92,9 +92,9 @@ The observable behavior is covered in [Use this package](#use-this-package); thi Registrations live in global and agent-scoped layers merged per agent via `ScopedLayers`. The child-injection shape — a command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection — preserves agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the mutation or starve later observers. -### Image admission +### Attachment admission -Image enforcement happens in the executor, not the composer: an admitted batch is committed through `admitEncodedImages` against the `attachments` store, a rejected batch publishes no durable object, and cancellation is honored before the handler runs so a retrying caller never duplicates state. Handlers that cannot use the images return an error, so the dispatching composer keeps the originals. +Attachment enforcement happens in the executor: images are committed through `admitEncodedImages`, files are resolved through the single Session-aware receipt provider, and the executor restores their original mixed order before calling the handler. Validation rejection starts no attachment writes. An image-storage failure can leave unreachable content-addressed objects for deferred collection, but publishes no model-visible message. Cancellation is honored before the handler runs. Command errors leave the dispatching composer's draft and attachment cards intact. @@ -119,7 +119,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions) submits the optional message in `/plan [message]` after selecting plan mode. Image attachments follow the same rule: the executor only admits them into durable attachment objects, and a declaring producer decides whether and how they become model-visible message content. +The registry itself submits nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt. A command producer may explicitly use the receiving `Agent`; for example, [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions) submits the optional message and ordered attachments in `/plan [message]` after selecting plan mode. The executor only admits attachments into durable objects; the declaring producer decides whether and how they become model-visible message content. #### Token effect diff --git a/packages/interaction/commands/README.zh.md b/packages/interaction/commands/README.zh.md index af9a23adf6..abb538d6ad 100644 --- a/packages/interaction/commands/README.zh.md +++ b/packages/interaction/commands/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-commands` 让用户能在交互式 Harness UI 中输入 `/command [input]`,并直接针对接收命令的 agent(智能体)执行,不产生模型消息。插件注册命令时提供名称、描述、可选的输入提示与图片接受标志,以及可中止的处理器;交互式适配器按 agent 发现并分派这些命令。挂载在 agent 上下文之下的命令生产插件可以注册精确限定到该 agent 的命令,它会遮蔽同名的全局定义。每次命令执行都会记录在接收 agent 的会话日志中,结果由适配器渲染,绝不进入模型历史。斜杠命令随 `dsh` CLI 与 Web 客户端一起提供。 +`dsh-commands` 让用户能在交互式 Harness UI 中输入 `/command [input]`,并直接针对接收命令的 agent(智能体)执行,不产生模型消息。插件注册命令时提供名称、描述、可选的输入提示与附件接受标志,以及可中止的处理器;交互式适配器按 agent 发现并分派这些命令。挂载在 agent 上下文之下的命令生产插件可以注册精确限定到该 agent 的命令,它会遮蔽同名的全局定义。每次命令执行都会记录在接收 agent 的会话日志中,结果由适配器渲染,绝不进入模型历史。斜杠命令随 `dsh` CLI 与 Web 客户端一起提供。 ## 目录 @@ -53,13 +53,13 @@ ctx.commands.register({ 普通注册全局生效。挂载在 agent 自身上下文之下的命令生产插件会声明 `commands` 注入,并注册精确限定到该 agent 的命令;该定义只对这个 agent 遮蔽同名的全局定义。 -### 图片附件 +### 附件 -命令可以声明 `input.images` 以接受 composer 图片附件。执行器负责声明的强制执行:把图片发给未声明的命令、附件存储缺失或批量超出限制,都会在处理器运行前以错误结果结算。通过准入的图片以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器,其模型可见用途由处理器负责——注册表本身绝不定时调度它们。 +命令可以声明 `input.attachments` 以接受 composer 图片与通用文件。执行器负责强制执行声明:把附件发给未声明的命令、附件存储缺失、Session 范围内的文件上传凭证未知或图片批量超出限制,都会在处理器运行前以错误结果结算。图片以 base64 输入通过命令 wire,通用文件则引用后台上传完成后得到的凭证,因此命令提交不会再次读取文件字节。通过准入的 `ImageBlock` 与 `FileBlock` 按用户选择顺序组成冻结的 `invocation.attachments` 数组,其模型可见用途由处理器负责。 ### 从适配器分派 -交互式适配器调用 `execute(agent, line, images, signal)`,传入确切的接收 agent、完整命令行与本次提交的图片。它返回已结算的 `CommandExecution`——规范化结果加生命周期配对 `commandId`——语法无效或名称未知时返回 `undefined`。`list(agent)` 与 `find(agent, name)` 在应用 agent 作用域遮蔽后服务发现。 +交互式适配器调用 `execute(agent, line, attachments, signal)`,传入确切的接收 agent、完整命令行与本次提交的有序附件。它返回已结算的 `CommandExecution`——规范化结果加生命周期配对 `commandId`——语法无效或名称未知时返回 `undefined`。`list(agent)` 与 `find(agent, name)` 在应用 agent 作用域遮蔽后服务发现。 ### 取消 @@ -92,9 +92,9 @@ ctx.commands.register({ 注册表通过 `ScopedLayers` 维护全局层与按 agent 的作用域层,并按 agent 合并视图。子级注入形态——挂载在 `agent.ctx` 之下的命令生产插件声明自身的 `commands` 注入——保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层内的名称重复会在注册时失败;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 -### 图片准入 +### 附件准入 -图片强制执行发生在执行器而非 composer 中:通过准入的批量经 `admitEncodedImages` 提交给 `attachments` 存储,被拒绝的批量不发布任何持久化对象;取消会在处理器运行前被处理,因此重试的调用方绝不会重复状态。无法使用图片的处理器会返回错误,使分发方 composer 保留原件。 +附件强制执行发生在执行器中:图片经 `admitEncodedImages` 提交,文件通过唯一的 Session 感知凭证提供方解析,执行器在调用处理器前恢复原始混合顺序。验证拒绝不会开始写入附件。图片存储失败可能留下等待清理且无法引用的内容寻址对象,但不会发布模型可见消息。取消会在处理器运行前生效。命令返回错误时,分发方 composer 的草稿与附件卡保持原位。 @@ -119,7 +119,7 @@ ctx.commands.register({ #### 模型看到的内容 -注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.zh.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。图片附件遵循同一规则:执行器只负责把它们准入为持久化附件对象,是否以及如何成为模型可见的消息内容由声明接受的生产方决定。 +注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.zh.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息与有序附件。执行器只负责把附件准入为持久化对象,是否以及如何成为模型可见消息由声明接受的生产方决定。 #### Token 影响 diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index faf2d6134a..4d1b3783ca 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UIs", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index b7cdeb0b4a..0e94998066 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -7,8 +7,8 @@ import { Context } from '@deepseek-ai/cordis' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { Agent } from '@deepseek-ai/dsh-agent' import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' -import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' -import type { ImageBlock } from '@deepseek-ai/dsh-llm' +import type { EncodedImageAttachment, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment/types' +import type { FileBlock, ImageBlock } from '@deepseek-ai/dsh-llm' import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' import { SessionSeq } from '@deepseek-ai/dsh-session' @@ -20,6 +20,7 @@ import type { CommandExecution, CommandInputDescriptor, CommandResult, + CommandSubmitAttachment, } from './types.ts' export { CommandId } from './brand.ts' @@ -29,8 +30,11 @@ export const name = 'commands' const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u -/** Shared frozen attachments value for image-free invocations. */ -const NO_ATTACHMENTS: readonly ImageBlock[] = Object.freeze([]) +/** Shared frozen attachments value for attachment-free invocations. */ +const NO_ATTACHMENTS: readonly (ImageBlock | FileBlock)[] = Object.freeze([]) + +/** Host resolver for Session-scoped staged file-upload receipts. */ +export type CommandFileReceiptResolver = (agent: Agent, receiptId: string) => FileAttachmentRef | undefined /** Invocation passed to one registered command handler. */ export interface CommandInvocation { @@ -41,13 +45,13 @@ export interface CommandInvocation { /** Exact text following the registered command name, including separator whitespace. */ readonly rawInput: string /** - * Durably admitted image blocks accompanying this invocation, in submission - * order; empty unless the definition declares `input.images`. The handler + * Durably admitted image and file blocks accompanying this invocation, in submission + * order; empty unless the definition declares `input.attachments`. The handler * owns their model-visible use — the registry never schedules them itself — * and a handler whose grammar cannot use them in this invocation returns an * error so the dispatching composer retains the originals. */ - readonly attachments: readonly ImageBlock[] + readonly attachments: readonly (ImageBlock | FileBlock)[] /** Cancellation signal owned by the dispatching UI request. */ readonly signal: AbortSignal } @@ -192,12 +196,12 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand { if (rawInput.hint.trim().length === 0) { throw new TypeError(`command "${definition.name}" input hint must not be empty`) } - if ('images' in rawInput && rawInput.images !== undefined && typeof rawInput.images !== 'boolean') { - throw new TypeError(`command "${definition.name}" input images flag must be a boolean`) + if ('attachments' in rawInput && rawInput.attachments !== undefined && typeof rawInput.attachments !== 'boolean') { + throw new TypeError(`command "${definition.name}" input attachments flag must be a boolean`) } input = Object.freeze({ hint: rawInput.hint, - ...('images' in rawInput && rawInput.images === true) ? { images: true } : {}, + ...('attachments' in rawInput && rawInput.attachments === true) ? { attachments: true } : {}, }) } const normalized = Object.freeze({ @@ -261,6 +265,8 @@ export class CommandRuntime extends TypertRemoteService { private commandSeq = 0 /** Instance token keeping minted ids unique across process restarts over one resumed log. */ private readonly instanceToken = randomUUID().slice(0, 8) + /** Optional provider installed by the Session upload owner. */ + private readonly fileReceipts: { resolver: CommandFileReceiptResolver | undefined } = { resolver: undefined } constructor(ctx: Context) { super(ctx, 'commands') @@ -280,6 +286,21 @@ export class CommandRuntime extends TypertRemoteService { ) } + /** + * Register the sole authority that resolves staged file receipts for command submissions. + * @param resolver - Session-aware receipt resolver. + * @returns disposer that removes this exact resolver. + */ + registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void { + if (this.fileReceipts.resolver !== undefined) { + throw new Error('commands: a file receipt resolver is already registered') + } + this.fileReceipts.resolver = resolver + return () => { + if (this.fileReceipts.resolver === resolver) this.fileReceipts.resolver = undefined + } + } + /** * List the effective immutable command descriptors for one agent. * @param agent - exact receiving agent and scoped-layer key. @@ -316,15 +337,17 @@ export class CommandRuntime extends TypertRemoteService { * handler-failure path is contained so the handler's own error stays the * reported failure. * - * Image admission is enforced here, not in the composer: images sent to a - * command that does not declare `input.images`, an absent attachment store, - * and an exceeded attachment limit each settle as an error result before - * the handler runs, and a rejected batch publishes no durable object. + * Attachment admission is enforced here, not in the composer: attachments sent to a + * command that does not declare `input.attachments`, an absent attachment store, + * and an exceeded image limit each settle as an error result before + * the handler runs. Validation rejection starts no attachment writes; + * a storage failure can leave only unreachable content-addressed objects + * for deferred collection. * * @param agent - exact receiving agent. * @param line - complete slash-command line. - * @param images - base64-encoded composer images accompanying the line, in - * submission order; empty for a plain invocation. + * @param submittedAttachments - encoded images and staged file receipts accompanying the line, + * in submission order; empty for a plain invocation. * @param signal - cancellation signal owned by the UI request. * @returns the settled execution (result + lifecycle pairing id), or * `undefined` when syntax or name does not resolve. @@ -333,7 +356,7 @@ export class CommandRuntime extends TypertRemoteService { async execute( agent: Agent, line: string, - images: readonly EncodedImageAttachment[], + submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise { const parsed = parseCommand(line) @@ -358,18 +381,21 @@ export class CommandRuntime extends TypertRemoteService { }) return Object.freeze({ commandId, result: Object.freeze(result) }) } - let attachments: readonly ImageBlock[] = NO_ATTACHMENTS - if (images.length > 0) { - if (command.definition.input?.images !== true) { - return settle({ kind: 'error', text: `/${parsed.name} does not accept image attachments` }) + let attachments: readonly (ImageBlock | FileBlock)[] = NO_ATTACHMENTS + if (submittedAttachments.length > 0) { + if (command.definition.input?.attachments !== true) { + return settle({ kind: 'error', text: `/${parsed.name} does not accept attachments` }) } const store = this.ctx.get('attachments') if (store === undefined) { - return settle({ kind: 'error', text: `/${parsed.name}: image attachments are unavailable because no attachment store is composed` }) + return settle({ kind: 'error', text: `/${parsed.name}: attachments are unavailable because no attachment store is composed` }) } try { - const refs = await admitEncodedImages(store, images) - attachments = Object.freeze(refs.map(ref => Object.freeze({ type: 'image' as const, attachment: ref }))) + attachments = await admitCommandAttachments( + store, + submittedAttachments, + receiptId => this.fileReceipts.resolver?.(agent, receiptId), + ) } catch (error: unknown) { if (error instanceof AttachmentError) { return settle({ kind: 'error', text: error.message }) @@ -458,4 +484,45 @@ export class CommandRuntime extends TypertRemoteService { } } +/** Admit a mixed command batch and restore its original image/file order. */ +async function admitCommandAttachments( + store: Parameters[0], + attachments: readonly CommandSubmitAttachment[], + resolveFileReceipt: (receiptId: string) => FileAttachmentRef | undefined, +): Promise { + const files = new Map() + for (const attachment of attachments) { + if (attachment.type !== 'file' || files.has(attachment.receiptId)) continue + const file = resolveFileReceipt(attachment.receiptId) + if (file === undefined) { + throw new AttachmentError('File upload receipt is unknown for this session.', 'ATTACHMENT_NOT_FOUND') + } + files.set(attachment.receiptId, file) + } + const images: EncodedImageAttachment[] = [] + for (const attachment of attachments) { + if (attachment.type !== 'image') continue + images.push({ + mediaType: attachment.mediaType, + data: attachment.data, + ...(attachment.name === undefined ? {} : { name: attachment.name }), + }) + } + const imageRefs = images.length === 0 ? [] : await admitEncodedImages(store, images) + let imageIndex = 0 + const blocks: Array = [] + for (const attachment of attachments) { + if (attachment.type === 'image') { + const ref = imageRefs[imageIndex] as ImageAttachmentRef + imageIndex += 1 + blocks.push(Object.freeze({ type: 'image', attachment: ref })) + continue + } + blocks.push(Object.freeze({ + type: 'file', attachment: files.get(attachment.receiptId) as FileAttachmentRef, + })) + } + return Object.freeze(blocks) +} + export default CommandRuntime diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts index 0ca87c778d..7277766871 100644 --- a/packages/interaction/commands/src/types.ts +++ b/packages/interaction/commands/src/types.ts @@ -9,19 +9,25 @@ import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { CommandId } from './brand.ts' +import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' + +/** One browser-submitted command attachment: encoded image input or a staged file receipt. */ +export type CommandSubmitAttachment = + | ({ readonly type: 'image' } & EncodedImageAttachment) + | { readonly type: 'file'; readonly receiptId: string } /** Immutable metadata for a command's optional unstructured input. */ export interface CommandInputDescriptor { /** Placeholder shown before the user supplies free-form input. */ readonly hint: string /** - * Whether composer image attachments may accompany an invocation. Absent or - * false = the executor rejects an invocation carrying images and capable + * Whether composer attachments may accompany an invocation. Absent or + * false = the executor rejects an invocation carrying attachments and capable * composers refuse the submission before dispatch. A declaring command's * handler receives the admitted durable blocks and owns every further * grammar decision, including rejecting sub-commands that cannot use them. */ - readonly images?: boolean + readonly attachments?: boolean } /** Expected command outcome rendered directly by the dispatching UI. */ diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index 01a31e205c..92a12d921d 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -466,7 +466,7 @@ describe('CommandRuntime', () => { }) }) -describe('image attachments', () => { +describe('command attachments', () => { const PNG = 'AAAA' function storeOf() { @@ -484,6 +484,12 @@ describe('image attachments', () => { ...input.name === undefined ? {} : { name: input.name }, }) }), + saveFile: vi.fn((input: { data: Uint8Array; name?: string }) => { + saved += 1 + return Promise.resolve({ + attachmentId: `att-${saved}`, bytes: input.data.byteLength, name: input.name ?? 'attachment', + }) + }), validateImageBatch(inputs: readonly unknown[]) { const validate = AttachmentStore.prototype as unknown as { validateImageBatch(this: unknown, batch: readonly unknown[]): void @@ -501,8 +507,8 @@ describe('image attachments', () => { function accepting(handler: CommandDefinition['handler']): CommandDefinition { return { name: 'vision', - description: 'accepts images', - input: { hint: '', images: true }, + description: 'accepts attachments', + input: { hint: '', attachments: true }, handler, } } @@ -511,32 +517,32 @@ describe('image attachments', () => { const ctx = await mount() expect(() => ctx.commands.register({ ...command('flag-type'), - input: { hint: 'x', images: 'yes' }, - } as unknown as CommandDefinition)).toThrow('command "flag-type" input images flag must be a boolean') + input: { hint: 'x', attachments: 'yes' }, + } as unknown as CommandDefinition)).toThrow('command "flag-type" input attachments flag must be a boolean') }) - it('lists images acceptance on the descriptor and omits a false flag', async () => { + it('lists attachment acceptance on the descriptor and omits a false flag', async () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(accepting(() => ({ kind: 'success' }))) - ctx.commands.register({ ...command('plain-input'), input: { hint: 'x', images: false } }) + ctx.commands.register({ ...command('plain-input'), input: { hint: 'x', attachments: false } }) const byName = new Map(ctx.commands.list(agent).map(descriptor => [descriptor.name, descriptor])) - expect(byName.get('vision')?.input).toEqual({ hint: '', images: true }) + expect(byName.get('vision')?.input).toEqual({ hint: '', attachments: true }) expect(byName.get('plain-input')?.input).toEqual({ hint: 'x' }) }) - it('settles images sent to a non-declaring command as a logged error before the handler', async () => { + it('settles attachments sent to a non-declaring command as a logged error before the handler', async () => { const ctx = await mount() const { agent } = await mintAgentScope(ctx, 'a') const handler = vi.fn(() => ({ kind: 'success' as const })) ctx.commands.register({ ...command('deploy'), handler }) const execution = await ctx.commands.execute( - agent, '/deploy now', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal) - expect(execution?.result).toEqual({ kind: 'error', text: '/deploy does not accept image attachments' }) + agent, '/deploy now', [{ type: 'image', mediaType: 'image/png', data: PNG }], new AbortController().signal) + expect(execution?.result).toEqual({ kind: 'error', text: '/deploy does not accept attachments' }) expect(handler).not.toHaveBeenCalled() expect(lifecycleOf(agent)).toMatchObject([ { type: 'command/run', data: { name: 'deploy' } }, - { type: 'command/done', data: { kind: 'error', text: '/deploy does not accept image attachments' } }, + { type: 'command/done', data: { kind: 'error', text: '/deploy does not accept attachments' } }, ]) }) @@ -545,41 +551,79 @@ describe('image attachments', () => { const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(accepting(() => ({ kind: 'success' }))) const execution = await ctx.commands.execute( - agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal) + agent, '/vision x', [{ type: 'image', mediaType: 'image/png', data: PNG }], new AbortController().signal) expect(execution?.result).toEqual({ kind: 'error', - text: '/vision: image attachments are unavailable because no attachment store is composed', + text: '/vision: attachments are unavailable because no attachment store is composed', }) }) - it('admits and hands the handler frozen ordered image blocks; plain invocations stay empty', async () => { + it('admits a mixed batch in selection order and keeps plain invocations empty', async () => { const ctx = await mount() ctx.provide('attachments', storeOf()) const { agent } = await mintAgentScope(ctx, 'a') + ctx.commands.registerFileReceiptResolver((_receivingAgent, receiptId) => receiptId === 'receipt-notes' + ? { attachmentId: 'file-notes' as never, name: 'notes.txt', bytes: 5 } + : undefined) const seen = vi.fn((invocation: { attachments: readonly unknown[] }) => { expect(Object.isFrozen(invocation.attachments)).toBe(true) return { kind: 'success' as const } }) ctx.commands.register(accepting(seen)) await ctx.commands.execute(agent, '/vision x', [ - { mediaType: 'image/png', data: PNG, name: 'a.png' }, - { mediaType: 'image/png', data: PNG, name: 'b.png' }, + { type: 'image', mediaType: 'image/png', data: PNG, name: 'a.png' }, + { type: 'file', receiptId: 'receipt-notes' }, + { type: 'image', mediaType: 'image/png', data: PNG, name: 'b.png' }, ], new AbortController().signal) const invocation = seen.mock.calls[0]?.[0] as { attachments: ReadonlyArray<{ type: string; attachment: { name?: string } }> } expect(invocation.attachments.map(block => [block.type, block.attachment.name])).toEqual([ - ['image', 'a.png'], ['image', 'b.png'], + ['image', 'a.png'], ['file', 'notes.txt'], ['image', 'b.png'], ]) await ctx.commands.execute(agent, '/vision y', [], new AbortController().signal) expect((seen.mock.calls[1]?.[0] as { attachments: readonly unknown[] }).attachments).toEqual([]) }) + it('requires a same-session file receipt resolver and keeps its registration single-owner', async () => { + const ctx = await mount() + const store = storeOf() + ctx.provide('attachments', store) + const { agent } = await mintAgentScope(ctx, 'a') + const handler = vi.fn(() => ({ kind: 'success' as const })) + ctx.commands.register(accepting(handler)) + const missing = await ctx.commands.execute( + agent, '/vision x', [{ type: 'file', receiptId: 'missing' }], new AbortController().signal) + expect(missing?.result).toEqual({ + kind: 'error', text: 'File upload receipt is unknown for this session.', + }) + expect(handler).not.toHaveBeenCalled() + const mixed = await ctx.commands.execute(agent, '/vision x', [ + { type: 'image', mediaType: 'image/png', data: PNG }, + { type: 'file', receiptId: 'missing' }, + ], new AbortController().signal) + expect(mixed?.result).toEqual({ + kind: 'error', text: 'File upload receipt is unknown for this session.', + }) + expect(store.saveImage).not.toHaveBeenCalled() + + const first = vi.fn(() => undefined) + const disposeFirst = ctx.commands.registerFileReceiptResolver(first) + expect(() => ctx.commands.registerFileReceiptResolver(() => undefined)) + .toThrow('commands: a file receipt resolver is already registered') + disposeFirst() + const disposeSecond = ctx.commands.registerFileReceiptResolver(() => undefined) + disposeFirst() + expect(() => ctx.commands.registerFileReceiptResolver(() => undefined)) + .toThrow('commands: a file receipt resolver is already registered') + disposeSecond() + }) + it('settles an admission limit failure as a logged error result', async () => { const ctx = await mount() ctx.provide('attachments', storeOf()) const { agent } = await mintAgentScope(ctx, 'a') const handler = vi.fn(() => ({ kind: 'success' as const })) ctx.commands.register(accepting(handler)) - const three = [1, 2, 3].map(() => ({ mediaType: 'image/png' as const, data: PNG })) + const three = [1, 2, 3].map(() => ({ type: 'image' as const, mediaType: 'image/png' as const, data: PNG })) const execution = await ctx.commands.execute(agent, '/vision x', three, new AbortController().signal) expect(execution?.result).toEqual({ kind: 'error', text: 'Image batch exceeds the configured image-count limit.' }) expect(handler).not.toHaveBeenCalled() @@ -601,7 +645,7 @@ describe('image attachments', () => { const handler = vi.fn(() => ({ kind: 'success' as const })) ctx.commands.register(accepting(handler)) await expect(ctx.commands.execute( - agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], controller.signal, + agent, '/vision x', [{ type: 'image', mediaType: 'image/png', data: PNG }], controller.signal, )).rejects.toThrow('operator cancelled during admission') expect(handler).not.toHaveBeenCalled() expect(lifecycleOf(agent).at(-1)).toMatchObject({ @@ -618,7 +662,7 @@ describe('image attachments', () => { const { agent } = await mintAgentScope(ctx, 'a') ctx.commands.register(accepting(() => ({ kind: 'success' }))) await expect(ctx.commands.execute( - agent, '/vision x', [{ mediaType: 'image/png', data: PNG }], new AbortController().signal, + agent, '/vision x', [{ type: 'image', mediaType: 'image/png', data: PNG }], new AbortController().signal, )).rejects.toThrow('disk gone') expect(lifecycleOf(agent).at(-1)).toMatchObject({ type: 'command/done', diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json index ce05b679dc..70e3c59926 100644 --- a/packages/interaction/permission-presets/package.json +++ b/packages/interaction/permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-permission-presets", "description": "User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index a8ac0aaee9..11e819ac64 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ask-user", "description": "Model-facing ask_user_question tool over the ctx.userQuestions seam", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index b26b69fae6..1991d47d4a 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-approval", "description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index 45074af2fb..a924cca0a7 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-questions", "description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index e5f7729392..e4d5075c07 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs-local", "description": "Process-local implementation of the DeepSeek Harness background job registry seam", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index 34b32d40e7..0bc224a1b2 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs", "description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json index 65de60e4f0..fe7190afae 100644 --- a/packages/jobs/tool-jobs/package.json +++ b/packages/jobs/tool-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-jobs", "description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/deepseek-llm-api-extensions/package.json b/packages/llm/deepseek-llm-api-extensions/package.json index 3e17971587..6010efb1c6 100644 --- a/packages/llm/deepseek-llm-api-extensions/package.json +++ b/packages/llm/deepseek-llm-api-extensions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deepseek-llm-api-extensions", "description": "Additive request-field registry for the official DeepSeek LLM API adapter", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 4c7cdebf15..7a8be276ca 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", "description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/tests/file-store.spec.ts b/packages/llm/llm-deepseek/tests/file-store.spec.ts index d6d154033b..9b97859ac8 100644 --- a/packages/llm/llm-deepseek/tests/file-store.spec.ts +++ b/packages/llm/llm-deepseek/tests/file-store.spec.ts @@ -1,7 +1,7 @@ -import { mkdtemp } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' import { DeepSeekFileStore, MAX_CHAT_IMAGE_BYTES } from '../src/file-store.ts' @@ -31,6 +31,12 @@ const CONNECTION = { baseURL: 'https://api.deepseek.com', apiKey: 'key' } const POLICY = { expiresAfterSeconds: 604_800, refreshMarginSeconds: 3_600, quotaCleanupBatch: 100 } const NOW = 1_700_000_000_000 +/** Every temp store root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + function requestUrl(input: string | URL | Request): string { if (typeof input === 'string') return input return input instanceof URL ? input.href : input.url @@ -64,6 +70,7 @@ function uploadFetch(now: () => number = () => NOW) { describe('DeepSeekFileStore', () => { it('singleflights the first upload and reuses the durable mapping across store instances', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const remote = uploadFetch() const first = new DeepSeekFileStore({ index, now: () => NOW, fetch: remote.fetchImpl }) @@ -84,6 +91,7 @@ describe('DeepSeekFileStore', () => { it('keeps a shared upload alive while another waiter remains', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) let complete: ((response: Response) => void) | undefined let uploadSignal: AbortSignal | undefined @@ -123,6 +131,7 @@ describe('DeepSeekFileStore', () => { it('aborts the shared upload after its only waiter cancels', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) let uploadSignal: AbortSignal | undefined const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => { @@ -149,6 +158,7 @@ describe('DeepSeekFileStore', () => { it('normalizes a non-Error cancellation reason', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => ( new Promise((_resolve, reject) => { init?.signal?.addEventListener('abort', () => { @@ -176,6 +186,7 @@ describe('DeepSeekFileStore', () => { it('starts a fresh upload while the cancelled transport is settling', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) let requests = 0 const fetchImpl = vi.fn((_url: string | URL | Request, init?: RequestInit) => { requests += 1 @@ -222,6 +233,7 @@ describe('DeepSeekFileStore', () => { it('does not persist an upload whose response is missing and retries on the next request', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const good = uploadFetch() let first = true @@ -242,6 +254,7 @@ describe('DeepSeekFileStore', () => { it('rejects an upload response whose byte count differs from the request version', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const fetchImpl = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ id: 'file-api-wrong-size', object: 'file', bytes: 2, created_at: NOW / 1_000, filename: 'dsh-wrong.png', purpose: 'user_data', @@ -262,6 +275,7 @@ describe('DeepSeekFileStore', () => { ['image/gif', 'gif'], ] as const)('uses the %s filename extension for uploads', async (mediaType, extension) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const remote = uploadFetch() const store = new DeepSeekFileStore({ index: new DeepSeekUploadIndex(join(dir, `${extension}.json`)), @@ -279,6 +293,7 @@ describe('DeepSeekFileStore', () => { it('normalizes a non-Error failure from the durable upload index', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) vi.spyOn(index, 'get').mockRejectedValue('index unavailable') const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: vi.fn() as typeof fetch }) @@ -291,6 +306,7 @@ describe('DeepSeekFileStore', () => { it('reuses local expires_at above the refresh margin and uploads again at the margin', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) let now = NOW const remote = uploadFetch(() => now) @@ -311,6 +327,7 @@ describe('DeepSeekFileStore', () => { it('releases an indexed file through DELETE and removes only that mapping', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const remote = uploadFetch() const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: remote.fetchImpl }) @@ -323,6 +340,7 @@ describe('DeepSeekFileStore', () => { it('removes a losing upload and keeps the winning durable mapping when duplicate cleanup fails', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) vi.spyOn(index, 'commit').mockResolvedValue({ accepted: false, @@ -352,6 +370,7 @@ describe('DeepSeekFileStore', () => { it('reclaims one owned file after quota rejection and retries the upload once', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) let uploads = 0 const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => { if (init?.method === 'POST') { @@ -394,6 +413,7 @@ describe('DeepSeekFileStore', () => { it('preserves a quota error when no harness-owned file can be reclaimed', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const fetchImpl = vi.fn((_input: string | URL | Request, init?: RequestInit) => { if (init?.method === 'POST') return Promise.resolve(new Response(JSON.stringify({ error: { message: 'file count quota exceeded', code: 'file_quota' }, @@ -418,6 +438,7 @@ describe('DeepSeekFileStore', () => { it('finishes pagination before deleting cursor files during quota recovery', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const deleted = new Set() const fetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const target = new URL(requestUrl(input)) @@ -456,6 +477,7 @@ describe('DeepSeekFileStore', () => { it('stops pagination when a page omits or repeats its cursor', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) for (const mode of ['missing', 'repeated'] as const) { let page = 0 const fetchImpl = vi.fn((input: string | URL | Request, init?: RequestInit) => { @@ -482,6 +504,7 @@ describe('DeepSeekFileStore', () => { it('releases every batch and clears the scoped upload index', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-file-store-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const store = new DeepSeekFileStore({ index, now: () => NOW, fetch: vi.fn() as typeof fetch }) const reclaim = vi.spyOn(store, 'reclaimOldestOwned') diff --git a/packages/llm/llm-deepseek/tests/upload-index.spec.ts b/packages/llm/llm-deepseek/tests/upload-index.spec.ts index 2cad8a22be..989a921e69 100644 --- a/packages/llm/llm-deepseek/tests/upload-index.spec.ts +++ b/packages/llm/llm-deepseek/tests/upload-index.spec.ts @@ -1,7 +1,7 @@ -import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import { DeepSeekFileId } from '../src/file-id.ts' import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts' @@ -9,6 +9,12 @@ import { deepSeekFileScope, DeepSeekUploadIndex } from '../src/upload-index.ts' const ATTACHMENT = AttachmentId(`sha256:${'a'.repeat(64)}`) const VARIANT = ImageVariantId(`sha256:${'b'.repeat(64)}`) +/** Every temp index root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + describe('DeepSeekUploadIndex', () => { it('normalizes trailing endpoint slashes in the credential scope', () => { expect(deepSeekFileScope('https://api.deepseek.com///', 'key')) @@ -17,6 +23,7 @@ describe('DeepSeekUploadIndex', () => { it('isolates API-key namespaces and reuses only records above the refresh margin', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const first = deepSeekFileScope('https://api.deepseek.com', 'first-key') const second = deepSeekFileScope('https://api.deepseek.com', 'second-key') @@ -38,6 +45,7 @@ describe('DeepSeekUploadIndex', () => { it('keeps a reusable cross-process winner and removes only an exact generation', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const scope = deepSeekFileScope('https://api.deepseek.com', 'key') const first = { @@ -56,6 +64,7 @@ describe('DeepSeekUploadIndex', () => { it('treats a corrupt upload cache as empty and repairs it on the next commit', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + roots.push(dir) const path = join(dir, 'index.json') await writeFile(path, '{bad', 'utf8') const index = new DeepSeekUploadIndex(path) @@ -128,6 +137,7 @@ describe('DeepSeekUploadIndex', () => { })}]}`, ])('treats an invalid persisted index as empty %#', async (text) => { const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + roots.push(dir) const path = join(dir, 'index.json') await writeFile(path, text, 'utf8') const index = new DeepSeekUploadIndex(path) @@ -138,6 +148,7 @@ describe('DeepSeekUploadIndex', () => { it('rejects duplicate persisted mappings as a corrupt cache', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + roots.push(dir) const path = join(dir, 'index.json') const scope = deepSeekFileScope('https://api.deepseek.com', 'key') const record = { @@ -151,6 +162,7 @@ describe('DeepSeekUploadIndex', () => { it('drops expired records on commit and clears only the selected namespace', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + roots.push(dir) const index = new DeepSeekUploadIndex(join(dir, 'index.json')) const first = deepSeekFileScope('https://api.deepseek.com', 'first') const second = deepSeekFileScope('https://api.deepseek.com', 'second') @@ -171,6 +183,7 @@ describe('DeepSeekUploadIndex', () => { it('propagates non-cache filesystem read failures', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-upload-index-')) + roots.push(dir) const path = join(dir, 'directory') await mkdir(path) const index = new DeepSeekUploadIndex(path) diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 4aa7caf701..41c218d2d5 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-pi-ai", "description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index b2ab3f7e9c..f08d3dd767 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-retry", "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 647c1a8e2b..ad3a18102c 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/packages/llm/llm/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/README.md -README.md: 28ffe082b35715b4c013a5365c741fb49af12a4b -README.zh.md: eb2aba3bb3457949b429c347b93988637f7b4eb8 +README.md: 59b27eaa56769c2b7915e86c88140be59795e3ef +README.zh.md: f517154fe335aca1054deb9b2c30694c3fab5d44 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 28ffe082b3..59b27eaa56 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -100,7 +100,7 @@ The service is built on one separation: **the logical contract is provider-neutr ### Main flow -A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. `offloadRequestImagesWithPolicy()` removes oldest images deterministically by raw or base64 size and count or byte quanta; the pure `offloadedImagePrefixCount()` exposes that decision so route-owned request pricing can reproduce it without building the projection. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. +A request is validated against its exact model's capability — context window, output default, reasoning efforts, and input modalities — and any adapter-configured defaults are materialized, then the whole request is deep-frozen. `prepareCall()` binds those facts, detached context, and retry policy to the exact adapter generation that performs terminal dispatch, so HMR or dynamic settings cannot combine one generation's image capability with another generation's endpoint. An image-capable adapter projects durable references into route-specific request versions; `resolveImageAttachmentAccess()` separately maps an attachment provider's optional host object into the current tool execution world without changing the request image or its `variantId`. A text-only route receives deterministic per-image placeholders, including nested tool-result images, without rewriting append-only session history. Durable `FileBlock` references never reach any adapter: request assembly replaces each one, nested tool-result occurrences included, with deterministic handle text naming the file and its saved read-only path, resolved through the mounted attachment and filesystem providers. `ctx.llm.fileRequestText(ref)` exposes that exact synchronous projection to request measurement. `offloadRequestImagesWithPolicy()` removes oldest images deterministically by raw or base64 size and count or byte quanta; the pure `offloadedImagePrefixCount()` exposes that decision so route-owned request pricing can reproduce it without building the projection. Adapters that charge visual tokens declare per-route `imageRequestPricing`, which `ctx.llm.imageRequestPricing(provider, model)` resolves synchronously for the token meter. Dispatch goes through the `llm/stream` waterfall, then chunks return as token-level deltas and every adapter outcome reaches the consumer as one terminal `finish` chunk. ### Invariants @@ -108,6 +108,7 @@ A request is validated against its exact model's capability — context window, - **Replay state travels only within one adapter** — assistant replay state rides along only when the same adapter instance owns the historical and target routes; otherwise it is dropped before dispatch. - **Prepared calls are one-shot** — a prepared call can be dispatched exactly once, and its call-config fields must match the prepared config. - **Image projection follows the captured route** — durable `ImageBlock` references become route-specific request versions only for image-capable models; text-only models receive stable placeholders. +- **File projection is unconditional** — no provider receives file bytes; every route gets one deterministic handle line per `FileBlock`, and the model reads the saved copy with its file tools on demand. - **Protocol ordering** — `usage` precedes `finish`, tool arguments stay raw JSON strings, and nothing follows the terminal `finish`. - **Registry mutations are atomic** — route and directory registration validates the whole candidate set before anything moves, so a refused change leaves the previous state serving. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index eb2aba3bb3..f517154fe3 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -100,7 +100,7 @@ for await (const chunk of ctx.llm.stream({ ### 主流程 -请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。`offloadRequestImagesWithPolicy()` 按原始字节或 base64 大小以及图片数或字节步长,确定性地从最旧图片开始移除;纯函数 `offloadedImagePrefixCount()` 公开同一决策,使路由所属的请求定价无需构建投影即可复现它。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 +请求会对照其精确模型的能力——上下文窗口、输出默认值、推理强度与输入模态——校验,填入任何适配器配置的默认值,然后整个请求被深度冻结。`prepareCall()` 把这些事实、分离的上下文与重试策略绑定到执行最终分发的精确适配器代次,因此 HMR 或动态设置无法把一个代次的图片能力与另一代次的端点混用。支持图片的适配器把持久引用投影为路由专用请求版本;`resolveImageAttachmentAccess()` 会单独把附件提供方的可选宿主对象映射进当前工具执行世界,而不改变请求图片或其 `variantId`。纯文本路由接收确定性的逐图片占位符,包括嵌套工具结果图片,而不会改写仅追加会话历史。持久 `FileBlock` 引用永远不会到达任何适配器:请求组装把每个引用(包括嵌套工具结果中的出现)替换为确定性 handle 文本,指出文件与其只读保存路径,路径经由挂载的附件与文件系统提供方解析。`ctx.llm.fileRequestText(ref)` 向请求计量公开相同的同步投影。`offloadRequestImagesWithPolicy()` 按原始字节或 base64 大小以及图片数或字节步长,确定性地从最旧图片开始移除;纯函数 `offloadedImagePrefixCount()` 公开同一决策,使路由所属的请求定价无需构建投影即可复现它。对视觉 token 收费的适配器声明按路由的 `imageRequestPricing`,`ctx.llm.imageRequestPricing(provider, model)` 为 token meter 同步解析它。分发经过 `llm/stream` waterfall,随后分片以 token 级增量返回,每个适配器结果都以唯一一个终止 `finish` 分片到达消费方。 ### 不变式 @@ -108,6 +108,7 @@ for await (const chunk of ctx.llm.stream({ - **回放状态只在同一适配器内流动**——仅当同一适配器实例同时拥有历史路由与目标路由时,assistant 回放状态才会随行;否则在分发前被丢弃。 - **已准备调用是一次性的**——已准备调用只能分发一次,且其调用配置字段必须与准备好的配置一致。 - **图片投影遵循捕获的路由**——只有支持图片的模型会把持久 `ImageBlock` 引用转换为路由专用请求版本;纯文本模型接收稳定占位符。 +- **文件投影无条件进行**——没有任何提供方会收到文件字节;每条路由对每个 `FileBlock` 都得到一行确定性 handle 文本,模型在需要时用文件工具读取保存的副本。 - **协议顺序**——`usage` 先于 `finish`,工具参数保持原始 JSON 字符串,终止 `finish` 之后不再有任何内容。 - **注册表变更具有原子性**——路由与目录注册会在任何变动前整体校验候选集合,因此被拒绝的变更会让此前状态继续服务。 diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 0a151a3b1a..a266355538 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm", "description": "Provider-neutral LLM service interface for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 5880f82c98..8ec0c14d35 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -2,7 +2,9 @@ import type { ContentBlock } from './types.ts' import type { Message } from './message.ts' -import type { AttachmentStore, ImageAttachmentRef, ImageMediaType, RequestImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { + AttachmentStore, FileAttachmentRef, ImageAttachmentRef, ImageMediaType, RequestImageAttachment, +} from '@deepseek-ai/dsh-attachment' import { assertNever } from '@deepseek-ai/dsh-util-values' /** Execution-world path that model tools can use to read one normalized attachment. */ @@ -126,6 +128,78 @@ export function contentHasImage(content: readonly ContentBlock[]): boolean { || (block.type === 'tool-result' && contentHasImage(block.content))) } +/** + * True when typed model content contains a file block, walking nested + * tool-result content on the same recursion every file policy shares. + * @param content - typed model content blocks. + * @returns whether any nested block is a file. + */ +export function contentHasFile(content: readonly ContentBlock[]): boolean { + return content.some(block => block.type === 'file' + || (block.type === 'tool-result' && contentHasFile(block.content))) +} + +/** + * Stable model-facing handle for one durable file reference: the address of + * the verbatim stored copy and the instruction to read it on demand. This is + * the only representation a provider ever receives for a file. + * @param ref - durable verbatim file reference. + * @param readonlyPath - execution-world path of the stored copy, when resolvable. + * @returns deterministic handle text naming the file, its size, and its address. + */ +export function fileHandleText(ref: FileAttachmentRef, readonlyPath: string | undefined): string { + const digest = String(ref.attachmentId).slice('sha256:'.length, 'sha256:'.length + 8) + const identity = `File ${quoted(ref.name)} (${ref.bytes} bytes, sha256:${digest})` + if (readonlyPath === undefined) { + return `[${identity} was uploaded, but the current execution environment cannot access a readable path. Report that limitation if its contents are needed; do not claim to have read it.]` + } + return `[${identity}: verbatim read-only copy saved at ${quoted(readonlyPath)}. Read that path with your file tools when its contents are needed; copy it to a writable location before modifying it. When delegating file work, include this saved path in the delegation prompt; only subagents sharing this execution environment can read it.]` +} + +/** Replace every file occurrence, including nested tool results, with handle text. */ +function replaceFilesWithHandles( + blocks: readonly ContentBlock[], + resolvePath: (ref: FileAttachmentRef) => string | undefined, +): ContentBlock[] { + let next: ContentBlock[] | undefined + for (const [index, block] of blocks.entries()) { + if (block.type === 'file') { + next ??= blocks.slice(0, index) + next.push({ type: 'text', text: fileHandleText(block.attachment, resolvePath(block.attachment)) }) + continue + } + if (block.type === 'tool-result') { + const content = replaceFilesWithHandles(block.content, resolvePath) + if (content !== block.content) { + next ??= blocks.slice(0, index) + next.push({ ...block, content }) + continue + } + } + next?.push(block) + } + return next ?? blocks as ContentBlock[] +} + +/** + * Project durable file history into deterministic handle text for every model + * route. Unlike images, no provider receives file blocks natively, so this + * projection is unconditional in request assembly. + * @param messages - complete request history. + * @param resolvePath - resolve one reference's current execution-world read path. + * @returns the original list without files, otherwise shallow message copies with handle text. + */ +export function projectFilesToText( + messages: readonly Message[], + resolvePath: (ref: FileAttachmentRef) => string | undefined, +): readonly Message[] { + if (!messages.some(message => contentHasFile(message.content))) return messages + return messages.map((message) => { + const content = replaceFilesWithHandles(message.content, resolvePath) + return content === message.content ? message : { ...message, content } + }) +} + /** Base64 length of raw image bytes, including padding. */ function base64Length(bytes: number): number { return Math.ceil(bytes / 3) * 4 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index f5c4da4a32..6a3cc5de71 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -32,7 +32,10 @@ import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config. import { HarnessError, INVALID_CREDENTIAL_CODE } from './error.ts' import { normalizeLlmFailure } from './adapter-failure.ts' import { normalizeApiKey } from './api-key.ts' -import { contentHasImage, projectImagesForTextModel } from './content.ts' +import { + contentHasFile, contentHasImage, fileHandleText, projectFilesToText, projectImagesForTextModel, +} from './content.ts' +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' export * from './attribution.ts' export * from './brand.ts' @@ -661,6 +664,16 @@ export class LlmRuntime extends TypertRemoteService { return this.adapters.get(provider)?.adapter.imageRequestPricing(provider, model) } + /** + * Resolve the exact text one durable file occurrence contributes to every + * provider request in the current execution environment. + * @param ref - durable verbatim file reference from model history. + * @returns the same deterministic handle text used at adapter dispatch. + */ + fileRequestText(ref: FileAttachmentRef): string { + return fileHandleText(ref, this.fileReadPath(ref)) + } + /** Detach typed adapter-owned modality metadata. */ private detachedModalities(modalities: readonly ModelModality[] | undefined): ModelModality[] | undefined { return modalities === undefined ? undefined : [...modalities] @@ -957,6 +970,26 @@ export class LlmRuntime extends TypertRemoteService { return Object.isFrozen(options) ? deepFreeze(filtered) : filtered } + /** + * Resolve the current execution-world read path of one durable file + * reference through the mounted attachment and filesystem providers. + */ + private fileReadPath(ref: FileAttachmentRef): string | undefined { + let hostPath: string | undefined + try { + hostPath = this.ctx.get('attachments')?.fileHostPath(ref) + } catch { + // A malformed durable reference degrades this occurrence to the no-path + // handle instead of failing every later request over the same log. + return undefined + } + if (hostPath === undefined) return undefined + // Structural face: dsh-llm cannot depend on the filesystem package, and + // only this one mapping method is consumed. + const fs = this.ctx.get('fs') as { processPathFromHostPath(hostPath: string): string | undefined } | undefined + return fs?.processPathFromHostPath(hostPath) + } + /** * Final adapter boundary. Adapter selection, dispatch, iterator construction, * and iteration failures become one terminal failure chunk. Middleware and @@ -994,13 +1027,21 @@ export class LlmRuntime extends TypertRemoteService { : Object.isFrozen(options) ? deepFreeze({ ...options, ...resolvedConfig }) : { ...options, ...resolvedConfig } - const projectedOptions = modelInfo.inputModalities !== undefined + // Files are never dispatched natively: every route receives handle text. + let projectedMessages: readonly Message[] = resolvedOptions.messages + if (projectedMessages.some(message => contentHasFile(message.content))) { + projectedMessages = projectFilesToText(projectedMessages, ref => this.fileReadPath(ref)) + } + if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image') - && resolvedOptions.messages.some(message => contentHasImage(message.content)) - ? Object.isFrozen(resolvedOptions) - ? deepFreeze({ ...resolvedOptions, messages: projectImagesForTextModel(resolvedOptions.messages) as Message[] }) - : { ...resolvedOptions, messages: projectImagesForTextModel(resolvedOptions.messages) as Message[] } - : resolvedOptions + && projectedMessages.some(message => contentHasImage(message.content))) { + projectedMessages = projectImagesForTextModel(projectedMessages) + } + const projectedOptions = projectedMessages === resolvedOptions.messages + ? resolvedOptions + : Object.isFrozen(resolvedOptions) + ? deepFreeze({ ...resolvedOptions, messages: projectedMessages as Message[] }) + : { ...resolvedOptions, messages: projectedMessages as Message[] } const stream = dispatch(this.forAdapter(projectedOptions, adapter)) iterator = stream[Symbol.asyncIterator]() } catch (error: unknown) { diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 4303538892..c480f57b89 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -5,7 +5,7 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ToolCallId, ProviderRequestId, ReasoningEffortId } from './brand.ts' import type { Message } from './message.ts' @@ -74,6 +74,19 @@ export interface ImageBlock { attachment: ImageAttachmentRef } +/** + * A durable verbatim file reference, valid in user content. Files never reach + * a provider natively: request assembly projects every occurrence to + * deterministic handle text (name, byte size, and the read-only saved path), + * so adapters and providers see text in its place while the durable log keeps + * the structured reference for presentation and authorization. + */ +export interface FileBlock { + type: 'file' + /** Immutable verbatim bytes and display metadata owned by the attachment service. */ + attachment: FileAttachmentRef +} + /** A tool invocation requested by the model. */ export interface ToolCallBlock { type: 'tool-call' @@ -100,6 +113,7 @@ export interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock 'image': ImageBlock + 'file': FileBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index 748e20f412..af114958b1 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -3,7 +3,10 @@ import { AttachmentId, ImageVariantId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageMediaType } from '@deepseek-ai/dsh-attachment' import { ToolCallId, + contentHasFile, createUserMessage, + fileHandleText, + projectFilesToText, offloadedImageText, offloadedImagePrefixCount, offloadRequestImagesWithPolicy, @@ -360,3 +363,80 @@ describe('projectImagesForTextModel', () => { ]) }) }) + +describe('file projection', () => { + function fileBlock(name: string): Extract { + return { + type: 'file', + attachment: { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + name, + bytes: 42, + }, + } + } + + it('detects file blocks at the top level and inside nested tool results', () => { + expect(contentHasFile([{ type: 'text', text: 'x' }])).toBe(false) + expect(contentHasFile([fileBlock('a.txt')])).toBe(true) + expect(contentHasFile([{ + type: 'tool-result', + toolCallId: ToolCallId('call-1'), + content: [{ + type: 'tool-result', + toolCallId: ToolCallId('call-2'), + content: [fileBlock('deep.txt')], + }], + }])).toBe(true) + }) + + it('renders the handle with the read path or the explicit no-path fallback', () => { + const withPath = fileHandleText(fileBlock('notes.pdf').attachment, '/home/.dsh/attachments/v1/files/ab/x/notes.pdf') + expect(withPath).toContain('"notes.pdf"') + expect(withPath).toContain('42 bytes') + expect(withPath).toContain('sha256:abababab') + expect(withPath).toContain('"/home/.dsh/attachments/v1/files/ab/x/notes.pdf"') + expect(withPath).toContain('include this saved path in the delegation prompt') + expect(withPath).toContain('only subagents sharing this execution environment can read it') + const withoutPath = fileHandleText(fileBlock('notes.pdf').attachment, undefined) + expect(withoutPath).toContain('current execution environment cannot access a readable path') + expect(withoutPath).toContain('do not claim to have read it') + }) + + it('replaces every file occurrence with handle text and keeps file-free history identical', () => { + const plain = [createUserMessage({ content: [{ type: 'text', text: 'hi' }], source })] + expect(projectFilesToText(plain, () => '/p')).toBe(plain) + const unchangedTool = { + type: 'tool-result' as const, + toolCallId: ToolCallId('call-plain'), + content: [{ type: 'text' as const, text: 'unchanged result' }], + } + const messages = [plain[0]!, createUserMessage({ + content: [ + fileBlock('top.csv'), + { type: 'text', text: 'keep' }, + unchangedTool, + { + type: 'tool-result', + toolCallId: ToolCallId('call-3'), + content: [fileBlock('nested.csv')], + }, + ], + source, + })] + const projected = projectFilesToText(messages, ref => `/copies/${ref.name}`) + expect(projected).not.toBe(messages) + expect(projected[0]).toBe(messages[0]) + const content = projected[1]!.content + expect(content[0]).toEqual({ type: 'text', text: fileHandleText(fileBlock('top.csv').attachment, '/copies/top.csv') }) + expect(content[1]).toEqual({ type: 'text', text: 'keep' }) + expect(content[2]).toBe(messages[1]!.content[2]) + const nested = content[3] as Extract + expect(nested.content[0]).toEqual({ + type: 'text', + text: fileHandleText(fileBlock('nested.csv').attachment, '/copies/nested.csv'), + }) + // The durable message is untouched: projection returns shallow copies. + expect(messages[1]!.content[0]!.type).toBe('file') + }) +}) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 47309daea1..2bfcd0f486 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -205,6 +205,73 @@ describe('LlmRuntime', () => { expect(adapter.lastOptions?.messages[0]).toBe(message) }) + it('projects file blocks through every host-path availability outcome', async () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + name: 'notes.txt', + bytes: 3, + } + const cases = [ + { + name: 'native tools under read-only permission receive the mapped read path', + attachments: { fileHostPath: () => '/host/notes.txt' }, + fs: { processPathFromHostPath: () => '/sandbox/notes.txt' }, + expected: '"/sandbox/notes.txt"', + }, + { + name: 'Code Mode under workspace-write permission receives the same mapped read path', + attachments: { fileHostPath: () => '/host/notes.txt' }, + fs: { processPathFromHostPath: () => '/code-sandbox/notes.txt' }, + expected: '"/code-sandbox/notes.txt"', + }, + { + name: 'missing attachment service', + expected: 'current execution environment cannot access a readable path', + }, + { + name: 'provider without a host path', + attachments: { fileHostPath: () => undefined }, + expected: 'current execution environment cannot access a readable path', + }, + { + name: 'invalid durable reference', + attachments: { fileHostPath: () => { throw new Error('invalid ref') } }, + expected: 'current execution environment cannot access a readable path', + }, + { + name: 'missing filesystem mapping', + attachments: { fileHostPath: () => '/host/notes.txt' }, + expected: 'current execution environment cannot access a readable path', + }, + ] + + for (const fixture of cases) { + const ctx = new Context() + if (fixture.attachments !== undefined) ctx.provide('attachments', fixture.attachments as never) + if (fixture.fs !== undefined) ctx.provide('fs', fixture.fs as never) + await ctx.plugin(LlmRuntime) + const adapter = new RecordingAdapter(SCRIPT) + ctx.llm.registerAdapter(['test-provider'], adapter) + + await collect(ctx.llm.stream({ + provider: 'test-provider', + model: 'test-model', + messages: [createUserMessage({ + content: [{ type: 'file', attachment }], + source: { kind: 'user' }, + })], + })) + + const projected = adapter.lastOptions?.messages[0]?.content[0] + expect(projected, fixture.name).toMatchObject({ type: 'text' }) + if (projected?.type !== 'text') throw new Error(`expected projected text for ${fixture.name}`) + expect(projected.text, fixture.name).toContain(fixture.expected) + if (fixture.fs !== undefined) { + expect(projected.text, fixture.name).toContain('include this saved path in the delegation prompt') + } + } + }) + it('captures provider-owned retry policy at registration and defaults omission', async () => { const configured = resolveRetryPolicy({ mode: 'always' }, 'test retryPolicy') const adapter = new class extends ScriptedAdapter { diff --git a/packages/llm/plugin-package-inventory-deepseek/package.json b/packages/llm/plugin-package-inventory-deepseek/package.json index 7133707cfd..104737603a 100644 --- a/packages/llm/plugin-package-inventory-deepseek/package.json +++ b/packages/llm/plugin-package-inventory-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-package-inventory-deepseek", "description": "Active Loader-backed plugin package inventory for official DeepSeek LLM API requests", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml index bb885f0bfc..6413c0ba99 100644 --- a/packages/llm/token-meter/README.i18n.yaml +++ b/packages/llm/token-meter/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/token-meter/README.md -README.md: f2a4f7f7c70de4a08fd824e6e8001622ddb456b5 -README.zh.md: 3b163198279d4b254cd78638ebcad479d063630f +README.md: e23274f99adccf64d9ea832def9ad88682dbde1d +README.zh.md: cdb5f6728ec364524cadc9dfa6b78f3e9bd2d01d diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index f2a4f7f7c7..e23274f99a 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-token-meter` is the replay-aware token measurement service: `ctx.tokenMeter` advances one isolated fold per session from the durable event log, so compaction and other pressure-sensitive plugins share one accounting without depending on the compaction engine. With it you can measure current request and context pressure, price a single message, and — when the session-projection seam is mounted — read the `tokenUsage`, `contextPressure`, and `contextBreakdown` projections. It uses a fixed heuristic for text and routes without image pricing, applies adapter-declared visual-token pricing when available, and reuses provider-reported usage only when the request envelope matches exactly. It adds no prompt, message, schema, or tool of its own, and it never makes decisions for the loop. +`@deepseek-ai/dsh-token-meter` is the replay-aware token measurement service: `ctx.tokenMeter` advances one isolated fold per session from the durable event log, so compaction and other pressure-sensitive plugins share one accounting without depending on the compaction engine. With it you can measure current request and context pressure, price a single message, and read the `tokenUsage`, `contextPressure`, and `contextBreakdown` projections when the session-projection seam is mounted. It uses a fixed heuristic for text and routes without image pricing, applies adapter-declared visual-token pricing when available, prices files as the handle text request assembly sends, and reuses provider-reported usage only when the request envelope matches exactly. It adds no prompt, message, schema, or tool of its own, and it never makes decisions for the loop. ## Table of Contents @@ -40,13 +40,13 @@ const { totalTokens, surfaceTokens, nodes } = ctx.tokenMeter.measure(session) const price = ctx.tokenMeter.estimateMessage(message) ``` -Each measurement resolves the effective envelope's provider/model through the optional `llm` service. Image occurrences use the routed request's visual-token price plus model-visible text when the adapter declares pricing; other routes keep the fixed heuristic. Each node also carries route-independent `heuristicTokens` for replacement shadow prices. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; otherwise the complete current envelope and surface are estimated. Surface changes stay signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements. +Each measurement resolves the effective envelope's provider/model through the optional `llm` service. Image occurrences use the routed request's visual-token price plus model-visible text when the adapter declares pricing; other routes keep the fixed heuristic. File occurrences use the exact route-independent handle text that the same `llm` service resolves for adapter dispatch, including its current execution-world path or explicit no-path message. Each node also carries route-independent `heuristicTokens` for replacement shadow prices. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; otherwise the complete current envelope and surface are estimated. Surface changes stay signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements. ### Session projections When the composition provides `ctx.sessionProjections`, token-meter registers three projection units. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. A final assistant-message sample replaces streaming usage from the same attempt; `llm/retry-started` ends that replacement scope, so a retry in the same step contributes another billed attempt. `contextPressure` carries optional `pressureTokens` (the newest provider-reported prompt size), optional `projectedTokens` (what the next request's prompt would cost), and optional `contextWindow` from the newest `request/context` record. `contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition, not its provider-billed size. Unloading the plugin removes all three keys. -`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays the same O(1) shadow-price fold as `contextPressure`, so on fully metered logs it equals the sum of `measure().nodes[].heuristicTokens` at every event boundary and compaction shrinks it by its logged shadow price. The route-priced `measure().surfaceTokens` diverges when the routed model reprices images. A replacement without an adjacent shadow-price claim leaves this bounded projection unchanged because it cannot reconstruct the replaced range. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error — CJK text and JSON schemas underprice badly at four characters per token — that the composition rows still contain. Present them as an approximate composition, never as a total. +`contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens`, which describe the context's composition rather than its provider-billed size. The envelope figures reprice last-wins on every `request/header`; the message figure replays the same O(1) shadow-price fold as `contextPressure`, so on fully metered logs it equals the sum of `measure().nodes[].heuristicTokens` at every event boundary and compaction shrinks it by its logged shadow price. The request-priced `measure().surfaceTokens` diverges when the routed model reprices images or request assembly projects files to handle text. A replacement without an adjacent shadow-price claim leaves this bounded projection unchanged because it cannot reconstruct the replaced range. All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to `projectedTokens`, whose provider anchor carries exactly the error in the composition rows, including the severe CJK text and JSON schema underpricing from the four-characters-per-token rule. Present them as an approximate composition, never as a total. `deriveTurnTokenUsage(events)` folds one complete Turn into exact per-attempt and total usage for browser consumers. Missing lifecycle evidence, unsafe counts, or contradictory exact totals return no result; optional cache, reasoning, and route aggregates appear only when every contributing attempt reports them. diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md index 3b16319827..cdb5f6728e 100644 --- a/packages/llm/token-meter/README.zh.md +++ b/packages/llm/token-meter/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-token-meter` 是具备回放感知的 token 计量服务:`ctx.tokenMeter` 从持久事件日志为每个会话推进一个隔离 fold,因此压缩(compaction)与其他压力敏感插件可以共享同一份计量,无需依赖压缩引擎。借助它,你可以测量当前请求与上下文压力、为单条消息计价,并且在挂载会话投影 seam 时读取 `tokenUsage`、`contextPressure` 与 `contextBreakdown` 投影。文本和未声明图片定价的路由使用固定启发式规则,存在时则应用适配器声明的视觉 token 定价;只有请求 envelope 完全匹配时才复用提供方报告的用量。它不添加任何自己的提示词、消息、schema 或工具,也绝不为 loop 做决定。 +`@deepseek-ai/dsh-token-meter` 是具备回放感知的 token 计量服务:`ctx.tokenMeter` 从持久事件日志为每个会话推进一个隔离 fold,因此压缩(compaction)与其他压力敏感插件可以共享同一份计量,无需依赖压缩引擎。借助它,你可以测量当前请求与上下文压力、为单条消息计价,并在挂载会话投影 seam 时读取 `tokenUsage`、`contextPressure` 与 `contextBreakdown` 投影。文本和未声明图片定价的路由使用固定启发式规则,存在时应用适配器声明的视觉 token 定价,文件则按请求组装实际发送的 handle 文本计价;只有请求 envelope 完全匹配时才复用提供方报告的用量。它不添加任何自己的提示词、消息、schema 或工具,也绝不为 loop 做决定。 ## 目录 @@ -40,13 +40,13 @@ const { totalTokens, surfaceTokens, nodes } = ctx.tokenMeter.measure(session) const price = ctx.tokenMeter.estimateMessage(message) ``` -每次测量都会通过可选的 `llm` 服务解析生效 envelope 的提供方/模型。适配器声明图片定价时,图片出现处使用路由请求的视觉 token 价格加模型可见文本;其他路由保持固定启发式规则。每个节点还携带与路由无关的 `heuristicTokens`,供替换影子价使用。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配、且其总量不低于该调用完整路由定价锚点时,才复用提供方用量;否则会对完整当前 envelope 与表面做估算。表面变更保持相对于按同一路由重新定价的匹配锚点的带符号值,包括缩减替换后的负 delta。 +每次测量都会通过可选的 `llm` 服务解析生效 envelope 的提供方/模型。适配器声明图片定价时,图片出现处使用路由请求的视觉 token 价格加模型可见文本;其他路由保持固定启发式规则。文件出现处使用同一个 `llm` 服务为适配器分发解析的确切、与路由无关的 handle 文本,其中包含当前执行世界路径或明确的无路径说明。每个节点还携带与路由无关的 `heuristicTokens`,供替换影子价使用。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配、且其总量不低于该调用完整路由定价锚点时,才复用提供方用量;否则会对完整当前 envelope 与表面做估算。表面变更保持相对于按同一路由重新定价的匹配锚点的带符号值,包括缩减替换后的负 delta。 ### 会话投影 当组合提供 `ctx.sessionProjections` 时,token-meter 注册三个投影单元。`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 与 `cacheWriteTokens`。最终 assistant 消息样本会替换同一次尝试的流式用量;`llm/retry-started` 会结束该替换范围,因此同一步骤中的重试会贡献另一次计费用量。`contextPressure` 携带可选 `pressureTokens`(提供方报告的最新提示词规模)、可选 `projectedTokens`(下一个请求的提示词将花费多少)与来自最新一条 `request/context` 记录的可选 `contextWindow`。`contextBreakdown` 携带启发式 `systemTokens`、`toolsTokens` 与 `messageTokens`——上下文的构成,而非提供方计费规模。卸载插件会移除全部三个键。 -`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放与 `contextPressure` 相同的 O(1) 影子价折叠,因此在完整计量的日志上,它在每个事件边界都等于 `measure().nodes[].heuristicTokens` 之和,压缩会按记录的影子价缩小该值。路由定价的 `measure().surfaceTokens` 在路由模型重新为图片计价时会与该值不同。若替换前没有紧邻的影子价声明,这个有界投影会保持不变,因为它无法重建被替换区间。三个数字都使用测量服务的固定启发式规则,属于估算值。它们加起来不等于 `projectedTokens`,后者的提供方锚点体现了这些明细行仍然带有的误差(按「4 字符 ≈ 1 token」计价时,CJK 文本与 JSON schema 会被严重低估)。请把它们当作近似的**组成**呈现,而不是总量。 +`contextBreakdown` 携带启发式的 `systemTokens`、`toolsTokens` 与 `messageTokens`,描述上下文的组成而非提供方计费规模。envelope 数字在每条 `request/header` 上按后者胜重新计价;消息数字重放与 `contextPressure` 相同的 O(1) 影子价折叠,因此在完整计量的日志上,它在每个事件边界都等于 `measure().nodes[].heuristicTokens` 之和,压缩会按记录的影子价缩小该值。请求定价的 `measure().surfaceTokens` 在路由模型重新为图片计价或请求组装把文件投影成 handle 文本时会与该值不同。若替换前没有紧邻的影子价声明,这个有界投影会保持不变,因为它无法重建被替换区间。三个数字都使用测量服务的固定启发式规则,属于估算值。它们加起来不等于 `projectedTokens`,后者的提供方锚点包含组成数据仍有的误差,其中按「4 字符约等于 1 token」计价会严重低估 CJK 文本与 JSON schema。请把它们当作近似的**组成**呈现,不要当作总量。 `deriveTurnTokenUsage(events)` 为浏览器消费方把一个完整 Turn 折叠为精确的逐次尝试与整轮用量。生命周期证据缺失、计数不安全或精确总量矛盾时不返回结果;只有每次参与的尝试都报告可选缓存、推理或路由值时,相应汇总才会出现。 diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 7d6d322acf..13d5b370e6 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-token-meter", "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index f4976c5cc7..fc30652cd8 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -7,7 +7,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { BlockAssembler, expandAssistantStream } from '@deepseek-ai/dsh-llm' -import type { LlmImageRequestPricing, Message, TokenUsage } from '@deepseek-ai/dsh-llm' +import type { LlmImageRequestPricing, LlmRuntime, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import { deepFreeze } from '@deepseek-ai/dsh-util-values' import type { EpochHeader, @@ -147,7 +147,8 @@ export class TokenMeter extends Service { ? state.header : canonicalHeader(requestHeader) const pricing = this._routeImagePricing(header) - const surface = priceSurface(state.surface, pricing) + const fileText = this._fileRequestText() + const surface = priceSurface(state.surface, pricing, fileText) const anchor = state.anchor let baseline: TokenMeasurementBaseline @@ -156,7 +157,7 @@ export class TokenMeter extends Service { // Matching headers share one route, so the anchored snapshot reprices // under the same pricing as the current surface and the signed delta // compares like with like. - const anchorSurfaceTokens = priceSurface(anchor.nodes, pricing).surfaceTokens + const anchorSurfaceTokens = priceSurface(anchor.nodes, pricing, fileText).surfaceTokens + anchor.assistantTokens const estimatedAnchorTokens = estimateHeader(header) + anchorSurfaceTokens const usage = anchor.usage @@ -194,6 +195,14 @@ export class TokenMeter extends Service { return this.ctx.get('llm')?.imageRequestPricing(config.provider, config.model) } + /** Resolve request-time file projection when an LLM service is mounted. */ + private _fileRequestText(): ( + (ref: Parameters[0]) => string + ) | undefined { + const llm = this.ctx.get('llm') + return llm === undefined ? undefined : ref => llm.fileRequestText(ref) + } + /** * Heuristically price one model-visible message (instance face of the pure * `estimateMessage` export from `estimate.ts`). diff --git a/packages/llm/token-meter/src/route-pricing.ts b/packages/llm/token-meter/src/route-pricing.ts index 402db18ac0..03a1c519c3 100644 --- a/packages/llm/token-meter/src/route-pricing.ts +++ b/packages/llm/token-meter/src/route-pricing.ts @@ -1,18 +1,17 @@ /** - * Route-aware surface pricing: projects the fold's fixed-heuristic nodes onto - * the routed model's request, replacing every image occurrence's structural - * price with the route's declared visual tokens plus the model-visible text it - * actually sends. Without declared pricing every node keeps its fixed - * heuristic price, so provider-neutral behavior is unchanged. + * Request-projected surface pricing: replaces attachment-block heuristics with + * the image and file representations sent to the routed model. * * @module @deepseek-ai/dsh-token-meter/route-pricing */ -import type { LlmImageRequestPricing } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmImageRequestPricing } from '@deepseek-ai/dsh-llm' import { estimateContent } from './estimate.ts' import type { MeterSurfaceNode } from './surface-fold.ts' import type { TokenSurfaceNode } from './types.ts' +type FileAttachmentRef = Extract['attachment'] + /** One surface priced for a request route: public nodes plus their total. */ export interface PricedSurface { /** Positional nodes carrying both the route price and the fixed-heuristic price. */ @@ -22,9 +21,10 @@ export interface PricedSurface { } /** - * Price one ordered surface under a route's request-image pricing. + * Price one ordered surface under its model-request attachment projection. * @param nodes - the fold's current or snapshotted surface, in model-visible order. * @param pricing - the routed model's image pricing, or undefined to keep the fixed heuristic. + * @param fileText - exact file handle projection used by the mounted LLM service. * @returns detached public nodes and their route-priced total. * @throws when the pricing answers a different occurrence count than it was * asked — misalignment would silently misprice nodes, so it must fail loud. @@ -32,9 +32,11 @@ export interface PricedSurface { export function priceSurface( nodes: readonly MeterSurfaceNode[], pricing: LlmImageRequestPricing | undefined, + fileText?: (ref: FileAttachmentRef) => string, ): PricedSurface { const images = pricing === undefined ? [] : nodes.flatMap(node => node.images) - if (pricing === undefined || images.length === 0) { + const hasFiles = fileText !== undefined && nodes.some(node => node.files.length > 0) + if ((pricing === undefined || images.length === 0) && !hasFiles) { let surfaceTokens = 0 const publicNodes = nodes.map((node) => { surfaceTokens += node.heuristicTokens @@ -42,8 +44,8 @@ export function priceSurface( }) return { nodes: publicNodes, surfaceTokens } } - const prices = pricing.priceImages(images) - if (prices.length !== images.length) { + const prices = pricing === undefined ? [] : pricing.priceImages(images) + if (pricing !== undefined && prices.length !== images.length) { throw new Error( `token meter: route image pricing answered ${prices.length} prices for ${images.length} occurrences`, ) @@ -52,8 +54,14 @@ export function priceSurface( let surfaceTokens = 0 const publicNodes = nodes.map((node) => { let tokens = node.heuristicTokens - if (node.images.length > 0) { - tokens = node.imageFreeTokens + if (fileText !== undefined && node.files.length > 0) { + tokens -= node.fileStructuralTokens + for (const file of node.files) { + tokens += estimateContent([{ type: 'text', text: fileText(file) }]) + } + } + if (pricing !== undefined && node.images.length > 0) { + tokens -= node.imageStructuralTokens for (let occurrence = 0; occurrence < node.images.length; occurrence += 1) { // oxlint-disable-next-line typescript/no-non-null-assertion -- length equality is asserted above const price = prices[cursor]! diff --git a/packages/llm/token-meter/src/surface-fold.ts b/packages/llm/token-meter/src/surface-fold.ts index ae293a7f57..b8478ae30d 100644 --- a/packages/llm/token-meter/src/surface-fold.ts +++ b/packages/llm/token-meter/src/surface-fold.ts @@ -10,8 +10,8 @@ * fallible step read-only and {@link commitSurfaceTokens} mutates in place, * so a throw leaves the caller's state untouched and the same malformed * event fails identically on every retry. - * Nodes also carry their durable image occurrences and image-free heuristic - * price, so `measure()` can reprice image content for the routed model. + * Nodes also carry durable attachment occurrences and their structural prices, + * so `measure()` can price the request representation sent to the model. * * @module @deepseek-ai/dsh-token-meter/surface-fold */ @@ -22,16 +22,22 @@ import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { estimateMessage, estimateStructuralBlock } from './estimate.ts' +type FileAttachmentRef = Extract['attachment'] + /** One priced surface node with the image occurrences route pricing replaces. */ export interface MeterSurfaceNode { /** Durable sequence number of the surface event. */ readonly seq: SessionSeq /** Fixed-heuristic price of the node's exact message. */ readonly heuristicTokens: number - /** Fixed-heuristic price with every image occurrence's structural price removed. */ - readonly imageFreeTokens: number + /** Structural JSON price replaced when the routed request projects images. */ + readonly imageStructuralTokens: number + /** Structural JSON price replaced when request assembly projects files to text. */ + readonly fileStructuralTokens: number /** Durable image occurrences in message order; empty for image-free nodes. */ readonly images: readonly ImageAttachmentRef[] + /** Durable file occurrences in message order; empty for file-free nodes. */ + readonly files: readonly FileAttachmentRef[] } /** One validated surface transition that has not mutated the priced surface yet. */ @@ -46,31 +52,53 @@ export interface SurfaceTokenPlan { readonly target: 'append' | { readonly startIdx: number; readonly endIdx: number } } -/** Collect image occurrences recursively and total their structural prices. */ -function collectImages(blocks: readonly ContentBlock[], images: ImageAttachmentRef[]): number { - let structuralTokens = 0 +/** Collect projected attachment occurrences and their structural prices. */ +function collectProjectedAttachments( + blocks: readonly ContentBlock[], + images: ImageAttachmentRef[], + files: FileAttachmentRef[], +): { readonly imageTokens: number; readonly fileTokens: number } { + let imageTokens = 0 + let fileTokens = 0 for (const block of blocks) { if (block.type === 'image') { images.push(block.attachment) - structuralTokens += estimateStructuralBlock(block) + imageTokens += estimateStructuralBlock(block) + } else if (block.type === 'file') { + files.push(block.attachment) + fileTokens += estimateStructuralBlock(block) } else if (block.type === 'tool-result') { - structuralTokens += collectImages(block.content, images) + const nested = collectProjectedAttachments(block.content, images, files) + imageTokens += nested.imageTokens + fileTokens += nested.fileTokens } } - return structuralTokens + return { imageTokens, fileTokens } } /** Build one priced node from a surface event's derived message. */ function analyzeNode(seq: SessionSeq, message: Message | null): MeterSurfaceNode { - if (message === null) return { seq, heuristicTokens: 0, imageFreeTokens: 0, images: [] } + if (message === null) { + return { + seq, + heuristicTokens: 0, + imageStructuralTokens: 0, + fileStructuralTokens: 0, + images: [], + files: [], + } + } const heuristicTokens = estimateMessage(message) const images: ImageAttachmentRef[] = [] - const imageStructuralTokens = collectImages(message.content, images) + const files: FileAttachmentRef[] = [] + const structural = collectProjectedAttachments(message.content, images, files) return { seq, heuristicTokens, - imageFreeTokens: heuristicTokens - imageStructuralTokens, + imageStructuralTokens: structural.imageTokens, + fileStructuralTokens: structural.fileTokens, images, + files, } } diff --git a/packages/llm/token-meter/tests/route-pricing.spec.ts b/packages/llm/token-meter/tests/route-pricing.spec.ts index 5dcec59d0f..174d76a922 100644 --- a/packages/llm/token-meter/tests/route-pricing.spec.ts +++ b/packages/llm/token-meter/tests/route-pricing.spec.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { LlmRuntime, LlmAdapter, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { + LlmRuntime, LlmAdapter, createMessage, createUserMessage, projectFilesToText, +} from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmImageRequestPricing, Message, StreamChunk, TokenUsage, UserMessage } from '@deepseek-ai/dsh-llm' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' import type { EpochHeader } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -53,6 +55,14 @@ function imageMessage(name: string, text = 'look at this'): UserMessage { }) } +function fileRef(name: string): FileAttachmentRef { + return { + attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), + name, + bytes: 2_447_000_000, + } +} + function header(model: string): EpochHeader { return canonicalHeader({ config: { provider: 'mock', model } }) } @@ -65,6 +75,12 @@ interface Harness { async function harness(pricing: (model: string) => LlmImageRequestPricing | undefined): Promise { const ctx = new Context() new SessionProjectionRegistry(ctx) + ctx.provide('attachments', { + fileHostPath: (ref: FileAttachmentRef) => `/host/${ref.name}`, + } as never) + ctx.provide('fs', { + processPathFromHostPath: (path: string) => path.replace('/host/', '/sandbox/'), + } as never) const llm = new LlmRuntime(ctx) llm.registerAdapter(['mock'], new PricingAdapter(pricing)) const meter = new TokenMeter(ctx) @@ -97,7 +113,23 @@ function appendSuccessfulCall(session: Session, value: EpochHeader, usage?: Toke session.append('step/end', { turn: 1, step: 1 }) } -describe('route-aware image pricing', () => { +describe('request projection pricing', () => { + it('prices file blocks as the exact handle text dispatched to the provider', async () => { + const { meter, session } = await harness(() => undefined) + const ref = fileRef('archive.zip') + const message = createUserMessage({ + content: [{ type: 'file', attachment: ref }], + source: { kind: 'user' }, + }) + session.append('user/message', message, { surfaceOp: 'append' }) + + const measurement = meter.measure(session) + const projected = projectFilesToText([message], file => `/sandbox/${file.name}`)[0] + if (projected === undefined) throw new Error('missing projected file message') + expect(measurement.nodes[0]?.tokens).toBe(estimateMessage(projected)) + expect(measurement.nodes[0]?.tokens).toBeGreaterThan(estimateMessage(message)) + }) + it('prices a first multimodal request estimate with the routed visual tokens', async () => { const { meter, session } = await harness(() => fixedPricing) const message = imageMessage('photo') diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index d8c5652950..249887b1fc 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp-stdio", "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index ab800ecb8d..69829139ff 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp", "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index 9f561f63b9..debf423646 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-lsp", "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index f9bdcfaa81..88e27ee2d6 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-mcp-client", "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index 9dd9a5e938..788b81ba04 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/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/plan/plan-mode/README.md -README.md: e768a19c67b2973ff32aa0fe29e51d1b90ea96c9 -README.zh.md: 9d2b1ed6e1eebee6f3e9cd27fcc8ebad2c626a89 +README.md: b9a86fe530923956365b17dbe5e0ececa28b3416 +README.zh.md: 5c1e21bac520dcd973c181d70139969abcff8b1b diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index e768a19c67..b9a86fe530 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-plan-mode` adds plan mode to the agent: while it is active, the agent explores and designs before executing, guided by instructions the deployment writes, and presents the finished plan for your approval before carrying it out. You enter plan mode with `/plan` (optionally with a message or images) and leave it with `/plan off`; the finished plan arrives as a review where you can approve it or send the agent back to keep planning. Plan mode is guidance, not enforcement: every tool stays available, so sandbox mode and approval prompts remain the way to impose limits. Choose it when the agent should think before acting, and plan mode carries over when a session resumes or forks. +`dsh-plan-mode` adds plan mode to the agent: while it is active, the agent explores and designs before executing, guided by instructions the deployment writes, and presents the finished plan for your approval before carrying it out. You enter plan mode with `/plan`, optionally carrying a message and ordered image or file attachments, and leave it with `/plan off`; the finished plan arrives as a review where you can approve it or send the agent back to keep planning. Plan mode is guidance, not enforcement: every tool stays available, so sandbox mode and approval prompts remain the way to impose limits. Choose it when the agent should think before acting, and plan mode carries over when a session resumes or forks. ## Table of Contents @@ -54,7 +54,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a Type `/plan` to enter plan mode, or `/plan ` to enter with an instruction — the message becomes your next request under plan guidance. Type `/plan off` to leave plan mode directly; it also cancels a plan-mode entry that has not taken effect yet. -You can attach images to a `/plan` message, and they are included with your instruction. `/plan off` with images is rejected so the images are not lost. The `/plan` command is available wherever slash commands are supported, such as the Web client. +You can attach images and generic files to a `/plan` message, and they are included with your instruction in selection order. `/plan off` with attachments is rejected before the mode changes, so the draft and cards remain available. The `/plan` command is available wherever slash commands are supported, such as the Web client. ### The reviewed exit @@ -86,7 +86,7 @@ The package persists one log-only whole-value event, `plan/mode`, and the last l ### The `/plan` command -The command child activates only when a commands service is composed. It maps bare `/plan` to active, the exact argument `off` to inactive without model input, and any other non-empty argument to active plus the trimmed text submitted through `agent.steer()` as the next step's ordinary logged user message; image attachments ride the steered message, and `/plan off` with images fails before any mode change. Entry points other than the command may drive `ctx.planMode` directly; the exact branch handling is in [`src/index.ts`](src/index.ts). +The command child activates only when a commands service is composed. It maps bare `/plan` to active, the exact argument `off` to inactive without model input, and any other non-empty argument to active plus the trimmed text submitted through `agent.steer()` as the next step's ordinary logged user message. Admitted image and file blocks keep their selection order in that message; `/plan off` with attachments fails before any mode change. Entry points other than the command may drive `ctx.planMode` directly; the exact branch handling is in [`src/index.ts`](src/index.ts). ### The exit tool @@ -149,11 +149,11 @@ The section is stable within plan mode, but entering or leaving changes the syst #### What the model sees -`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one user message through `agent.steer()` after plan mode is selected: any admitted image attachments as leading image blocks, then the trimmed text block. Bare `/plan` with admitted images steers one user message containing only those image blocks. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it. +`/plan`, `/plan off`, and their terminal results stay outside model history. A non-empty suffix other than the exact `off` argument becomes one user message through `agent.steer()` after plan mode is selected: admitted image and file blocks in selection order, then the trimmed text block. Bare `/plan` with admitted attachments steers one user message containing only those blocks. An active `/plan off` selection contributes the standard logged user-switch notice only when the last request header described plan mode; cancelling a pending entry contributes none because no request observed it. #### Token effect -The optional message costs the same history tokens as submitting that content separately. Bare `/plan` without images and `/plan off` add none; bare `/plan` with images has the normal image-prompt cost. A narrated active exit adds the small retained switch notice. +The optional message costs the same history tokens as submitting that content separately. Bare `/plan` without attachments and `/plan off` add none; bare `/plan` with attachments has the normal image and file-handle cost. A narrated active exit adds the small retained switch notice. #### KV Cache effect diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 9d2b1ed6e1..5c1e21bac5 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-plan-mode` 为 agent(智能体)提供计划模式:激活期间,agent 先探索和设计再执行,遵循你的部署所写的引导行事,并在执行前把完成的计划呈交你批准。你可以用 `/plan`(可附带消息或图片)进入计划模式,用 `/plan off` 离开;完成的计划会以评审形式呈现,你可以批准它,或让 agent 回去继续规划。计划模式是引导而非强制:每个工具仍然可用,因此沙箱模式与审批提示仍是施加限制的方式。当希望 agent 先思考再行动时选择它;会话恢复或 fork 后计划模式依然保持。 +`dsh-plan-mode` 为 agent(智能体)提供计划模式:激活期间,agent 先探索和设计再执行,遵循你的部署所写的引导行事,并在执行前把完成的计划呈交你批准。你可以用 `/plan` 进入计划模式,并随命令附带消息及有序的图片或文件附件;用 `/plan off` 离开。完成的计划会以评审形式呈现,你可以批准它,或让 agent 回去继续规划。计划模式是引导而非强制:每个工具仍然可用,因此沙箱模式与审批提示仍是施加限制的方式。当希望 agent 先思考再行动时选择它;会话恢复或 fork 后计划模式依然保持。 ## 目录 @@ -54,7 +54,7 @@ kind: "package-reference" 输入 `/plan` 进入计划模式,或输入 `/plan ` 连同一条指令一起进入——该消息会成为你在计划引导下的下一条请求。输入 `/plan off` 直接离开计划模式;它还会取消尚未生效的计划模式进入。 -你可以在 `/plan` 消息中附带图片,图片会随你的指令一起提交。带图片的 `/plan off` 会被拒绝,因此图片不会丢失。`/plan` 命令在支持斜杠命令的界面中可用,例如 Web 客户端。 +你可以在 `/plan` 消息中附带图片与通用文件,它们按选择顺序随指令一起提交。带附件的 `/plan off` 会在模式变更前被拒绝,因此草稿和附件卡仍可继续使用。`/plan` 命令在支持斜杠命令的界面中可用,例如 Web 客户端。 ### 经评审的退出 @@ -86,7 +86,7 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并 ### `/plan` 命令 -命令子插件只在组合了命令服务时激活。它把不带参数的 `/plan` 映射为激活,把恰好为 `off` 的参数映射为未激活且不发送模型输入,把其他非空参数映射为激活并把去除首尾空白的文本通过 `agent.steer()` 作为下一步骤的普通已记录用户消息提交;图片附件随被 steer 的消息一起提交,带图片的 `/plan off` 会在任何模式变更前失败。命令以外的入口可以直接驱动 `ctx.planMode`;确切的分支处理见 [`src/index.ts`](src/index.ts)。 +命令子插件只在组合了命令服务时激活。它把不带参数的 `/plan` 映射为激活,把恰好为 `off` 的参数映射为未激活且不发送模型输入,把其他非空参数映射为激活并把去除首尾空白的文本通过 `agent.steer()` 作为下一步骤的普通已记录用户消息提交。已准入的图片块与文件块按选择顺序进入这条消息;带附件的 `/plan off` 会在模式变更前失败。命令以外的入口可以直接驱动 `ctx.planMode`;确切的分支处理见 [`src/index.ts`](src/index.ts)。 ### 退出工具 @@ -149,11 +149,11 @@ You are in plan mode. Explore and design before presenting the complete plan thr #### 模型看到什么 -`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择计划模式后,通过 `agent.steer()` 成为一条用户消息:任何已准入的图片附件作为前置图片块,之后是去除首尾空白的文本块。不带参数的 `/plan` 若带有已准入图片,会 steer 一条只含这些图片块的用户消息。计划模式已激活时选择 `/plan off`,只会在最后记录的请求头描述了计划模式的情况下追加标准的已记录用户切换通知;取消待生效条目不贡献通知,因为没有请求观测到它。 +`/plan`、`/plan off` 及其终端结果留在模型历史之外。除恰好为 `off` 以外的非空后缀会在选择计划模式后,通过 `agent.steer()` 成为一条用户消息:已准入的图片块与文件块按选择顺序排列,之后是去除首尾空白的文本块。不带参数的 `/plan` 若带有已准入附件,会 steer 一条只含这些块的用户消息。计划模式已激活时选择 `/plan off`,只会在最后记录的请求头描述了计划模式的情况下追加标准的已记录用户切换通知;取消待生效条目不贡献通知,因为没有请求观测到它。 #### Token 影响 -可选消息的历史 token 成本与单独提交该内容相同。不带图片的 `/plan` 与 `/plan off` 不增加 token;带图片的 `/plan` 产生常规图片提示词成本。一次带叙述的激活状态退出会追加一条简短且会保留的切换通知。 +可选消息的历史 token 成本与单独提交该内容相同。不带附件的 `/plan` 与 `/plan off` 不增加 token;带附件的 `/plan` 产生常规图片与文件句柄成本。一次带叙述的激活状态退出会追加一条简短且会保留的切换通知。 #### KV Cache 影响 diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index f8edff4006..f11cb9605f 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plan-mode", "description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 1372a90f50..392f9193e7 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -225,11 +225,11 @@ export class PlanModeController extends Service { commandCtx.commands.register({ name: 'plan', description: 'Enter or leave plan mode', - input: { hint: '[off|message]', images: true }, + input: { hint: '[off|message]', attachments: true }, handler: ({ agent, rawInput, attachments }) => { const message = rawInput.trim() if (message === 'off' && attachments.length > 0) { - return { kind: 'error', text: 'Image attachments cannot accompany /plan off.' } + return { kind: 'error', text: 'Attachments cannot accompany /plan off.' } } if (message === 'off') { switch (this.set(agent, false)) { diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index cd53724e34..0d2dded443 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -620,7 +620,7 @@ describe('/plan', () => { const plainSteer = vi.fn() ;(plainAgent as unknown as { steer: typeof plainSteer }).steer = plainSteer expect(ctx.commands.list(plainAgent)).toEqual([ - { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', images: true } }, + { name: 'plan', description: 'Enter or leave plan mode', input: { hint: '[off|message]', attachments: true } }, ]) const signal = new AbortController().signal @@ -704,7 +704,7 @@ describe('/plan', () => { expect(foldPlanMode(agent.session.snapshotEvents())).toBe(false) }) - it('steers image attachments with or without text and refuses them on /plan off', async () => { + it('steers mixed attachments with or without text and refuses them on /plan off', async () => { const ctx = await setup() await ctx.plugin(CommandRuntime) await new Promise(resolve => setImmediate(resolve)) @@ -727,21 +727,34 @@ describe('/plan', () => { for (const input of inputs) refs.push(await saveImage(input)) return refs }, + saveFile(input: { data: Uint8Array; name?: string }) { + saved += 1 + return Promise.resolve({ + attachmentId: `att-${saved}`, bytes: input.data.byteLength, name: input.name ?? 'attachment', + }) + }, }) + ctx.commands.registerFileReceiptResolver((agent, receiptId) => receiptId === 'receipt-notes' + ? { attachmentId: `file-${agent.id}` as never, bytes: 5, name: 'notes.txt' } + : undefined) const signal = new AbortController().signal - const images = [{ mediaType: 'image/png' as const, data: 'AAAA' }] + const attachments = [ + { type: 'image' as const, mediaType: 'image/png' as const, data: 'AAAA', name: 'diagram.png' }, + { type: 'file' as const, receiptId: 'receipt-notes' }, + ] const agent = await agentWithSession(ctx, 'imaged-plan-command') openTurn(agent.session) const steer = vi.fn() ;(agent as unknown as { steer: typeof steer }).steer = steer - const withMessage = await ctx.commands.execute(agent, '/plan sketch the layout', images, signal) + const withMessage = await ctx.commands.execute(agent, '/plan sketch the layout', attachments, signal) expect(withMessage?.result.kind).toBe('success') expect(steer).toHaveBeenCalledExactlyOnceWith({ id: expect.any(String) as unknown, role: 'user', content: [ { type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-1' }) as unknown }, + { type: 'file', attachment: expect.objectContaining({ attachmentId: 'file-imaged-plan-command', name: 'notes.txt' }) as unknown }, { type: 'text', text: 'sketch the layout' }, ], source: { kind: 'user' }, @@ -751,12 +764,15 @@ describe('/plan', () => { openTurn(bareAgent.session) const bareSteer = vi.fn() ;(bareAgent as unknown as { steer: typeof bareSteer }).steer = bareSteer - expect((await ctx.commands.execute(bareAgent, '/plan', images, signal))?.result) + expect((await ctx.commands.execute(bareAgent, '/plan', attachments, signal))?.result) .toEqual({ kind: 'success', text: 'Entering plan mode (applies from the next step). Use /plan off to leave.' }) expect(bareSteer).toHaveBeenCalledExactlyOnceWith({ id: expect.any(String) as unknown, role: 'user', - content: [{ type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-2' }) as unknown }], + content: [ + { type: 'image', attachment: expect.objectContaining({ attachmentId: 'att-2' }) as unknown }, + { type: 'file', attachment: expect.objectContaining({ attachmentId: 'file-imaged-bare-plan-command', name: 'notes.txt' }) as unknown }, + ], source: { kind: 'user' }, }) expect(ctx.planMode.get(bareAgent)).toEqual({ active: false, pending: true }) @@ -764,8 +780,8 @@ describe('/plan', () => { const activeAgent = await agentWithSession(ctx, 'imaged-off-plan-command', { active: true }) const offSteer = vi.fn() ;(activeAgent as unknown as { steer: typeof offSteer }).steer = offSteer - expect((await ctx.commands.execute(activeAgent, '/plan off', images, signal))?.result) - .toEqual({ kind: 'error', text: 'Image attachments cannot accompany /plan off.' }) + expect((await ctx.commands.execute(activeAgent, '/plan off', attachments, signal))?.result) + .toEqual({ kind: 'error', text: 'Attachments cannot accompany /plan off.' }) expect(offSteer).not.toHaveBeenCalled() expect(ctx.planMode.get(activeAgent)).toEqual({ active: true }) }) diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index a8541e77af..6a28925fb8 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-presets", "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index 656f4bdb7f..0723fe93da 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -6,7 +6,7 @@ * stays read-only. */ -import { chmod, mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -15,7 +15,7 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import AgentPresets, { COMPOSITION_FILE, copyComposition, METADATA_FILE, type Config, } from '@deepseek-ai/dsh-agent-presets' @@ -23,6 +23,12 @@ import AgentPresets, { const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\n tool: alpha\n' +/** Every temp root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + let ctx: Context let userRoot: string @@ -49,6 +55,7 @@ async function seedPreset( beforeEach(async () => { userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-authoring-')) + roots.push(userRoot) ctx = new Context() ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' await ctx.plugin(Loader) @@ -201,6 +208,7 @@ describe('deleting a preset', () => { describe('a deployment with more than one user root', () => { it('refuses to delete a preset the writable root does not own', async () => { const second = await mkdtemp(join(tmpdir(), 'dsh-preset-second-')) + roots.push(second) await seedPreset(second, 'elsewhere') const layered = new Context() layered.baseUrl = pathToFileURL(FIXTURES).href + '/' @@ -246,7 +254,9 @@ describe('a deployment with no writable root', () => { describe('a user root that does not exist yet', () => { it('is created by the first copy', async () => { - const absent = join(await mkdtemp(join(tmpdir(), 'dsh-preset-absent-')), 'nested', 'preset') + const absentRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-absent-')) + roots.push(absentRoot) + const absent = join(absentRoot, 'nested', 'preset') const fresh = new Context() fresh.baseUrl = pathToFileURL(FIXTURES).href + '/' await fresh.plugin(Loader) diff --git a/packages/preset/agent-presets/tests/composition-inventory.spec.ts b/packages/preset/agent-presets/tests/composition-inventory.spec.ts index fa6cdf596a..74565362e2 100644 --- a/packages/preset/agent-presets/tests/composition-inventory.spec.ts +++ b/packages/preset/agent-presets/tests/composition-inventory.spec.ts @@ -5,7 +5,7 @@ * read reported broken by reason instead of dropped. */ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -33,6 +33,9 @@ const VALID = '- id: prompt\n name: \'@deepseek-ai/dsh-system-prompt\'\n' const contexts: Context[] = [] +/** Every temp root created by this file, removed after its contexts settle. */ +const roots: string[] = [] + /** A Loader-context evaluator over an empty scope, enough for literal gates. */ const evaluateExpression = (expression: string): unknown => evaluate({}, expression) /** An evaluator that refuses every expression, leaving rows conditional. */ @@ -41,6 +44,7 @@ const refuseExpression = (): never => { throw new Error('no loader context') } afterEach(async () => { vi.restoreAllMocks() await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) async function harness(roster: Config): Promise { @@ -63,6 +67,7 @@ async function harness(roster: Config): Promise { describe('fileComposition', () => { it('flattens groups and keeps refused expressions conditional', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-')) + roots.push(dir) const path = join(dir, COMPOSITION_FILE) await writeFile(path, [ '- id: alpha', @@ -124,6 +129,7 @@ describe('fileComposition', () => { it('evaluates decidable gates the way a mount would', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-')) + roots.push(dir) const path = join(dir, COMPOSITION_FILE) await writeFile(path, [ '- id: off', @@ -144,6 +150,7 @@ describe('fileComposition', () => { it('answers broken for a file that stopped reading as a composition', async () => { const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-')) + roots.push(dir) const missing = await fileComposition(join(dir, COMPOSITION_FILE), refuseExpression) expect(missing).toHaveProperty('broken') @@ -201,6 +208,7 @@ describe('mountedCompositionRows', () => { describe('AgentPresets.compositionInventory', () => { it('reads unmounted presets from their files, marking the default and metadata', async () => { const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-')) + roots.push(userRoot) await mkdir(join(userRoot, 'documented')) await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), [ VALID.trimEnd(), @@ -287,6 +295,7 @@ describe('AgentPresets.compositionInventory', () => { it('prefers the standing mount over a file that broke after mounting', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-composition-volatile-')) + roots.push(root) await mkdir(join(root, 'volatile')) const plugin = join(FIXTURES, 'plugins', 'contribute.js') await writeFile( @@ -344,6 +353,7 @@ describe('AgentPresets.compositionInventory', () => { it('keeps a broken preset on the inventory with its discovery reason', async () => { const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-')) + roots.push(userRoot) await mkdir(join(userRoot, 'damaged')) const ctx = await harness({ default: 'minimal', diff --git a/packages/preset/agent-presets/tests/discovery.spec.ts b/packages/preset/agent-presets/tests/discovery.spec.ts index 4ff1843557..3c22525bdf 100644 --- a/packages/preset/agent-presets/tests/discovery.spec.ts +++ b/packages/preset/agent-presets/tests/discovery.spec.ts @@ -1,8 +1,8 @@ -import { mkdtemp, mkdir, symlink, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { COMPOSITION_FILE, discoverPresets, scanRoot } from '@deepseek-ai/dsh-agent-presets' const fsHarness = vi.hoisted(() => ({ @@ -31,6 +31,12 @@ const HARNESS = new URL('.', import.meta.url).href const SYSTEM = { path: join(FIXTURES, 'system'), trust: 'system' as const } const USER = { path: join(FIXTURES, 'user'), trust: 'user' as const } +/** Every temp root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + beforeEach(() => { fsHarness.nextReadError = undefined }) @@ -38,6 +44,7 @@ beforeEach(() => { describe('display order', () => { it('puts declared order first, then everything else by id', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-order-')) + roots.push(root) for (const [id, order] of [['zulu', 1], ['alpha', 2]] as const) { await mkdir(join(root, id), { recursive: true }) await writeFile(join(root, id, COMPOSITION_FILE), '[]\n') @@ -57,6 +64,7 @@ describe('display order', () => { it('breaks a tie between equal declared orders by id', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-order-tie-')) + roots.push(root) for (const id of ['yankee', 'alpha']) { await mkdir(join(root, id), { recursive: true }) await writeFile(join(root, id, COMPOSITION_FILE), '[]\n') @@ -94,6 +102,7 @@ describe('preset discovery', () => { it('skips a directory whose name no preset id could ever claim', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-presets-oddname-')) + roots.push(root) await mkdir(join(root, '.hidden')) await mkdir(join(root, 'Has_Caps')) await mkdir(join(root, 'usable')) @@ -129,6 +138,7 @@ describe('preset discovery', () => { it('ignores a plain file sitting beside the preset directories', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-presets-')) + roots.push(root) await writeFile(join(root, 'stray.yml'), '- id: x\n') await mkdir(join(root, 'real')) await writeFile(join(root, 'real', COMPOSITION_FILE), '[]\n') @@ -140,6 +150,7 @@ describe('preset discovery', () => { it('reports a root it cannot read rather than treating it as empty', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-presets-')) + roots.push(root) const notADirectory = join(root, 'file-as-root') await writeFile(notADirectory, 'not a directory\n') @@ -168,6 +179,7 @@ describe('composition health', () => { */ async function scanned(composition: string): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-presets-health-')) + roots.push(root) await mkdir(join(root, 'probe')) await writeFile(join(root, 'probe', COMPOSITION_FILE), composition) const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS) @@ -208,6 +220,7 @@ describe('composition health', () => { it('reports a composition that stats but cannot be read', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-presets-unreadable-')) + roots.push(root) await mkdir(join(root, 'sealed')) const path = join(root, 'sealed', COMPOSITION_FILE) await writeFile(path, '[]\n') @@ -235,6 +248,7 @@ describe('rows naming a plugin that cannot be resolved', () => { /** One directory under a fresh root holding `composition`, scanned. */ async function scanned(composition: string): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-presets-resolve-')) + roots.push(root) await mkdir(join(root, 'probe')) await writeFile(join(root, 'probe', COMPOSITION_FILE), composition) const [preset] = await scanRoot({ path: root, trust: 'user' }, HARNESS) @@ -272,6 +286,7 @@ describe('rows naming a plugin that cannot be resolved', () => { it('resolves a preset-relative row against the preset\'s own directory', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-presets-relative-')) + roots.push(root) await mkdir(join(root, 'probe')) await writeFile(join(root, 'probe', 'own-plugin.mjs'), 'export function apply() {}\n') await writeFile(join(root, 'probe', COMPOSITION_FILE), '- id: own\n name: ./own-plugin.mjs\n- id: gone\n name: ./deleted.mjs\n') @@ -311,6 +326,7 @@ describe('rows naming a plugin that cannot be resolved', () => { // The fast path, and the one that has to answer alone: this package has a // directory and nothing to import, so a resolver would reject it. const home = await mkdtemp(join(tmpdir(), 'dsh-presets-installed-')) + roots.push(home) await mkdir(join(home, 'node_modules', '@scope', 'pkg'), { recursive: true }) await writeFile(join(home, 'node_modules', '@scope', 'pkg', 'package.json'), '{"name":"@scope/pkg"}\n') await mkdir(join(home, 'presets', 'probe'), { recursive: true }) @@ -326,6 +342,7 @@ describe('rows naming a plugin that cannot be resolved', () => { // What a stale profile install leaves behind: the name is still in // `node_modules`, pointing at a checkout that is gone. const home = await mkdtemp(join(tmpdir(), 'dsh-presets-dangling-')) + roots.push(home) await mkdir(join(home, 'node_modules', '@scope'), { recursive: true }) await symlink(join(home, 'deleted-checkout'), join(home, 'node_modules', '@scope', 'pkg')) await mkdir(join(home, 'presets', 'probe'), { recursive: true }) diff --git a/packages/preset/agent-presets/tests/metadata.spec.ts b/packages/preset/agent-presets/tests/metadata.spec.ts index 8bdabe1c59..99ed7b97f8 100644 --- a/packages/preset/agent-presets/tests/metadata.spec.ts +++ b/packages/preset/agent-presets/tests/metadata.spec.ts @@ -6,15 +6,22 @@ * from the file a user can write. */ -import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { METADATA_FILE, readPresetMetadata, renderPresetMetadata } from '../src/metadata.ts' +/** Every temp preset directory created by this file, removed after each test. */ +const tempDirs: string[] = [] +afterEach(async () => { + for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }) +}) + /** A preset directory holding exactly the given metadata text. */ async function presetDir(content?: string): Promise { const dir = await mkdtemp(join(tmpdir(), 'dsh-preset-meta-')) + tempDirs.push(dir) await mkdir(dir, { recursive: true }) if (content !== undefined) await writeFile(join(dir, METADATA_FILE), content) return dir diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index 9d5d052724..bd78447fff 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -13,7 +13,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import AgentPresets, { COMPOSITION_FILE, leakedServices, livePresetMounts, mountPreset, serviceForAgent, } from '@deepseek-ai/dsh-agent-presets' @@ -86,6 +86,13 @@ function rootResolves(ctx: Context, name: string): boolean { } let ctx: Context + +/** Every temp preset root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + beforeEach(async () => { ctx = await harness() }) @@ -93,6 +100,7 @@ beforeEach(async () => { describe('composing an agent from a preset', () => { it('hands an absolute plugin path to Node as a file URL', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-preset-absolute-plugin-')) + roots.push(root) const presetDir = join(root, 'absolute') const plugin = join(FIXTURES, 'plugins', 'contribute.js') await mkdir(presetDir) @@ -360,6 +368,7 @@ describe('composing from a broken preset', () => { /** A roster whose only user preset carries `composition`. */ async function rosterWith(composition: string): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-preset-broken-')) + roots.push(root) await mkdir(join(root, 'damaged')) await writeFile(join(root, 'damaged', COMPOSITION_FILE), composition) return await harness({ default: 'damaged', roots: [{ path: root, trust: 'user' as const }], includeShippedRoot: false, includeUserRoot: false }) @@ -428,6 +437,7 @@ describe('the preset file is an input, never a persistence target', () => { // committed fixture would be mutated by the very run that proves the bug // and every later run would compare against the damaged file and pass. const root = await mkdtemp(join(tmpdir(), 'dsh-preset-write-')) + roots.push(root) const dir = join(root, 'self-disposing') await mkdir(dir) const path = join(dir, COMPOSITION_FILE) @@ -625,6 +635,7 @@ describe('replacing a composition', () => { // A preset root this test owns, so removing the composition mid-flight // cannot disturb the shipped fixtures. const root = await mkdtemp(join(tmpdir(), 'dsh-preset-restore-')) + roots.push(root) const seeded: [string, string][] = [['first', `- id: only\n name: ${join(FIXTURES, 'plugins', 'contribute.js')}\n config:\n tool: only\n`], ['broken', `- id: nope\n name: ${join(FIXTURES, 'plugins', 'throws.js')}\n config:\n message: refuses\n`]] for (const [id, body] of seeded) { await mkdir(join(root, id)) @@ -679,6 +690,7 @@ describe('editing a composition file', () => { */ async function editable(id: string): Promise<{ scoped: Context; path: string }> { const root = await mkdtemp(join(tmpdir(), 'dsh-preset-edit-')) + roots.push(root) await mkdir(join(root, id)) const path = join(root, id, COMPOSITION_FILE) await writeFile(path, rowFor('before')) diff --git a/packages/preset/agent-presets/tests/remote.spec.ts b/packages/preset/agent-presets/tests/remote.spec.ts index 15b683a986..d04eed3514 100644 --- a/packages/preset/agent-presets/tests/remote.spec.ts +++ b/packages/preset/agent-presets/tests/remote.spec.ts @@ -4,7 +4,7 @@ * which is the only one of the three that mutates an agent. */ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -34,7 +34,13 @@ const ROOTS = [ // temp preset directory these tests seed would report the composition broken. const VALID = '- id: prompt\n name: \'@deepseek-ai/dsh-system-prompt\'\n' -afterEach(() => vi.restoreAllMocks()) +/** Every temp preset root created by this file, removed after each test. */ +const roots: string[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) async function remoteFailure(operation: Promise): Promise { try { @@ -97,6 +103,7 @@ const recordedPreset = (agent: Agent): unknown => describe('the roster a client reads', () => { it('projects path-free rows, marking the default and carrying published metadata', async () => { const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-')) + roots.push(userRoot) await mkdir(join(userRoot, 'documented'), { recursive: true }) await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID) await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n') @@ -122,6 +129,7 @@ describe('the roster a client reads', () => { it('keeps a broken preset on the roster with its reason', async () => { const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-')) + roots.push(userRoot) await mkdir(join(userRoot, 'damaged'), { recursive: true }) const ctx = await harness({ default: 'standard', @@ -174,6 +182,7 @@ describe('reading one composition', () => { it('carries the display metadata a preset published', async () => { const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-')) + roots.push(userRoot) await mkdir(join(userRoot, 'documented'), { recursive: true }) await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), VALID) await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\ndescription: 只做检索。\n') @@ -241,6 +250,7 @@ describe('authoring over Remote', () => { it('copies and deletes through the Remote adapters', async () => { const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-')) + roots.push(userRoot) const ctx = await harness({ default: 'standard', roots: [{ path: join(FIXTURES, 'system'), trust: 'system' }, { path: userRoot, trust: 'user' }], @@ -432,6 +442,7 @@ describe('switching one session\'s composition', () => { it('reports an unusable composition with its discovery reason', async () => { const userRoot = await mkdtemp(join(tmpdir(), 'dsh-preset-remote-')) + roots.push(userRoot) await mkdir(join(userRoot, 'damaged'), { recursive: true }) const ctx = await harness({ default: 'standard', diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index 9445eb604e..887c288aa5 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -4,7 +4,7 @@ * so a person can change which preset new sessions get without a restart. */ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -19,13 +19,19 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import FileSettingsProvider from '@deepseek-ai/dsh-settings-file' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import AgentPresets, { COMPOSITION_FILE, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') const ROOTS = [{ path: join(FIXTURES, 'system'), trust: 'system' as const }] const NS = SETTINGS_NAMESPACE +/** Every temp root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(async () => { + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) + /** * A composition with a real file-backed settings provider. `settingsFiber` is * the provider's own handle, so a test can take it away the way a reload does. @@ -34,6 +40,7 @@ async function harness( extraRoots: readonly { path: string; trust: 'system' | 'user' }[] = [], ): Promise<{ ctx: Context; settingsFile: string; settingsFiber: { dispose: () => unknown } }> { const home = await mkdtemp(join(tmpdir(), 'dsh-preset-settings-')) + roots.push(home) const settingsFile = join(home, 'settings.yaml') await writeFile(settingsFile, '{}\n') @@ -119,6 +126,7 @@ describe('the default preset as a user setting', () => { it('clears a user default it has just deleted', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-preset-authored-')) + roots.push(root) await mkdir(join(root, 'mine')) await writeFile( join(root, 'mine', COMPOSITION_FILE), diff --git a/packages/preset/agent-presets/tests/shipped-root.spec.ts b/packages/preset/agent-presets/tests/shipped-root.spec.ts index 323c56ee12..6b6dcd1789 100644 --- a/packages/preset/agent-presets/tests/shipped-root.spec.ts +++ b/packages/preset/agent-presets/tests/shipped-root.spec.ts @@ -9,7 +9,7 @@ * suite: the derived writable root is resolved in the constructor. */ -import { mkdtemp, readFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -24,16 +24,19 @@ import AgentPresets, { SHIPPED_PRESET_ROOT, type Config } from '@deepseek-ai/dsh const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') const SYSTEM_ROOT = join(FIXTURES, 'system') +let home: string let previousHome: string | undefined beforeEach(async () => { previousHome = process.env.DSH_HOME - process.env.DSH_HOME = await mkdtemp(join(tmpdir(), 'dsh-shipped-root-')) + home = await mkdtemp(join(tmpdir(), 'dsh-shipped-root-')) + process.env.DSH_HOME = home }) -afterEach(() => { +afterEach(async () => { if (previousHome === undefined) delete process.env.DSH_HOME else process.env.DSH_HOME = previousHome + await rm(home, { recursive: true, force: true }) }) /** Boot a roster with the shipped root left to the plugin's default. */ diff --git a/packages/preset/agent-presets/tests/user-root.spec.ts b/packages/preset/agent-presets/tests/user-root.spec.ts index 6da05b2ca7..f8eaf15a6d 100644 --- a/packages/preset/agent-presets/tests/user-root.spec.ts +++ b/packages/preset/agent-presets/tests/user-root.spec.ts @@ -10,7 +10,7 @@ * temporary home, or it would reach the developer's real one. */ -import { mkdtemp, mkdir, writeFile } from 'node:fs/promises' +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -31,15 +31,20 @@ const VALID = '- id: tool-alpha\n name: ../../plugins/contribute.js\n config:\ let home: string let previousHome: string | undefined +/** Extra per-test roots, removed with the home directory. */ +const explicitRoots: string[] = [] + beforeEach(async () => { home = await mkdtemp(join(tmpdir(), 'dsh-preset-home-')) previousHome = process.env.DSH_HOME process.env.DSH_HOME = home }) -afterEach(() => { +afterEach(async () => { if (previousHome === undefined) delete process.env.DSH_HOME else process.env.DSH_HOME = previousHome + await rm(home, { recursive: true, force: true }) + for (const root of explicitRoots.splice(0)) await rm(root, { recursive: true, force: true }) }) /** Boot a roster over the fixture system root, with the derived root left to the plugin. */ @@ -120,6 +125,7 @@ describe('the harness-home preset root', () => { it('yields to a configured user root for authoring, which writableRoot takes first', async () => { const explicit = await mkdtemp(join(tmpdir(), 'dsh-preset-explicit-')) + explicitRoots.push(explicit) const ctx = await roster({ roots: [ { path: SYSTEM_ROOT, trust: 'system' as const }, diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index 30d0a5fdd1..f1ba3d3974 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-persona", "description": "Composition-authored deployment persona section for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json index 538a804934..6764c65134 100644 --- a/packages/runtime-diagnostics/invariants/package.json +++ b/packages/runtime-diagnostics/invariants/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index d64f21a711..b6d0107805 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-local", "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 285afb865b..3cfcdde6e3 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -7,10 +7,10 @@ * are all exercised through the real `confine()` path. */ -import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' @@ -24,6 +24,12 @@ import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from '../s const RO: SandboxPolicy = { mode: 'read-only', workspaceRoot: '/ws' } const WW: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } +/** Every temp dir created by this file (fake launchers and runner entries), removed after each test. */ +const tempDirs: string[] = [] +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + async function setup(config: Config = {}, internals: LocalSandboxProvider['internals'] = {}) { const ctx = new Context() await ctx.plugin(LocalSandboxProvider, config) @@ -39,12 +45,15 @@ async function setup(config: Config = {}, internals: LocalSandboxProvider['inter * `sandbox-windows-acl/lib/runner.js`. */ function absentRunnerEntry(): string { - return join(mkdtempSync(join(tmpdir(), 'dsh-absent-acl-entry-')), 'runner.js') + const dir = mkdtempSync(join(tmpdir(), 'dsh-absent-acl-entry-')) + tempDirs.push(dir) + return join(dir, 'runner.js') } /** Write an executable fake `landlock-run` that answers `--probe` with `report`. */ function fakeLauncher(report = 'landlock: fully enforced'): string { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) + tempDirs.push(dir) const launcher = join(dir, 'landlock-run') writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = "--probe" ]; then echo "${report}"; exit 0; fi\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 }) return launcher @@ -53,6 +62,7 @@ function fakeLauncher(report = 'landlock: fully enforced'): string { /** Write an executable fake `sandbox-exec` that exits `status` for any invocation. */ function fakeSeatbeltExec(status: number): string { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-seatbelt-')) + tempDirs.push(dir) const exec = join(dir, 'sandbox-exec') writeFileSync(exec, `#!/bin/sh\nexit ${status}\n`, { mode: 0o755 }) return exec @@ -316,6 +326,7 @@ describe('the default landlock probe (launcher CLI contract)', () => { it('reads a failing launcher as unusable: the chain ends and fails closed', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-landlock-')) + tempDirs.push(dir) const launcher = join(dir, 'landlock-run') writeFileSync(launcher, `#!/bin/sh\nexit ${LAUNCHER_FAILURE_EXIT}\n`, { mode: 0o755 }) const { sandbox } = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher }) @@ -337,6 +348,7 @@ describe('probeTimeoutMs config', () => { // spawnSync blocks the worker and fork/exec latency inflates wall-clock) // cannot flip either verdict; the vitest timeout clears the patient budget. const dir = mkdtempSync(join(tmpdir(), 'dsh-slow-landlock-')) + tempDirs.push(dir) const launcher = join(dir, 'landlock-run') writeFileSync(launcher, '#!/bin/sh\nsleep 1\necho "landlock: fully enforced"\nexit 0\n', { mode: 0o755 }) @@ -440,6 +452,7 @@ describe('the windows-acl probe (runner invocation contract)', () => { it('prefers the built lib/runner.js entry when the resolved file exists', async () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-fake-acl-entry-')) + tempDirs.push(dir) const builtEntry = join(dir, 'runner.js') writeFileSync(builtEntry, '') const { sandbox } = await setup({}, { diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index c497d1b402..25020b5118 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-policy", "description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 53abd4f58f..ebc22692da 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-windows-acl", "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 5c3164917a..320f14fe55 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox", "description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox/tests/roots.spec.ts b/packages/sandbox/sandbox/tests/roots.spec.ts index 49fdc03816..1541363b95 100644 --- a/packages/sandbox/sandbox/tests/roots.spec.ts +++ b/packages/sandbox/sandbox/tests/roots.spec.ts @@ -4,16 +4,22 @@ * deriving from `writableRoots` — cannot drift. */ -import { realpathSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' -import { mkdtempSync } from 'node:fs' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox' +/** Every temp root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + describe('canonicalPath', () => { it('resolves symlinks (an existing path realpaths)', () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-roots-')) + roots.push(dir) expect(canonicalPath(dir)).toBe(realpathSync.native(dir)) }) @@ -29,11 +35,12 @@ describe('writableRoots', () => { it('workspace-write grants the workspace root plus the platform temp areas, canonical and deduplicated', () => { const ws = mkdtempSync(join(tmpdir(), 'dsh-ws-')) - const roots = writableRoots({ mode: 'workspace-write', workspaceRoot: ws }) - expect(roots).toContain(realpathSync.native(ws)) - expect(roots).toContain(canonicalPath('/tmp')) - expect(roots).toContain(realpathSync.native(tmpdir())) + roots.push(ws) + const writable = writableRoots({ mode: 'workspace-write', workspaceRoot: ws }) + expect(writable).toContain(realpathSync.native(ws)) + expect(writable).toContain(canonicalPath('/tmp')) + expect(writable).toContain(realpathSync.native(tmpdir())) // Deduplicated after canonicalization (/tmp and os.tmpdir() may coincide). - expect(new Set(roots).size).toBe(roots.length) + expect(new Set(writable).size).toBe(writable.length) }) }) diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index eff81516f3..7712d91118 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-schedule", "description": "Agent-scoped durable after, at, and fixed-rate reminders over the session event log", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index d90dfc5736..72a226ea9a 100644 --- a/packages/sdk/client/package.json +++ b/packages/sdk/client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-client", "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index 4ddf04d2ea..0faa32b0ed 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-protocol", "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index 42dcdeb477..029ac79a74 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index eff3190c30..d6e597b312 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/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/session-query/session-log-export/README.md -README.md: 283d9e5ca8ede40ddc2f1e57f4c0cd34eecd1018 -README.zh.md: d350a21908bc36c4495e4be8fec0c110b0d36828 +README.md: db26d9c405b3408cc24f27e5947c94ccaa2393e8 +README.zh.md: 4e2eb00de94dd6520af8c1836155a00d187ef99f diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 283d9e5ca8..db26d9c405 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -55,7 +55,7 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u ### What to expect -The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. Each logical log uses the current generation's canonical filename inside the archive (`session.jsonl` for v0, otherwise `session.vN.jsonl`), including beneath each sub-session directory. +The dialog reports three phases: preparing, download started, or failed. Closing the dialog does not cancel an in-flight download, and the dialog does not reopen when that operation later settles. One session admits one active download at a time; repeated gestures share that operation. The export includes the live session's newest events: the host endpoint flushes a live root session before reading, so a slash-triggered ZIP includes the `command/run` and `command/done` pair that started the download; cold persisted sessions need no flush. Each logical log uses the current generation's canonical filename inside the archive (`session.jsonl` for v0, otherwise `session.vN.jsonl`), including beneath each sub-session directory. Images use `media/.`, and generic files use `files///`. Generic-file bytes are read and compressed as bounded chunks, so exporting a large upload does not buffer it in full. ### Failures diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index d350a21908..4e2eb00de9 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -55,7 +55,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` ### 预期行为 -弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后完成时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;冷持久化会话不需要 flush。每份逻辑日志在归档中使用当前 generation 的规范文件名(v0 为 `session.jsonl`,其他版本为 `session.vN.jsonl`),每个子会话目录下也遵循同一规则。 +弹窗报告三个阶段:准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载,该操作随后完成时弹窗也不会重新打开。每个会话同时只允许一项下载,重复操作共用该任务。导出包含实时会话的最新事件:Host 端点在读取前会 flush 活动的根会话,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对;冷持久化会话不需要 flush。每份逻辑日志在归档中使用当前 generation 的规范文件名(v0 为 `session.jsonl`,其他版本为 `session.vN.jsonl`),每个子会话目录下也遵循同一规则。图片使用 `media/.`,通用文件使用 `files///`。通用文件以有界分块读取并压缩,因此导出大文件时不会把它完整缓冲进内存。 ### 失败 diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index f7fc2190ab..ce12787db0 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log export command and shared download dialog", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-log-export/src/archive.ts b/packages/session-query/session-log-export/src/archive.ts index 2cb087c0a7..5c64a2eec2 100644 --- a/packages/session-query/session-log-export/src/archive.ts +++ b/packages/session-query/session-log-export/src/archive.ts @@ -1,18 +1,18 @@ /** * Host-side session-log download: streams one ZIP archive whose files are the - * sessions' logical session logs plus every referenced media object. Each log + * sessions' logical session logs plus every referenced attachment. Each log * is read through a persistence read handle and serialized here as canonical * JSONL — one header line, then one line per validated event — so every * backend (JSONL, SQLite, future) exports identically. The root log uses the * current generation's canonical `session[.vN].jsonl` name; each subagent * descendant uses `subagents//session[.vN].jsonl`; each image referenced by any included log * under `media/.` (content-addressed, so one archive never - * duplicates a shared image). No manifest is written — every file is - * self-describing through its own header line or media type. Before each live - * session's log read, the SessionStore flush barrier makes the current - * in-memory log durable; cold sessions need no barrier. Request abort and - * response-consumer cancellation share one producer signal and terminate the - * active compressor. + * duplicates a shared image); each generic file sits under + * `files///` and streams from the attachment store. No + * manifest is written. Before each live session's log read, the SessionStore + * flush barrier makes the current in-memory log durable; cold sessions need no + * barrier. Request abort and response-consumer cancellation share one producer + * signal and terminate the active compressor. * Compression runs on the host with fflate's streaming Zip API, so the archive * bytes are produced incrementally and the host never holds the whole archive * in one buffer; production waits for consumer pull whenever the response queue @@ -23,7 +23,9 @@ import { Zip, ZipDeflate } from 'fflate' import type { Context } from '@deepseek-ai/cordis' -import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { + AttachmentStore, FileAttachmentRef, ImageAttachmentRef, +} from '@deepseek-ai/dsh-attachment' import type { SessionLineageNode, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import { sessionFormatLogFilename } from '@deepseek-ai/dsh-session-format' @@ -89,10 +91,11 @@ export async function flushLiveSessionLog( signal?.throwIfAborted() } -/** One exported file: a serialized session log or one referenced media object. */ +/** One exported file: a serialized session log or one referenced attachment object. */ export type SessionLogZipEntry = | { readonly path: string; readonly content: string } | { readonly path: string; readonly data: Uint8Array } + | { readonly path: string; readonly chunks: AsyncIterable } /** The current generation's canonical base filename for every exported session log. */ export const SESSION_LOG_FILENAME = sessionFormatLogFilename(SESSION_FORMAT_VERSION) @@ -184,13 +187,26 @@ function mediaEntryPath(ref: ImageAttachmentRef): string { return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}` } +/** Archive path that preserves one stored file reference's digest and name. */ +function fileEntryPath(ref: FileAttachmentRef): string { + const digest = String(ref.attachmentId).replace(/^sha256:/u, '') + const name = ref.name.replace(/[\\/\u0000-\u001f\u007f]/gu, '_') + const safeName = name === '.' || name === '..' || name === '' ? 'file' : name + return `files/${digest.slice(0, 2)}/${digest}/${safeName}` +} + /** - * Collect every image reference inside one content array, descending into + * Collect every attachment reference inside one content array, descending into * nested tool results the way the live attachment route does. * @param content - an event content array (or nested tool-result content). - * @param refs - the dedupe map being filled (keyed by attachment id). + * @param images - image dedupe map keyed by attachment id. + * @param files - file dedupe map keyed by attachment id and stored name. */ -function collectImageRefs(content: unknown, refs: Map): void { +function collectAttachmentRefs( + content: unknown, + images: Map, + files: Map, +): void { if (!Array.isArray(content)) return const pending: unknown[] = [] for (const item of content) pending.push(item) @@ -200,7 +216,11 @@ function collectImageRefs(content: unknown, refs: Map): void { +function collectEventAttachmentRefs( + event: unknown, + images: Map, + files: Map, +): void { const data = (event as { data?: unknown }).data if (typeof data !== 'object' || data === null) return const carrier = data as { @@ -224,29 +249,33 @@ function collectEventImageRefs(event: unknown, refs: Map stream?: Array<{ type?: unknown; chunk?: { type?: unknown; block?: unknown } }> } - collectImageRefs(carrier.content, refs) - if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs) + collectAttachmentRefs(carrier.content, images, files) + if (carrier.message !== undefined) collectAttachmentRefs(carrier.message.content, images, files) if (carrier.inserted !== undefined) { - for (const message of carrier.inserted) collectImageRefs(message.content, refs) + for (const message of carrier.inserted) collectAttachmentRefs(message.content, images, files) } if (carrier.stream !== undefined) { for (const record of carrier.stream) { if (record.type === 'chunk' && record.chunk?.type === 'block-end') { - collectImageRefs([record.chunk.block], refs) + collectAttachmentRefs([record.chunk.block], images, files) } } } } /** - * Collect the distinct media references one canonical Session-log text names. - * Lines that fail to parse cannot reference media and are skipped (the - * log text itself is exported regardless). - * @param content - the canonical Session-log text. - * @returns the dedupe map keyed by attachment id. + * Collect the distinct attachment references one stored artifact text names. + * Lines that fail to parse cannot reference attachments and are skipped (the + * artifact text itself is exported verbatim regardless). + * @param content - the stored artifact text. + * @returns image and file dedupe maps. */ -function imageRefsInArtifact(content: string): Map { - const refs = new Map() +function attachmentRefsInArtifact(content: string): { + readonly images: Map + readonly files: Map +} { + const images = new Map() + const files = new Map() for (const line of content.split('\n')) { if (line === '') continue let event: unknown @@ -255,9 +284,9 @@ function imageRefsInArtifact(content: string): Map { } catch { continue } - collectEventImageRefs(event, refs) + collectEventAttachmentRefs(event, images, files) } - return refs + return { images, files } } /** @@ -286,10 +315,10 @@ export function sessionLogZipFilename(sessionId: string): string { * Yield the export entries in zip order: the preloaded root log first, then * every subagent descendant in lineage order (each flushed when live, read * through a persistence read handle right before it is yielded, and dropped - * after the consumer moves on), then every distinct media object referenced by any of - * the included logs (read and verified from the attachment store, one archive - * entry per attachment id). The host holds at most one descendant's log text - * and one media object at a time beyond the root. + * after the consumer moves on), then every distinct attachment referenced by + * the included logs. Images are read and verified as bounded stored objects; + * generic files remain streamed through the ZIP writer. The host holds at most + * one descendant log, one image, and one file chunk beyond the root. * @param deps - the mounted export services (the caller answered 500 before this runs). * @param rootContent - the already-serialized root log (read by the caller so * the missing-session path can answer cleanly before streaming starts). @@ -306,10 +335,13 @@ export async function* sessionLogZipEntries( signal?: AbortSignal, ): AsyncGenerator { const media = new Map() - const rememberMedia = (content: string): void => { - for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref) + const files = new Map() + const rememberAttachments = (content: string): void => { + const refs = attachmentRefsInArtifact(content) + for (const [id, ref] of refs.images) media.set(id, ref) + for (const [id, ref] of refs.files) files.set(id, ref) } - rememberMedia(rootContent) + rememberAttachments(rootContent) yield { path: SESSION_LOG_FILENAME, content: rootContent } if (includeDescendants) { const seen = new Set([sessionId]) @@ -327,7 +359,7 @@ export async function* sessionLogZipEntries( if (content === undefined) { throw new Error(`subagent "${id}" has no stored log`) } - rememberMedia(content) + rememberAttachments(content) yield { path: `subagents/${safeSessionIdSegment(id)}/${SESSION_LOG_FILENAME}`, content, @@ -345,6 +377,13 @@ export async function* sessionLogZipEntries( signal?.throwIfAborted() yield { path: mediaEntryPath(ref), data: stored.data } } + for (const ref of files.values()) { + signal?.throwIfAborted() + yield { + path: fileEntryPath(ref), + chunks: deps.attachments.readFileStream(ref, signal), + } + } } /** How many code units of Session-log text one zip push carries (bounded encode memory). */ @@ -416,6 +455,25 @@ async function pushBinaryChunks( } while (offset < data.byteLength) } +/** Push one streamed file entry without retaining its complete byte sequence. */ +async function pushStreamChunks( + deflate: ZipDeflate, + chunks: AsyncIterable, + controller: ReadableStreamDefaultController, + capacity: ResponseCapacityGate, + signal: AbortSignal, +): Promise { + for await (const chunk of chunks) { + signal.throwIfAborted() + if (chunk.byteLength === 0) continue + deflate.push(chunk, false) + await capacity.wait(controller, signal) + } + signal.throwIfAborted() + deflate.push(new Uint8Array(), true) + await capacity.wait(controller, signal) +} + /** * Push one artifact's text into a deflate stream in bounded chunks, never * splitting a surrogate pair across a chunk boundary (a lone high surrogate @@ -509,8 +567,10 @@ export function streamSessionLogZip( archive.add(deflate) if ('content' in entry) { await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal) - } else { + } else if ('data' in entry) { await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal) + } else { + await pushStreamChunks(deflate, entry.chunks, controller, capacity, producerSignal) } } archive.end() diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 0ec33bc2e5..abc4932b10 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -58,6 +58,7 @@ interface SessionLogConnection { register(route: { readonly path: string readonly methods: readonly ('GET' | 'HEAD')[] + readonly requestBody: 'buffered' readonly fetch: (request: Request) => Promise }): () => Promise } @@ -84,6 +85,7 @@ export function apply(ctx: Context, config: Config = {}): void { connectionOf(ctx).fetch.register({ path: SESSION_LOG_EXPORT_PATH, methods: ['GET', 'HEAD'], + requestBody: 'buffered', fetch: async (request) => { const response = await sessionLogExportResponse( ctx, diff --git a/packages/session-query/session-log-export/tests/archive.host.spec.ts b/packages/session-query/session-log-export/tests/archive.host.spec.ts index 9f676d02d1..f26a128ccc 100644 --- a/packages/session-query/session-log-export/tests/archive.host.spec.ts +++ b/packages/session-query/session-log-export/tests/archive.host.spec.ts @@ -11,7 +11,7 @@ import { randomBytes } from 'node:crypto' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { unzipSync, strFromU8 } from 'fflate' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' import { SessionPersistenceNotFoundError } from '@deepseek-ai/dsh-session-persistence' @@ -88,6 +88,14 @@ function readHandle(stored: StoredLog): SessionHandle { } as unknown as SessionHandle } +/** A user/message event carrying one generic-file reference. */ +function fileEvent(id: string, name = 'notes.txt', bytes = 5, seq = SessionSeq(1)): SessionEvent { + return { + type: 'user/message', seq, time: 1000, + data: { content: [{ type: 'file', attachment: { attachmentId: id, name, bytes } }] }, + } as unknown as SessionEvent +} + async function buildApi( logs: Record, descendants: SessionLineageNode[] = [], @@ -95,6 +103,7 @@ async function buildApi( query?: boolean persistence?: boolean | 'throw' attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise>) + readFileStream?: (ref: FileAttachmentRef, signal?: AbortSignal) => AsyncIterable sessions?: { get(id: SessionId): { readonly id: SessionId } | undefined flush(session: { readonly id: SessionId }): Promise @@ -150,6 +159,9 @@ async function buildApi( validateImage: async () => {}, saveImage: async () => { throw new Error('export never saves images') }, readImage, + readFileStream: services.readFileStream ?? (async function* () { + throw new Error('fixture has no files') + }), } as never) } if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never) @@ -740,6 +752,51 @@ describe('session.export download endpoint', () => { expect(files['media/img-1.png']).toEqual(storedImage('img-1').data) }) + it('streams generic files under their content-addressed archive paths', async () => { + const digest = 'a'.repeat(64) + const id = `sha256:${digest}` + const fallbackDigest = 'c'.repeat(64) + const fallbackId = `sha256:${fallbackDigest}` + const root = log('session-root', undefined, [ + fileEvent(id, 'notes.txt', 5), + fileEvent(fallbackId, '.', 5, SessionSeq(2)), + ]) + const reads: Array<{ ref: FileAttachmentRef; signal: AbortSignal | undefined }> = [] + const api = await buildApi({ 'session-root': root }, [], { + readFileStream: (ref, signal) => (async function* (): AsyncIterable { + reads.push({ ref, signal }) + yield new Uint8Array() + yield Uint8Array.of(1, 2) + yield Uint8Array.of(3, 4, 5) + })(), + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(files[`files/aa/${digest}/notes.txt`]).toEqual(Uint8Array.of(1, 2, 3, 4, 5)) + expect(files[`files/cc/${fallbackDigest}/file`]).toEqual(Uint8Array.of(1, 2, 3, 4, 5)) + expect(reads).toHaveLength(2) + expect(reads[0]?.ref).toMatchObject({ attachmentId: id, name: 'notes.txt', bytes: 5 }) + expect(reads[0]?.signal).toBeInstanceOf(AbortSignal) + }) + + it('fails the whole export when a referenced file stream fails', async () => { + const digest = 'b'.repeat(64) + const id = `sha256:${digest}` + const root = log('session-root', undefined, [fileEvent(id)]) + const api = await buildApi({ 'session-root': root }, [], { + readFileStream: () => (async function* (): AsyncIterable { + yield Uint8Array.of(1) + throw new Error('file bytes missing') + })(), + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + await expect(response.arrayBuffer()).rejects.toThrow('file bytes missing') + }) + it('collects media referenced from nested tool results', async () => { const nested = { type: 'assistant/message', seq: SessionSeq(2), time: 2000, diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index ec09b12404..dc887ddcda 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 56ddf87330..0d42d6c5bc 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 9887129ec7..841f23c1ca 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-session-query", "description": "Workspace-authorized model-facing session history search, trace, and event read tools", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 5477c761ed..87029993e0 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-checkpoint-policy", "description": "Semantic session durability checkpoints before model requests and tool side effects", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format-catalog/package.json b/packages/session/session-format-catalog/package.json index 10504d3d66..2fff7672ad 100644 --- a/packages/session/session-format-catalog/package.json +++ b/packages/session/session-format-catalog/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-catalog", "description": "Build-static first-party Session format codec and migration catalog", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format-v0-to-v1/package.json b/packages/session/session-format-v0-to-v1/package.json index ad285f4b03..729167146e 100644 --- a/packages/session/session-format-v0-to-v1/package.json +++ b/packages/session/session-format-v0-to-v1/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-v0-to-v1", "description": "Frozen released-v0 Session codec and identity migration to v1", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format-v1-to-v2/package.json b/packages/session/session-format-v1-to-v2/package.json index d7f76ae850..d0a2e61bce 100644 --- a/packages/session/session-format-v1-to-v2/package.json +++ b/packages/session/session-format-v1-to-v2/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-v1-to-v2", "description": "Frozen released-v1 Session codec and assistant-stream migration to v2", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format/package.json b/packages/session/session-format/package.json index 020702c738..6eb20dc1e5 100644 --- a/packages/session/session-format/package.json +++ b/packages/session/session-format/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format", "description": "Pure adjacent whole-artifact Session format migration machinery", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-log-deepseek/package.json b/packages/session/session-log-deepseek/package.json index 2e28955c73..a5b6b72f28 100644 --- a/packages/session/session-log-deepseek/package.json +++ b/packages/session/session-log-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-deepseek", "description": "Incremental lossless session-log request extension for the official DeepSeek LLM API", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index e047e169a4..384ad56eed 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/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/session/session-persistence-jsonl/README.md -README.md: 62735e5f7d8124d5d26af16eb9c21b7c91f850d3 -README.zh.md: b40d00495c61109ec3acf85142e396982c45603f +README.md: 1632b971c1577c7c406fdbd18b732add1c97bfee +README.zh.md: 289e3f3aefff5b98c4053d7682c2fb50f8692c71 diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 62735e5f7d..1632b971c1 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -152,7 +152,7 @@ These limits define when this backend is a poor fit or needs special operational - **The flat-file storage layout does not load** — use a separate root or move pre-release artifacts into the project/session directory layout before loading. - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally; the seam has no deletion API. -- **One live writer per session, in-process only** — the write-handle claim excludes a second writer inside the owning backend instance; another instance or process must not write the same session until that handle closes (the durable cross-process lease is the seam's planned next layer). +- **One live writer per session** — the write-handle claim excludes a second writer inside the owning backend instance, and a kernel lock (non-blocking `flock(2)` on `session.lock`; on Windows a named kernel semaphore derived from that path, with no filesystem footprint) excludes every other instance and process; the lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write, so an unmaterialized session leaves no filesystem footprint. A crashed holder's lock dies with its process, so its session is writable again immediately, while a live-but-wedged holder blocks writers until its process exits (on POSIX, removing the lock file forfeits that exclusion; release itself never removes it). Advisory `flock` is unreliable on some network filesystems (NFSv3), and the Windows semaphore name is per login session. - **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement. diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index b40d00495c..289e3f3aef 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -152,7 +152,7 @@ JSONL 存储不修改实时请求前缀。只有重建历史、当前 envelope - **平铺文件存储布局不加载**——加载前使用独立根,或将预发布产物移入项目/会话目录布局。 - **压缩文件不能直接按行读取**——使用后端加载;或在写入新根前选择 `compression: 'none'`,供外部行读取方使用。 - **不删除会话文件**——日志在 `root` 下累积,直到外部移除;seam 无删除接口。 -- **每会话一个活动写入方,仅限进程内**——写句柄认领只在所属后端实例内排除第二个写入方;在该句柄关闭前,另一实例或进程不得写入同一会话(持久的跨进程租约是该 seam 计划中的下一层)。 +- **每会话一个活动写入方**——写句柄认领在所属后端实例内排除第二个写入方,内核锁(`session.lock` 上的非阻塞 `flock(2)`;Windows 上为由该路径派生的命名内核信号量,零文件系统足迹)排除其他所有实例与进程;锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取,因此未物化的会话不留任何文件系统足迹。崩溃持有者的锁随其进程消亡,会话立即可再写入,而活着但卡死的持有者会阻塞写入方直到其进程退出(POSIX 上删除锁文件即放弃该排他;释放本身从不删除它)。咨询式 `flock` 在部分网络文件系统(NFSv3)上不可靠,Windows 信号量名按登录会话隔离。 - **POSIX 实体化需要硬链接支持**——第一次 append 使用 `link()`,使同 id 竞态失败而不覆盖已提交日志;Windows 使用无替换 write-through rename。 diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index bfcd2fe81c..fad9085813 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, @@ -34,6 +34,7 @@ "dependencies": { "@deepseek-ai/dsh-session-format": "workspace:^", "@deepseek-ai/dsh-session-format-catalog": "workspace:^", + "fs-ext": "2.1.1", "koffi": "^3.1.0", "@deepseek-ai/schemastery": "workspace:^" }, @@ -41,6 +42,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-format-v0-to-v1": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@types/fs-ext": "2.0.3" } } diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 9babc5d855..fa0fa221ca 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -30,6 +30,7 @@ import { type SessionPersistenceRevision as PersistenceRevision, } from '@deepseek-ai/dsh-session-persistence' import { JsonlBackendTracker, JsonlSessionHandle } from './storage.ts' +import { SessionWriteLease } from './lease.ts' import { SESSION_FORMAT_VERSION, SessionId as makeSessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId, SessionHeader, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' import { @@ -228,6 +229,10 @@ class JsonlSessionPersistence extends SessionPersistence { throw new SessionAlreadyExistsError(snapshot.id) } options?.signal?.throwIfAborted() + // No lock yet: before materialization there is no durable artifact for + // another process to contend over, so the handle acquires the lock right + // before its first log bytes publish (ensureLease); an unmaterialized + // session leaves no filesystem footprint at all. this.tracker.registerCreated(snapshot, inheritedEventCount) return this.tracker.adopt(new JsonlSessionHandle(this, snapshot.id, snapshot, 'write', { cursor: 0, materialized: false, inheritedEventCount })) } @@ -258,7 +263,11 @@ class JsonlSessionPersistence extends SessionPersistence { // A pending entry always belongs to an ACTIVE creator handle (close erases // it), so the claim below rejects that case as already owned. this.tracker.claimWrite(id) + let lease: SessionWriteLease | undefined try { + const resolved = await this.findLog(id, options?.signal) + if (resolved === undefined) throw new SessionPersistenceNotFoundError(id) + lease = await this.acquireLease(id, undefined, dirname(resolved.currentPath)) const stored = await this.requireStoredLog(id, options?.signal) return this.tracker.adopt(new JsonlSessionHandle(this, id, stored.meta, 'write', { cursor: stored.events.length, @@ -267,10 +276,25 @@ class JsonlSessionPersistence extends SessionPersistence { recoveredTail: stored.recoveredTail, inheritedEventCount: stored.inheritedEventCount, primed: stored.events, - })) + }, lease)) } catch (error) { + // Free the in-process claim no matter how the kernel-lock release + // fares, and keep the original diagnostic: a release failure joins it + // instead of replacing it. + /* v8 ignore next -- typed backends and fs reject with Error */ + const failure = error instanceof Error ? error : new Error(String(error)) + let releaseFailure: Error | undefined + try { + await lease?.release() + } catch (raw: unknown) { + /* v8 ignore next -- lock releases reject with Error */ + releaseFailure = raw instanceof Error ? raw : new Error(String(raw)) + } this.tracker.releaseClaim(id) - throw error + if (releaseFailure !== undefined) { + throw new AggregateError([failure, releaseFailure], `session "${id}": write open failed and its lock release failed`) + } + throw failure } } @@ -593,6 +617,31 @@ class JsonlSessionPersistence extends SessionPersistence { this.tracker.release(handle, materialized) } + /** + * Acquire the session directory's kernel write lock; the kernel holds it + * until the handle's close releases the descriptor, including on process death. + * @param id - the session the lock guards. + * @param cwd - header cwd used to derive the directory for a fresh session. + * @param dir - the resolved directory of an existing artifact, when known. + * @returns the held lock. + */ + private acquireLease(id: SessionId, cwd: string | undefined, dir = sessionDir(this.root, cwd, id)): Promise { + return SessionWriteLease.acquire(dir, id) + } + + /** + * Acquire the cross-process write lock for a materializing created session, + * called by its handle immediately before the first log bytes publish. + * @param header - the session's stored header (its cwd derives the directory). + * @returns the held lock. + */ + async acquireWriteLease(header: SessionHeader): Promise { + // Refuse an opposite-encoding artifact before the lock's mkdir publishes + // the session directory — the last moment the directory can be absent. + await this.rejectOppositeArtifact(header.cwd, header.id) + return this.acquireLease(header.id, header.cwd) + } + /** Decode complete frames and retain complete JSONL records from a torn final frame. */ private async readZstdPrefix( buffer: Buffer, diff --git a/packages/session/session-persistence-jsonl/src/lease.ts b/packages/session/session-persistence-jsonl/src/lease.ts new file mode 100644 index 0000000000..033f96c5c4 --- /dev/null +++ b/packages/session/session-persistence-jsonl/src/lease.ts @@ -0,0 +1,145 @@ +/** + * Cross-process write-ownership lock for one session's artifact directory, + * held for the whole life of a write handle. The arbiter is the kernel: + * POSIX takes a non-blocking `flock(2)` (through fs-ext) on `session.lock` + * beside the log, and Windows holds a named kernel semaphore derived from + * that path — never a file lock or handle, so readers, searches, and + * directory removal proceed freely while the lock is held. Contention maps + * to `SessionAlreadyOwnedError`; the kernel releases the lock when the + * holder's descriptor or last object handle closes, including on any process + * death, so a crashed holder never blocks a successor. A live but wedged + * holder keeps the lock until its process exits: there is deliberately no + * expiry that could expropriate a stalled writer whose resumed appends would + * tear the log. + * A POSIX lock names an inode, not a path, so after locking the holder + * verifies the locked inode is still the file at the lock path and retries + * otherwise: an unlinked-and-recreated lock file carries a fresh inode, and + * a lock on the orphaned one proves nothing. Removing a live session's lock + * file therefore forfeits exclusion on POSIX (nothing in the harness does + * so); Windows has no lock file at all. Readers never touch the lock. + * The lock is acquired at write-open of an existing artifact and, for a + * created session, only right before its first materializing write — an + * unmaterialized session has no filesystem footprint. Release never removes + * the POSIX lock file: every acquired lock belongs to a materialized or + * materializing session, and the surviving file keeps the stable inode later + * lockers verify against. The browser worker deployment stubs fs-ext to + * immediate success: it is single-process, so the in-process write claim + * already excludes every writer. + * @module @deepseek-ai/dsh-session-persistence-jsonl/lease + */ + +import { mkdir, open, stat } from 'node:fs/promises' +import type { FileHandle } from 'node:fs/promises' +import { join } from 'node:path' +import { flock } from 'fs-ext' +import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { acquireLockHandleWin32, releaseLockHandleWin32 } from './win32.ts' + +/** Base name of the kernel lock file inside a session's directory. */ +export const LEASE_FILENAME = 'session.lock' + +/** The held kernel lock: a POSIX descriptor or a Win32 semaphore handle. */ +type HeldLock = + | { readonly kind: 'posix'; readonly handle: FileHandle } + | { readonly kind: 'win32'; readonly handle: number } + +/** Promise face over fs-ext's callback flock, pinned to its string-flag overload. */ +function flockAsync(fd: number, flags: 'exnb' | 'un'): Promise { + return new Promise((resolve, reject) => { + flock(fd, flags, (error) => { + if (error) reject(error) + else resolve() + }) + }) +} + +/** Whether a flock failure means another descriptor holds the lock. */ +function isLockContention(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code + // flock(2) reports EAGAIN; some libcs spell it EWOULDBLOCK. + return code === 'EAGAIN' || code === 'EWOULDBLOCK' +} + +/** + * One held write lock. Constructed only by {@link SessionWriteLease.acquire}; + * `release` closes the descriptor or handle, which is what releases the lock. + */ +export class SessionWriteLease { + private released = false + + private constructor(private readonly held: HeldLock) {} + + /** + * Acquire the session directory's kernel write lock. + * @param dir - the session's artifact directory (created if absent). + * @param id - the session the lock guards, for error identities. + * @returns the held lock. + * @throws {SessionAlreadyOwnedError} while another holder keeps the lock. + */ + static async acquire(dir: string, id: SessionId): Promise { + const path = join(dir, LEASE_FILENAME) + // Owner-only like materializePosix's directories: the lock may create the + // session directory first, and both creators must agree on the mode. + await mkdir(dir, { recursive: true, mode: 0o700 }) + /* v8 ignore start -- native Windows coverage exercises this platform branch; Linux covers the POSIX peer */ + if (process.platform === 'win32') { + let handle: number + try { + handle = await acquireLockHandleWin32(path) + } catch (error: unknown) { + // Sharing violation: another handle already holds the write exclusion. + if ((error as NodeJS.ErrnoException | null)?.code === 'EBUSY') throw new SessionAlreadyOwnedError(id) + throw error + } + return new SessionWriteLease({ kind: 'win32', handle }) + } + /* v8 ignore stop */ + // Bounded retry: locking an inode a releasing creator just unlinked (or a + // recreated path) re-opens the fresh file; steady state needs one pass. + for (let attempt = 0; attempt < 3; attempt += 1) { + const handle = await open(path, 'w') + try { + try { + await flockAsync(handle.fd, 'exnb') + } catch (error: unknown) { + if (isLockContention(error)) throw new SessionAlreadyOwnedError(id) + throw error + } + const held = await handle.stat({ bigint: true }) + const current = await stat(path, { bigint: true }).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined + throw error + }) + if (current !== undefined && current.ino === held.ino && current.dev === held.dev) { + return new SessionWriteLease({ kind: 'posix', handle }) + } + } catch (error: unknown) { + await handle.close() + throw error + } + // The locked inode is no longer the file at the lock path: start over + // against whatever now stands there. + await handle.close() + } + throw new SessionAlreadyOwnedError(id) + } + + /** + * Release the kernel lock by closing its descriptor or handle. The POSIX + * lock file is never removed: every acquired lock belongs to a + * materialized or materializing session, and keeping the file preserves + * the stable inode later lockers verify against. Idempotent. + */ + async release(): Promise { + if (this.released) return + this.released = true + /* v8 ignore start -- native Windows coverage exercises this platform branch; Linux covers the POSIX peer */ + if (this.held.kind === 'win32') { + await releaseLockHandleWin32(this.held.handle) + return + } + /* v8 ignore stop */ + await this.held.handle.close() + } +} diff --git a/packages/session/session-persistence-jsonl/src/storage.ts b/packages/session/session-persistence-jsonl/src/storage.ts index 936355260f..7cb4132cc9 100644 --- a/packages/session/session-persistence-jsonl/src/storage.ts +++ b/packages/session/session-persistence-jsonl/src/storage.ts @@ -14,8 +14,8 @@ import { errorChain } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import { assertContiguous, - materializeAppendBatch, SessionAlreadyExistsError, + materializeAppendBatch, SessionAlreadyOwnedError, SessionHandleClosedError, SessionPersistenceNotFoundError, @@ -29,6 +29,7 @@ import type { SessionHandleFlushOptions, SessionHandleReadOptions, } from '@deepseek-ai/dsh-session-persistence' +import type { SessionWriteLease } from './lease.ts' /** Maximum intentional wait before a routed live session batch starts writing. */ export const LIVE_WRITE_BATCH_MAX_DELAY_MS = 200 @@ -52,6 +53,8 @@ export interface JsonlHandleStorage { readStoredLog(path: string, expectedId: SessionId, signal?: AbortSignal): Promise<{ events: SessionEvent[] }> /** Whether the id is still a created-but-unmaterialized session here. */ hasPendingSession(id: SessionId): boolean + /** Acquire the session's cross-process write lock in its artifact directory. */ + acquireWriteLease(header: SessionHeader): Promise /** Drop the handle's bookkeeping on close. */ releaseHandle(handle: JsonlSessionHandle, materialized: boolean): void } @@ -95,6 +98,8 @@ export class JsonlSessionHandle implements SessionHandle { readonly header: SessionHeader, readonly access: SessionAccess, private readonly state: StorageHandleState, + /** The cross-process write lock; a create handle acquires it lazily at first materialization. */ + private lease?: SessionWriteLease, ) {} /** Exact fork-inherited prefix length stored with this session's log. */ @@ -166,6 +171,7 @@ export class JsonlSessionHandle implements SessionHandle { options?.signal?.throwIfAborted() if (this.access !== 'write') throw new SessionReadOnlyError(this.id, 'flush') if (this.state.materialized) return // appends are durable on resolution + await this.ensureLease() await this.storage.persistHeader(this.header, this.state.inheritedEventCount) this.state.materialized = true }) @@ -175,7 +181,9 @@ export class JsonlSessionHandle implements SessionHandle { * Release the handle; see the seam contract. Idempotent and uncancellable. * A write handle first drains its routed live buffer through the still-open * storage, so backend teardown loses nothing regardless of which fiber - * unwinds first; a drain failure still releases ownership, then rejects. + * unwinds first; a drain or lock-release failure still frees the in-process + * claim, then rejects — both failures together reject as one + * `AggregateError`. * @returns settlement of the release. */ close(): Promise { @@ -198,10 +206,22 @@ export class JsonlSessionHandle implements SessionHandle { } // After a drain failure the chain may still hold in-flight mutations. await this.chain - this.storage.releaseHandle(this, this.state.materialized) + // Free the in-process claim no matter how the kernel-lock release + // fares: a skipped releaseHandle would wedge the id in this process + // behind a lock the kernel may already have dropped. + const failures: Error[] = [] if (drainFailure !== undefined) { - throw drainFailure instanceof Error ? drainFailure : new Error(errorChain(drainFailure)) + failures.push(drainFailure instanceof Error ? drainFailure : new Error(errorChain(drainFailure))) } + try { + await this.lease?.release() + } catch (releaseFailure: unknown) { + /* v8 ignore next -- lock releases reject with Error */ + failures.push(releaseFailure instanceof Error ? releaseFailure : new Error(errorChain(releaseFailure))) + } + this.storage.releaseHandle(this, this.state.materialized) + if (failures.length > 1) throw new AggregateError(failures, `session "${this.id}": close failed to drain and to release its write lock`) + if (failures[0] !== undefined) throw failures[0] })() } @@ -261,10 +281,11 @@ export class JsonlSessionHandle implements SessionHandle { } } - /** The shared durable-append body: contiguity, torn-tail repair, storage write, state advance. */ + /** The shared durable-append body: contiguity, ownership, torn-tail repair, storage write, state advance. */ private async persistContiguous(batch: readonly SessionEvent[]): Promise { if (this.access !== 'write') throw new SessionReadOnlyError(this.id, 'append') if (batch.length === 0) return + await this.ensureLease() assertContiguous(this.id, batch, this.state.cursor) // Commit any pending torn-tail repair first, clearing each step's state // only once it lands so a failed step retries on the next mutation: @@ -287,6 +308,17 @@ export class JsonlSessionHandle implements SessionHandle { this.observedLength = this.state.cursor } + /** + * Hold the cross-process write lock before this session's first durable + * write. An open write handle holds it from construction; a create handle + * acquires it here — immediately before the first log bytes publish — and + * keeps it through close even when materialization then fails, so a + * materializing session stays exclusively owned across retries. + */ + private async ensureLease(): Promise { + this.lease ??= await this.storage.acquireWriteLease(this.header) + } + /** Serialize one operation onto the chain without the closed-handle refusal (drain-from-close). */ private enqueueChain(op: () => Promise): Promise { const next = this.chain.then(op) @@ -335,7 +367,11 @@ export class JsonlBackendTracker { /** * Claim write ownership and record the created session as pending, making - * it observable to this process before it materializes. + * it observable to this process before it materializes. Before + * materialization this registration is the only guard — session ids do not + * collide across processes, and no durable artifact exists for another + * process to open; the handle takes the cross-process lock at its first + * materializing write. * @param header - the validated detached header. * @param inheritedEventCount - the exact fork-inherited prefix length. * @throws {SessionAlreadyExistsError} when a concurrent create or an open diff --git a/packages/session/session-persistence-jsonl/src/win32.ts b/packages/session/session-persistence-jsonl/src/win32.ts index c3fa852b08..2369f474f2 100644 --- a/packages/session/session-persistence-jsonl/src/win32.ts +++ b/packages/session/session-persistence-jsonl/src/win32.ts @@ -11,14 +11,23 @@ * @module dsh-session-persistence-jsonl/win32 */ +import { createHash } from 'node:crypto' import { mkdtemp, rm, stat } from 'node:fs/promises' import { join, parse, resolve, toNamespacedPath } from 'node:path' type MoveFileExW = (existing: string, replacement: string, flags: number) => number +type CreateSemaphoreW = (security: null, initial: number, maximum: number, name: string) => number +type WaitForSingleObject = (handle: number, milliseconds: number) => number +type ReleaseSemaphore = (handle: number, count: number, previous: null) => number +type CloseHandle = (handle: number) => number type GetLastError = () => number interface Win32Bindings { moveFileExW: MoveFileExW + createSemaphoreW: CreateSemaphoreW + waitForSingleObject: WaitForSingleObject + releaseSemaphore: ReleaseSemaphore + closeHandle: CloseHandle getLastError: GetLastError } @@ -28,10 +37,13 @@ interface Win32ErrnoException extends NodeJS.ErrnoException { } const MOVEFILE_WRITE_THROUGH = 0x00000008 +const WAIT_OBJECT_0 = 0 +const WAIT_TIMEOUT = 0x00000102 const ERROR_FILE_NOT_FOUND = 2 const ERROR_PATH_NOT_FOUND = 3 const ERROR_ACCESS_DENIED = 5 const ERROR_NOT_SAME_DEVICE = 17 +const ERROR_SHARING_VIOLATION = 32 const ERROR_FILE_EXISTS = 80 const ERROR_INVALID_NAME = 123 const ERROR_ALREADY_EXISTS = 183 @@ -45,6 +57,10 @@ async function win32(): Promise { const kernel32 = koffi.load('kernel32.dll') bindings = { moveFileExW: kernel32.func('__stdcall', 'MoveFileExW', 'int', ['str16', 'str16', 'uint']) as MoveFileExW, + createSemaphoreW: kernel32.func('__stdcall', 'CreateSemaphoreW', 'intptr', ['void*', 'int', 'int', 'str16']) as CreateSemaphoreW, + waitForSingleObject: kernel32.func('__stdcall', 'WaitForSingleObject', 'uint', ['intptr', 'uint']) as WaitForSingleObject, + releaseSemaphore: kernel32.func('__stdcall', 'ReleaseSemaphore', 'int', ['intptr', 'int', 'void*']) as ReleaseSemaphore, + closeHandle: kernel32.func('__stdcall', 'CloseHandle', 'int', ['intptr']) as CloseHandle, getLastError: kernel32.func('__stdcall', 'GetLastError', 'uint', []) as GetLastError, } return bindings @@ -59,6 +75,8 @@ function errnoCode(win32Code: number): string { return 'EACCES' case ERROR_NOT_SAME_DEVICE: return 'EXDEV' + case ERROR_SHARING_VIOLATION: + return 'EBUSY' case ERROR_FILE_EXISTS: case ERROR_ALREADY_EXISTS: return 'EEXIST' @@ -119,6 +137,41 @@ export async function publishNewFileWin32(existing: string, replacement: string) if (ok === 0) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement) } +/** + * Acquire the session write lock as a named kernel semaphore (count 1) whose + * name is derived from the canonical lock path. A kernel object never touches + * the filesystem, so readers, searches, and directory removal proceed freely + * while the lock is held; a second acquirer's zero-timeout wait times out + * (`EBUSY`); and when the last handle closes — including on any process + * death — the object is destroyed, so a successor's create starts fresh. + * @param path - the lock file path the name is derived from (case-folded: + * Windows paths are case-insensitive). + * @returns the open semaphore handle, released via {@link releaseLockHandleWin32}. + */ +export async function acquireLockHandleWin32(path: string): Promise { + const api = await win32() + const name = `Local\\dsh-session-lock-${createHash('sha256').update(resolve(path).toLowerCase()).digest('hex')}` + const handle = api.createSemaphoreW(null, 1, 1, name) + if (handle === 0) throw win32Error('CreateSemaphoreW', api.getLastError(), path, name) + const wait = api.waitForSingleObject(handle, 0) + if (wait === WAIT_OBJECT_0) return handle + api.closeHandle(handle) + if (wait === WAIT_TIMEOUT) throw win32Error('WaitForSingleObject', ERROR_SHARING_VIOLATION, path, name) + throw win32Error('WaitForSingleObject', api.getLastError(), path, name) +} + +/** + * Release a lock from {@link acquireLockHandleWin32}: restore the semaphore + * count and close the handle (the object dies with its last handle). + * @param handle - the open semaphore handle. + */ +export async function releaseLockHandleWin32(handle: number): Promise { + const api = await win32() + const released = api.releaseSemaphore(handle, 1, null) + const closed = api.closeHandle(handle) + if (released === 0 || closed === 0) throw win32Error('ReleaseSemaphore', api.getLastError(), `handle:${handle}`, `handle:${handle}`) +} + /** * Create `target` and its missing ancestors with durable Windows namespace * publication. Each missing directory is first created as a random staging diff --git a/packages/session/session-persistence-jsonl/tests/fixtures/lease-holder.mjs b/packages/session/session-persistence-jsonl/tests/fixtures/lease-holder.mjs new file mode 100644 index 0000000000..613f06fa8e --- /dev/null +++ b/packages/session/session-persistence-jsonl/tests/fixtures/lease-holder.mjs @@ -0,0 +1,28 @@ +/** + * Two-process lock e2e holder: creates one session over the given root, + * materializes two events, prints `holding`, and keeps its kernel write lock + * until the parent SIGKILLs this process (a crash: release never runs). + * Runs the built package under plain Node. + */ + +import { Context } from '@deepseek-ai/cordis' +import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' + +const [root, sessionId] = process.argv.slice(2) +const ctx = new Context() +await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) +const handle = await ctx.sessionPersistence.create({ + version: SESSION_FORMAT_VERSION, + id: sessionId, + createdAt: 1000, + cwd: '/work', + isSeeded: false, +}) +await handle.append([ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, +]) +process.stdout.write('holding\n') +// Keep the descriptor (and with it the kernel lock) until killed; never close. +setInterval(() => {}, 1000) diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 24f4e82d12..581a964c8e 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -2240,15 +2240,14 @@ describe('JsonlSessionPersistence: edge cases', () => { await expect(backend.exists(join(blocker, 'child.jsonl'))).rejects.toThrow(/ENOTDIR/) }) - it('materialization surfaces a project-directory storage fault', async () => { + it('a project-directory storage fault surfaces at the first materializing write', async () => { const cwd = '/x' await writeFile(projectDir(root, cwd), 'x') // project path is now a file + // Create touches no storage; the lock acquisition ahead of the first + // materializing append walks into the fault. const handle = await ctx.sessionPersistence.create(meta('exists-fault', cwd)) - try { - await expectCode(handle.append(oneTurnLog()), ['EEXIST', 'ENOTDIR']) - } finally { - await handle.close() - } + await expectCode(handle.append([{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }]), ['EEXIST', 'ENOTDIR']) + await handle.close() }) it('backend teardown closes handles left open and fails later operations loudly', async () => { diff --git a/packages/session/session-persistence-jsonl/tests/lease.spec.ts b/packages/session/session-persistence-jsonl/tests/lease.spec.ts new file mode 100644 index 0000000000..b792b937f2 --- /dev/null +++ b/packages/session/session-persistence-jsonl/tests/lease.spec.ts @@ -0,0 +1,437 @@ +/** + * Cross-process write-lock behavior, exercised through fresh backend + * instances over one shared root: kernel `flock` locks conflict between two + * descriptors even inside one process, so a second instance behaves exactly + * like a second process. Exclusion while a holder is live, immediate + * admission after close, lock-file residue rules, and the inode verification + * that defeats an unlinked-and-recreated lock path. Filesystem and flock + * refusals are injected through the module mocks below: POSIX modes cannot + * express them on Windows, and an injected error is the only deterministic + * cross-platform refusal. Real cross-process exclusion and crash release are + * pinned by lease.two-process.e2e.ts. + */ + +import { existsSync } from 'node:fs' +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import type { SessionHeader } from '@deepseek-ai/dsh-session' +import { + SessionAlreadyExistsError, + SessionAlreadyOwnedError, + SessionPersistenceNotFoundError, +} from '@deepseek-ai/dsh-session-persistence' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import JsonlSessionPersistence from '../src/index.ts' +import { LEASE_FILENAME, SessionWriteLease } from '../src/lease.ts' +import type { JsonlSessionHandle } from '../src/storage.ts' +import { sessionDir } from '../src/format.ts' + +// The lock's base name, duplicated for the hoisted mock factories: they run +// while `../src/lease.ts` is still evaluating, before LEASE_FILENAME exists. +const LOCK = vi.hoisted(() => 'session.lock') + +const refuse = vi.hoisted(() => ({ + /** Next open of a lock file fails EACCES (read-only directory). */ + lockOpen: false, + /** Next flock call fails EACCES (a non-contention kernel refusal). */ + flock: false, + /** Next flock call fails EWOULDBLOCK (the Windows LockFileEx contention code). */ + flockBusy: false, + /** Next stat of a lock file fails EACCES (unreadable path). */ + lockStat: false, + /** For N further lock-path stats: unlink and recreate the file first, so the locked inode is orphaned. */ + swapLockOnStat: 0, + /** Next lock-path stat: unlink the file first, so the verify read finds nothing. */ + dropLockOnStat: false, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + const denied = (syscall: string): never => { + throw Object.assign(new Error(`EACCES: injected ${syscall} refusal`), { code: 'EACCES' }) + } + return { + ...actual, + open: (async (path: unknown, ...rest: never[]) => { + if (refuse.lockOpen && String(path).endsWith(LOCK)) { + refuse.lockOpen = false + denied('open') + } + return (actual.open as (path: unknown, ...args: never[]) => Promise)(path, ...rest) + }) as typeof actual.open, + stat: (async (path: unknown, ...rest: never[]) => { + const at = String(path) + if (at.endsWith(LOCK)) { + if (refuse.lockStat) { + refuse.lockStat = false + denied('stat') + } + if (refuse.dropLockOnStat) { + refuse.dropLockOnStat = false + await actual.unlink(at) + } else if (refuse.swapLockOnStat > 0) { + refuse.swapLockOnStat -= 1 + await actual.unlink(at) + await actual.writeFile(at, '') + } + } + return (actual.stat as (path: unknown, ...args: never[]) => Promise)(path, ...rest) + }) as typeof actual.stat, + } +}) + +vi.mock('fs-ext', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + flock: ((fd: number, flags: never, callback: (error: Error | null) => void) => { + if (refuse.flock) { + refuse.flock = false + callback(Object.assign(new Error('EACCES: injected flock refusal'), { code: 'EACCES' })) + return + } + if (refuse.flockBusy) { + refuse.flockBusy = false + callback(Object.assign(new Error('EWOULDBLOCK: injected contention'), { code: 'EWOULDBLOCK' })) + return + } + (actual.flock as (fd: number, flags: never, callback: (error: Error | null) => void) => void)(fd, flags, callback) + }) as typeof actual.flock, + } +}) + +const dirs: string[] = [] +const contexts: Context[] = [] + +afterEach(async () => { + refuse.lockOpen = false + refuse.flock = false + refuse.flockBusy = false + refuse.lockStat = false + refuse.swapLockOnStat = 0 + refuse.dropLockOnStat = false + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }) +}) + +function meta(id: string, cwd = '/work'): SessionHeader { + return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt: 1_000, cwd, isSeeded: false } +} + +async function freshRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-jsonl-lease-')) + dirs.push(root) + return root +} + +async function mount(root: string): Promise { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) + return ctx.sessionPersistence +} + +function lockPath(root: string, id: string, cwd = '/work'): string { + return join(sessionDir(root, cwd, SessionId(id)), LEASE_FILENAME) +} + +/** + * Make the next lock release do its real work, then report failure — as a + * close(2) that freed the descriptor but returned EIO would. + */ +function failReleaseOnce(): void { + const spy = vi.spyOn(SessionWriteLease.prototype, 'release') + spy.mockImplementationOnce(async function (this: SessionWriteLease) { + spy.mockRestore() + await this.release() + throw Object.assign(new Error('EIO: injected release failure'), { code: 'EIO' }) + }) +} + +const EVENTS = [ + { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, +] as const + +describe('cross-process write lock', () => { + it('excludes a second instance while the holder is live, and admits it after close', async () => { + const root = await freshRoot() + const first = await mount(root) + const second = await mount(root) + const holder = await first.create(meta('excluded')) + await holder.append([...EVENTS]) + + // Another instance over the same root cannot create or write-open the id: + // the materialized duplicate is an existence fact, the write open an + // ownership one. + await expect(second.create(meta('excluded'))).rejects.toBeInstanceOf(SessionAlreadyExistsError) + await expect(second.open(SessionId('excluded'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + // Unmaterialized creates hold no lock and leave no artifact, so a rival + // instance's create succeeds; the collision surfaces at the loser's first + // materializing write, where the winner already holds the lock. + const pendingWinner = await first.create(meta('excluded-pending')) + const pendingLoser = await second.create(meta('excluded-pending')) + await pendingWinner.append([...EVENTS]) + await expect(pendingLoser.append([...EVENTS])).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + await pendingLoser.close() + await pendingWinner.close() + // Reads never touch the lock. + const reader = await second.open(SessionId('excluded'), 'read') + expect((await reader.read()).map(event => event.seq)).toEqual([0, 1]) + await reader.close() + + await holder.close() + // POSIX keeps the materialized session's lock file (Windows locks a kernel + // object with no filesystem footprint); the kernel lock itself is gone. + if (process.platform !== 'win32') expect(existsSync(lockPath(root, 'excluded'))).toBe(true) + const reopened = await second.open(SessionId('excluded'), 'write') + await reopened.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }]) + await reopened.close() + }) + + it.skipIf(process.platform === 'win32')('removing the lock file forfeits a wedged holder: a fresh inode admits a successor', async () => { + const root = await freshRoot() + const first = await mount(root) + const second = await mount(root) + const wedged = await first.create(meta('wedged')) + await wedged.append([...EVENTS]) + + // The documented escape hatch for a live-but-stuck holder: deleting the + // lock file orphans the held inode, and a successor locks the fresh one. + await rm(lockPath(root, 'wedged')) + const successor = await second.open(SessionId('wedged'), 'write') + await successor.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }]) + await successor.close() + await wedged.close() + }) + + it('write-opening an absent session leaves no lock residue', async () => { + const root = await freshRoot() + const backend = await mount(root) + await expect(backend.open(SessionId('absent'), 'write')).rejects.toBeInstanceOf(SessionPersistenceNotFoundError) + expect(existsSync(join(root, LEASE_FILENAME))).toBe(false) + }) + + it('write-opening an absent id under an existing project directory reports not-found', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('present-sibling')) + await writer.append([...EVENTS]) + await writer.close() + // The project directory exists but the id's session directory does not: + // the generation scan reports absence rather than misreading a sibling. + await expect(backend.open(SessionId('absent-sibling'), 'write')).rejects.toBeInstanceOf(SessionPersistenceNotFoundError) + }) + + it('a never-materialized create leaves no filesystem footprint at all', async () => { + const root = await freshRoot() + const backend = await mount(root) + const handle = await backend.create(meta('erased')) + // The lock is taken only at the first materializing write, so an + // unmaterialized session creates neither its directory nor a lock file. + expect(existsSync(join(lockPath(root, 'erased'), '..'))).toBe(false) + await handle.close() + expect(existsSync(join(lockPath(root, 'erased'), '..'))).toBe(false) + await expect(backend.stat(SessionId('erased'))).resolves.toBeUndefined() + }) + + it('materialization publishes the lock before the first log bytes and keeps it on the handle', async () => { + const root = await freshRoot() + const first = await mount(root) + const second = await mount(root) + const creator = await first.create(meta('lazy-lock')) + await creator.append([...EVENTS]) + // The materializing append acquired and retained the lock. + if (process.platform !== 'win32') expect(existsSync(lockPath(root, 'lazy-lock'))).toBe(true) + await expect(second.open(SessionId('lazy-lock'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + // A later append reuses the held lock rather than re-acquiring. + await creator.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }]) + await creator.close() + const reopened = await second.open(SessionId('lazy-lock'), 'write') + await reopened.close() + }) + + it('an explicitly flushed empty session takes the lock with its header', async () => { + const root = await freshRoot() + const first = await mount(root) + const second = await mount(root) + const creator = await first.create(meta('flush-lock')) + await creator.flush() + await expect(second.open(SessionId('flush-lock'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + await creator.close() + }) + + it.skipIf(process.platform === 'win32')('surfaces a filesystem refusal opening the lock file', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('open-blocked')) + await writer.append([...EVENTS]) + await writer.close() + + refuse.lockOpen = true + await expect(backend.open(SessionId('open-blocked'), 'write')).rejects.toThrow(/EACCES/) + }) + + it.skipIf(process.platform === 'win32')('surfaces a non-contention flock failure', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('flock-blocked')) + await writer.append([...EVENTS]) + await writer.close() + + refuse.flock = true + await expect(backend.open(SessionId('flock-blocked'), 'write')).rejects.toThrow(/EACCES/) + }) + + it.skipIf(process.platform === 'win32')('maps the EWOULDBLOCK contention spelling to already-owned', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('win-contended')) + await writer.append([...EVENTS]) + await writer.close() + + // Some libcs spell flock(2) contention EWOULDBLOCK rather than EAGAIN. + refuse.flockBusy = true + await expect(backend.open(SessionId('win-contended'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + }) + + it.skipIf(process.platform === 'win32')('surfaces a lock-path stat refusal from the inode verification', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('stat-blocked')) + await writer.append([...EVENTS]) + await writer.close() + + refuse.lockStat = true + await expect(backend.open(SessionId('stat-blocked'), 'write')).rejects.toThrow(/EACCES/) + }) + + it.skipIf(process.platform === 'win32')('retries when the locked inode is no longer the lock path, and wins on a stable pass', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('churned')) + await writer.append([...EVENTS]) + await writer.close() + + // One churn (unlink+recreate under the verify stat) orphans the first + // locked inode; the retry locks the fresh file and verifies clean. + refuse.swapLockOnStat = 1 + const reopened = await backend.open(SessionId('churned'), 'write') + await reopened.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }]) + await reopened.close() + }) + + it.skipIf(process.platform === 'win32')('retries when the lock path vanishes under the verify read', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('vanished')) + await writer.append([...EVENTS]) + await writer.close() + + refuse.dropLockOnStat = true + const reopened = await backend.open(SessionId('vanished'), 'write') + await reopened.close() + }) + + it.skipIf(process.platform === 'win32')('gives up as already-owned when the lock path never stabilizes', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('unstable')) + await writer.append([...EVENTS]) + await writer.close() + + // Churn on every attempt: the bounded retry refuses rather than spinning. + refuse.swapLockOnStat = 3 + await expect(backend.open(SessionId('unstable'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + }) + + it('a failing lock release still frees the in-process claim on close', async () => { + const root = await freshRoot() + const backend = await mount(root) + const holder = await backend.create(meta('release-fails')) + await holder.append([...EVENTS]) + + failReleaseOnce() + await expect(holder.close()).rejects.toThrow(/injected release failure/) + // The claim is freed despite the failed release: the id is not wedged. + const reopened = await backend.open(SessionId('release-fails'), 'write') + await reopened.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }]) + await reopened.close() + }) + + it('a write-open failure with a failing release aggregates both and frees the claim', async () => { + const root = await freshRoot() + const backend = await mount(root) + const writer = await backend.create(meta('open-and-release-fail')) + await writer.append([...EVENTS]) + await writer.close() + // Corrupt the stored header line so the open fails after the lock is + // acquired (a garbled tail would be recovered as torn, not refused). + const dir = join(lockPath(root, 'open-and-release-fail'), '..') + const log = (await readdir(dir)).find(name => name.endsWith('.jsonl')) + const stored = await readFile(join(dir, String(log)), 'utf8') + await writeFile(join(dir, String(log)), `#${stored.slice(1)}`) + + failReleaseOnce() + const outcome = await backend.open(SessionId('open-and-release-fail'), 'write').then(() => undefined, (error: unknown) => error) + expect(outcome).toBeInstanceOf(AggregateError) + const errors = (outcome as AggregateError).errors as Error[] + expect(errors).toHaveLength(2) + expect(String(errors[0])).toMatch(/corrupt/i) + expect(String(errors[1])).toMatch(/injected release failure/) + // The original diagnostic survives, and the claim is freed: the next + // attempt reports the corruption again rather than a phantom owner. + await expect(backend.open(SessionId('open-and-release-fail'), 'write')).rejects.toThrow(/corrupt/i) + }) + + it('a drain failure and a release failure reject close as one AggregateError', async () => { + const root = await freshRoot() + const backend = await mount(root) + const holder = await backend.create(meta('drain-and-release-fail')) as unknown as JsonlSessionHandle + await holder.append([...EVENTS]) + + vi.spyOn(backend as unknown as { persistBatch: () => Promise }, 'persistBatch') + .mockRejectedValueOnce(new Error('injected drain refusal')) + holder.enqueueLive({ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }, () => {}) + failReleaseOnce() + const outcome = await holder.close().then(() => undefined, (error: unknown) => error) + expect(outcome).toBeInstanceOf(AggregateError) + expect((outcome as AggregateError).errors.map(String).join('\n')).toMatch(/drain refusal[\s\S]*release failure/) + // Both failures reported, and the id is still not wedged. + const reopened = await backend.open(SessionId('drain-and-release-fail'), 'write') + await reopened.close() + }) + + it.skipIf(process.platform === 'win32')('release is idempotent and never removes the lock file', async () => { + const root = await freshRoot() + const dir = join(root, 'solo') + const lease = await SessionWriteLease.acquire(dir, SessionId('solo')) + await lease.release() + await lease.release() + // The file survives every release, keeping the stable inode later + // lockers verify against; the kernel lock died with the descriptor. + expect(existsSync(join(dir, LOCK))).toBe(true) + const successor = await SessionWriteLease.acquire(dir, SessionId('solo')) + await successor.release() + expect(existsSync(join(dir, LOCK))).toBe(true) + }) + + + it('keeps distinct sessions independently lockable', async () => { + const root = await freshRoot() + const backend = await mount(root) + const a = await backend.create(meta('indep-a')) + const b = await backend.create(meta('indep-b')) + await a.append([...EVENTS]) + await b.append([...EVENTS]) + if (process.platform !== 'win32') { + expect((await readdir(join(lockPath(root, 'indep-a'), '..'))).filter(name => name === LOCK)).toHaveLength(1) + } + await a.close() + await b.close() + }) +}) diff --git a/packages/session/session-persistence-jsonl/tests/lease.two-process.e2e.ts b/packages/session/session-persistence-jsonl/tests/lease.two-process.e2e.ts new file mode 100644 index 0000000000..9b6bfe8f68 --- /dev/null +++ b/packages/session/session-persistence-jsonl/tests/lease.two-process.e2e.ts @@ -0,0 +1,69 @@ +/** + * Real two-process lock contention over one shared root: a child Node + * process (running the built package under plain Node) creates a session and + * holds its kernel write lock; this process is excluded while the child + * lives, and acquires immediately after a SIGKILL — the kernel releases the + * lock with the dead process's descriptors, no waiting period. Keyless. + */ + +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' + +const SESSION = 'two-process-lease' + +const dirs: string[] = [] +const contexts: Context[] = [] + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }) +}) + +const HOLDER = fileURLToPath(new URL('./fixtures/lease-holder.mjs', import.meta.url)) + +describe('two-process write lock (built lib)', () => { + it('excludes a live holder process and takes over immediately after its crash', { timeout: 30_000 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-lease-2proc-')) + dirs.push(root) + + const holder = spawn(process.execPath, [HOLDER, root, SESSION], { + stdio: ['ignore', 'pipe', 'inherit'], + }) + const exited = new Promise((resolve) => { holder.once('exit', () => { resolve() }) }) + try { + await once(holder.stdout, 'data') // 'holding' + + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) + const mine = ctx.sessionPersistence + + // Excluded while the other process's descriptor holds the kernel lock. + await expect(mine.open(SessionId(SESSION), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError) + // Reads are unaffected across processes. + const reader = await mine.open(SessionId(SESSION), 'read') + expect((await reader.read()).map(event => event.seq)).toEqual([0, 1]) + await reader.close() + + // Crash the holder: no release runs, but the kernel drops the lock with + // the process, so takeover succeeds without any waiting period. + holder.kill('SIGKILL') + await exited + const taken = await mine.open(SessionId(SESSION), 'write') + await taken.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }]) + expect((await taken.read()).map(event => event.seq)).toEqual([0, 1, 2]) + await taken.close() + } finally { + if (holder.exitCode === null) holder.kill('SIGKILL') + } + }) +}) diff --git a/packages/session/session-persistence-jsonl/tests/win32.spec.ts b/packages/session/session-persistence-jsonl/tests/win32.spec.ts index 3b6cfc4f78..aa78cd6d49 100644 --- a/packages/session/session-persistence-jsonl/tests/win32.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/win32.spec.ts @@ -208,3 +208,81 @@ describe('Windows durable namespace helpers', () => { await expect(ensureDurableDirectoryWin32(join(blocked, 'child'))).rejects.toMatchObject({ code: 'ENOTDIR' }) }) }) + +async function importWithLock(bindings: { + createSemaphoreW?: (name: string, initial: number, maximum: number) => number + waitResult?: number + releaseSemaphore?: (handle: number) => number + closeHandle?: (handle: number) => number + lastError?: number +}): Promise { + vi.resetModules() + vi.doMock('koffi', () => ({ + default: { + load: () => ({ + func: (_convention: string, name: string) => { + if (name === 'CreateSemaphoreW') { + return (_security: null, initial: number, maximum: number, semName: string) => + (bindings.createSemaphoreW ?? (() => 7))(semName, initial, maximum) + } + if (name === 'WaitForSingleObject') return () => bindings.waitResult ?? 0 + if (name === 'ReleaseSemaphore') return bindings.releaseSemaphore ?? (() => 1) + if (name === 'CloseHandle') return bindings.closeHandle ?? (() => 1) + if (name === 'MoveFileExW') return () => 1 + return () => bindings.lastError ?? 0 // GetLastError + }, + }), + }, + })) + return import('../src/win32.ts') +} + +describe('Windows write-lock semaphore', () => { + it('acquires a path-derived named semaphore with a zero-timeout wait', async () => { + const created: Array<{ name: string; initial: number; maximum: number }> = [] + const { acquireLockHandleWin32 } = await importWithLock({ + createSemaphoreW: (name, initial, maximum) => { + created.push({ name, initial, maximum }) + return 7 + }, + }) + await expect(acquireLockHandleWin32('C:\\s\\session.lock')).resolves.toBe(7) + expect(created).toHaveLength(1) + // Count-1 semaphore in the login-session namespace, named by path hash: + // no filesystem footprint, and case-insensitive like Windows paths. + expect(created[0]).toMatchObject({ initial: 1, maximum: 1 }) + expect(created[0]?.name).toMatch(/^Local\\dsh-session-lock-[0-9a-f]{64}$/) + const upper = await importWithLock({ createSemaphoreW: (name) => { created.push({ name, initial: 1, maximum: 1 }); return 7 } }) + await upper.acquireLockHandleWin32('C:\\S\\SESSION.LOCK') + expect(created[1]?.name).toBe(created[0]?.name) + }) + + it('maps a held semaphore (wait timeout) to EBUSY and closes the probe handle', async () => { + const closed: number[] = [] + const { acquireLockHandleWin32 } = await importWithLock({ + waitResult: 0x102, + closeHandle: (handle) => { closed.push(handle); return 1 }, + }) + await expect(acquireLockHandleWin32('C:\\s\\session.lock')).rejects.toMatchObject({ code: 'EBUSY' }) + expect(closed).toEqual([7]) + }) + + it('surfaces create and wait failures with Win32 codes', async () => { + const createFailed = await importWithLock({ createSemaphoreW: () => 0, lastError: 5 }) + await expect(createFailed.acquireLockHandleWin32('C:\\s\\session.lock')).rejects.toMatchObject({ code: 'EACCES', win32Code: 5 }) + const waitFailed = await importWithLock({ waitResult: 0xffffffff, lastError: 5 }) + await expect(waitFailed.acquireLockHandleWin32('C:\\s\\session.lock')).rejects.toMatchObject({ code: 'EACCES', win32Code: 5 }) + }) + + it('releases by restoring the count and closing, surfacing a failed release', async () => { + const order: string[] = [] + const working = await importWithLock({ + releaseSemaphore: (handle) => { order.push(`release:${handle}`); return 1 }, + closeHandle: (handle) => { order.push(`close:${handle}`); return 1 }, + }) + await working.releaseLockHandleWin32(7) + expect(order).toEqual(['release:7', 'close:7']) + const failing = await importWithLock({ releaseSemaphore: () => 0, lastError: 5 }) + await expect(failing.releaseLockHandleWin32(9)).rejects.toMatchObject({ code: 'EACCES', win32Code: 5 }) + }) +}) diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 8b7221239c..e97be74057 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence/tests/live-write-contract.ts b/packages/session/session-persistence/tests/live-write-contract.ts index c0f57ba9fa..865f0c23e6 100644 --- a/packages/session/session-persistence/tests/live-write-contract.ts +++ b/packages/session/session-persistence/tests/live-write-contract.ts @@ -233,6 +233,10 @@ export function runLiveWritePathContract( const handle = await ctx.sessionPersistence.create(session.header) const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const host = ctx.sessionPersistence as unknown as { persistBatch: (...args: unknown[]) => Promise } + // Materialize under real timers first: the write lock is acquired ahead + // of the first materializing write, and that real I/O must not sit + // inside the fake-timer window below. + await handle.flush() const real = host.persistBatch.bind(host) const persist = vi.spyOn(host, 'persistBatch').mockRejectedValue(new Error('first drain refused')) diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index c359e5bd60..92f227e4a3 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection-cache", "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index 190993a070..f18f871e53 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection", "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json index 9ca44c4106..96fb41d82b 100644 --- a/packages/session/session-stats/package.json +++ b/packages/session/session-stats/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-stats", "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 1d240150bc..2884f2c493 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry-otel", "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index 689cb2af98..ccf76b600d 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry", "description": "SessionTelemetryBackend seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json index 307ec9de3a..2bf029b651 100644 --- a/packages/session/session-title-all-prompts-llm/package.json +++ b/packages/session/session-title-all-prompts-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-all-prompts-llm", "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index a26e57f934..ab8b8f5356 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-first-prompt-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index bb5c8a04f8..a9e43cce45 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index 52f4b0d805..b31538b5af 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title", "description": "Log-backed session title service and provider registry for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-turn-outline/package.json b/packages/session/session-turn-outline/package.json index a1df19f1a0..a7aead8369 100644 --- a/packages/session/session-turn-outline/package.json +++ b/packages/session/session-turn-outline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-turn-outline", "description": "Whole-log turn outline projection (turnOutline) for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings-file/package.json b/packages/settings/settings-file/package.json index 9a7d0a1d6e..69052c474b 100644 --- a/packages/settings/settings-file/package.json +++ b/packages/settings/settings-file/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings-file", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 00da4957cf..903ce3a5ef 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index b76178d4ba..841bafdf2d 100644 --- a/packages/shell/bash-local/package.json +++ b/packages/shell/bash-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-local/tests/executor.spec.ts b/packages/shell/bash-local/tests/executor.spec.ts index c081ad7078..0f5b7992f3 100644 --- a/packages/shell/bash-local/tests/executor.spec.ts +++ b/packages/shell/bash-local/tests/executor.spec.ts @@ -1,7 +1,7 @@ -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' @@ -10,6 +10,10 @@ import type { ShellProcess } from '@deepseek-ai/dsh-shell' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) +afterAll(() => { + rmSync(spillDir, { recursive: true, force: true }) +}) + async function setup(config: ConstructorParameters[1] = {}) { const ctx = new Context() await ctx.plugin(LocalSubprocessRuntime) diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index 15c77884c6..6d543e6f41 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts index 4029cc4f40..21d2dfceaa 100644 --- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts @@ -8,7 +8,7 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -23,6 +23,10 @@ import type { Config } from '@deepseek-ai/dsh-bash-sandbox' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-sandbox-spec-')) +afterAll(() => { + rmSync(spillDir, { recursive: true, force: true }) +}) + /** One recorded provider call: the argv handed over and the policy it rode with. */ interface ConfineCall { argv: string[] @@ -518,12 +522,17 @@ describe('result facts', () => { it('reports a real permission failure as a sandbox denial with the mode it ran under', async () => { const { bash } = await setup() - const lockedDir = join(mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')), 'locked') - mkdirSync(lockedDir) - chmodSync(lockedDir, 0o555) - const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` })) - expect(result.exitCode).not.toBe(0) - expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + const deniedRoot = mkdtempSync(join(tmpdir(), 'dsh-sandbox-denied-')) + try { + const lockedDir = join(deniedRoot, 'locked') + mkdirSync(lockedDir) + chmodSync(lockedDir, 0o555) + const result = await bash.run(bash.resolve({ command: `echo x > ${lockedDir}/f` })) + expect(result.exitCode).not.toBe(0) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + } finally { + rmSync(deniedRoot, { recursive: true, force: true }) + } }) it('carries the provider\'s partial-enforcement fact through unchanged', async () => { diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index a1ae767fd1..a3f1fef031 100644 --- a/packages/shell/pwsh-local/package.json +++ b/packages/shell/pwsh-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-local/tests/executor.spec.ts b/packages/shell/pwsh-local/tests/executor.spec.ts index 53047a62c8..319c588ac5 100644 --- a/packages/shell/pwsh-local/tests/executor.spec.ts +++ b/packages/shell/pwsh-local/tests/executor.spec.ts @@ -9,11 +9,11 @@ * writes CRLF on Windows, so exact text assertions normalize line endings. */ -import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' -import { describe, expect, it } from 'vitest' +import { afterAll, afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' @@ -24,6 +24,16 @@ import type { ShellProcess } from '@deepseek-ai/dsh-shell' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-exec-spec-')) +afterAll(() => { + rmSync(spillDir, { recursive: true, force: true }) +}) + +/** Per-test temp dirs, removed after each test. */ +const tempDirs: string[] = [] +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + // The probe follows the executor's own resolution (Program Files installs on // Windows are found even when bare `pwsh` is not on PATH). const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 @@ -114,6 +124,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => it('returns the first EXISTING win32 candidate, else pwsh', () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-')) + tempDirs.push(dir) const store = join(dir, 'store') mkdirSync(store, { recursive: true }) writeFileSync(join(store, 'pwsh.exe'), '') @@ -131,6 +142,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => // Store app execution aliases stat as EACCES but lstat as a link; a // dangling symlink reproduces that split on every platform. const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-link-')) + tempDirs.push(dir) const store = join(dir, 'store') mkdirSync(store, { recursive: true }) const link = join(store, 'pwsh.exe') @@ -141,6 +153,7 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => it('skips a directory candidate and falls through to the PATH-resolution default', () => { const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-dir-')) + tempDirs.push(dir) const store = join(dir, 'store') mkdirSync(join(store, 'pwsh.exe'), { recursive: true }) expect(resolvePwshPath(undefined, { @@ -201,6 +214,7 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.run', () => { it('uses config cwd, overridable per call', async () => { const first = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-a-')) const second = mkdtempSync(join(tmpdir(), 'dsh-pwsh-cwd-b-')) + tempDirs.push(first, second) const { bash } = await setup({ cwd: first }) const fromConfig = await bash.run(bash.resolve({ command: '(Get-Location).Path' })) expect(samePath(fromConfig.stdout.text.trim(), first)).toBe(true) diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index 6887e7849f..fa722d97af 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 517d59e094..bf3beecb81 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell-env", "description": "Tool-independent managed DSH_* shell environment registry", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index 074febadda..89a575aca8 100644 --- a/packages/shell/shell/package.json +++ b/packages/shell/shell/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell", "description": "Abstract bash executor seam (ctx.shell) for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index 231db575a0..3f4ad336e5 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash-persistent", "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 176642f236..4828a3188a 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash", "description": "Model-facing bash tool with optional generic background-job and sandbox-escalation support", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 747d98b48d..6da7972087 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -1,7 +1,7 @@ -import { mkdtempSync } from 'node:fs' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { ShellExecutor } from '@deepseek-ai/dsh-shell' @@ -29,6 +29,10 @@ const testToolSignal = new AbortController().signal const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-')) +afterAll(() => { + rmSync(spillDir, { recursive: true, force: true }) +}) + /** Foreground-only harness: no job runtime (backgrounding fails loud here). */ async function setup() { const ctx = new Context() diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index d3146ad65c..643c295de0 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index f39a6d9829..df1ee7c707 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 2bf101b9e2..dbd1f13bdd 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -10,9 +10,9 @@ * is pinned separately in integration.spec.ts. */ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { mkdtempSync, realpathSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' import { ToolCallId } from '@deepseek-ai/dsh-llm' @@ -38,6 +38,12 @@ import { renderPwshProcessRead, renderPwshResult } from '../src/render.ts' const testToolSignal = new AbortController().signal +/** Per-test temp dirs (session cwd/home fixtures), removed after each test. */ +const tempDirs: string[] = [] +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) +}) + /** * A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()` * returns the armed foreground script, `start()` returns the armed background @@ -388,6 +394,7 @@ describe('argument validation', () => { describe('execution through the bash seam', () => { it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => { const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-')) + tempDirs.push(dshHome) const { ctx, bash } = await setup({}, dshHome) bash.handler = () => runResult('hi\n') const agent = registerFakeAgent(ctx, 'session-1') @@ -538,6 +545,7 @@ describe('per-call sandbox policy resolution', () => { it('stamps the CALLING SESSION\'s resolved policy onto the request (session cwd, not the server launch dir)', async () => { const { ctx, bash } = await setupSandboxed() const sessionCwd = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-policy-')) + tempDirs.push(sessionCwd) const agent = registerFakeAgent(ctx, 'policy-session') Object.assign(agent.session.header, { cwd: sessionCwd }) const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent) diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index 1a92c9b5c0..3161965091 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-badge", "description": "Bundled dsh badge skill provider for DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index fc5dda6a3f..b8490fedb1 100644 --- a/packages/skill/skill-filesystem/package.json +++ b/packages/skill/skill-filesystem/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-filesystem", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-filesystem/tests/skill-filesystem-watcher.spec.ts b/packages/skill/skill-filesystem/tests/skill-filesystem-watcher.spec.ts index 95a0883a33..db57f3f6f5 100644 --- a/packages/skill/skill-filesystem/tests/skill-filesystem-watcher.spec.ts +++ b/packages/skill/skill-filesystem/tests/skill-filesystem-watcher.spec.ts @@ -3,7 +3,7 @@ import type { Stats } from 'node:fs' import { mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SkillRegistry from '@deepseek-ai/dsh-skill' @@ -91,8 +91,16 @@ vi.mock('chokidar', () => ({ const SkillFileSystem = await import('../src/index.ts') +/** Every temp dir created by this file, removed after each test. */ +const tempDirs: string[] = [] +afterEach(async () => { + for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }) +}) + async function tempDir(name: string): Promise { - return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) + const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) + tempDirs.push(dir) + return dir } async function writeSkill(root: string, name: string): Promise { diff --git a/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts b/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts index 238d1421c7..054535d7e7 100644 --- a/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts +++ b/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { mkdir, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' @@ -7,8 +7,16 @@ import SkillRegistry from '@deepseek-ai/dsh-skill' import { FileSystem, FsError, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs' import * as SkillFileSystem from '../src/index.ts' +/** Every temp dir created by this file, removed after each test. */ +const tempDirs: string[] = [] +afterEach(async () => { + for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }) +}) + async function tempDir(name: string): Promise { - return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) + const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) + tempDirs.push(dir) + return dir } async function writeSkill(root: string, name: string, description: string, body = 'Use the skill.'): Promise { diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 7197b02b68..e1c2f1d06e 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 814c8b7c0d..031a651239 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index d9c9fe89be..ffcb54268b 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest' -import { mkdir, writeFile } from 'node:fs/promises' +import { afterEach, describe, expect, it } from 'vitest' +import { mkdir, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from '@deepseek-ai/cordis' @@ -17,8 +17,16 @@ import * as toolSkill from '@deepseek-ai/dsh-tool-skill' const testToolSignal = new AbortController().signal +/** Every temp dir created by this file, removed after each test. */ +const tempDirs: string[] = [] +afterEach(async () => { + for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }) +}) + async function tempDir(name: string): Promise { - return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) + const dir = await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) + tempDirs.push(dir) + return dir } async function writeSkill(root: string, name: string, description: string, body: string): Promise { diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index e3a89f694d..465da9045d 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-local", "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index 9fb80b20f2..7db954dfdb 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-policy", "description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service API)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index 32b137f5c0..9cba6b38a3 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill", "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index 497e64d225..4090385679 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-domain", "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index fbe5e4e4f4..8377879175 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index a924f04c6a..c792dc793e 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 7f59ed4d0d..6b58a75b91 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage", "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index a55e09eac7..a6af312abd 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-acp", "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index b8817b8deb..c6a13ed6a2 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-claude-code", "description": "One-shot Claude Code subagent provider over the official Agent SDK", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 5d48201933..1abf92f843 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 4d542a8bc4..120b0c5304 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -53,7 +53,7 @@ afterEach(async () => { for (const root of roots.splice(0)) { await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) } -}) +}, 30_000) interface RealHarness { readonly ctx: Context diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index ba636c5336..c0351818ab 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-dsh-sdk", "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index 7ac6491976..cd934b57f7 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-fork-in-process", "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index e461c80052..f72a597127 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-in-process-driver", "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index b9dee3ab86..6c95b96103 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-spawn-in-process", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 25ecd63314..24c699d436 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent", "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 4e82493c1c..96f989bb50 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -30,7 +30,7 @@ */ import { Context } from '@deepseek-ai/cordis' -import { admitPromptContent } from '@deepseek-ai/dsh-attachment' +import type {} from '@deepseek-ai/dsh-attachment' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' @@ -445,7 +445,7 @@ export class SubagentRuntime extends TypertRemoteService { } else { const attachments = this.ctx.get('attachments') if (attachments === undefined) throw new Error('subagent image prompt requires an attachment store') - content = await admitPromptContent(attachments, request.content) + content = await attachments.admitPromptContent(request.content) } return { messageId: await this[deliverSubagentPrompt]( diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 82298d6eaf..3acaf20af1 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -469,6 +469,9 @@ describe('SubagentRuntime.startContinuable', () => { const routeless = await ctx.agentLoop.create(SessionId('routeless-resume'), {}) const started = await ctx.subagents.startContinuable(startSpec(routeless)) await waitNoActivation(ctx, started.childId) + // End the first lifecycle so its write leases release before the fresh + // context re-creates the parent identity and cold-resumes the child. + await ctx.fiber.dispose() const fresh = new Context() await mountAgentLoopTestDependencies(fresh) @@ -481,7 +484,9 @@ describe('SubagentRuntime.startContinuable', () => { await fresh.plugin(TestSessionQuery) await fresh.plugin(SubagentRuntime) await fresh.plugin(SubagentSpawn, { providerName: 'spawn' }) - const freshParent = await fresh.agentLoop.create(SessionId('routeless-resume'), {}) + // The disposed lifecycle drained the parent's log durably, so the fresh + // context resumes that identity instead of re-creating it. + const freshParent = (await fresh.agents.resume({ resumeSessionId: SessionId('routeless-resume'), agentOptions: {} })).agent await queuePrompt(fresh, freshParent, started.childId, message('resume routeless')) const resumed = await vi.waitFor(() => { diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index 733564032b..22db177cc3 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import AttachmentStore, { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import SubagentRuntime, { @@ -169,7 +169,7 @@ describe('subagent prompt Remote', () => { const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) const saveImages = vi.fn(async (inputs: readonly { mediaType: string }[]) => inputs.map((input, index) => ({ ...IMAGE_REF, attachmentId: `att-${index}`, mediaType: input.mediaType }))) - ctx.provide('attachments', { saveImages } as never) + ctx.provide('attachments', Object.setPrototypeOf({ saveImages }, AttachmentStore.prototype) as never) const delivery = promptDelivery(subagents).mockResolvedValue('m-content' as MessageId) const content = [ { type: 'text' as const, text: 'before' }, @@ -188,11 +188,11 @@ describe('subagent prompt Remote', () => { it('maps a refused image batch to subagent/attachment-invalid and delivers nothing', async () => { const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) - ctx.provide('attachments', { + ctx.provide('attachments', Object.setPrototypeOf({ saveImages: async () => { throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') }, - } as never) + }, AttachmentStore.prototype) as never) const delivery = promptDelivery(subagents) await expect(subagents.prompt({ @@ -207,7 +207,7 @@ describe('subagent prompt Remote', () => { it('maps non-canonical base64 to subagent/attachment-invalid without touching the store', async () => { const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) const saveImages = vi.fn() - ctx.provide('attachments', { saveImages } as never) + ctx.provide('attachments', Object.setPrototypeOf({ saveImages }, AttachmentStore.prototype) as never) const delivery = promptDelivery(subagents) await expect(subagents.prompt({ diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index c33282e314..333e5c0f64 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-control", "description": "Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 36222dd9a9..628ce10bd4 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent", "description": "Model-facing subagent delegation tool over the ctx.subagents seam", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 76150e77d5..7a129abee9 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-local/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/subprocess/subprocess-local/README.md -README.md: dd9edbc99578f5411fad993cf88f93429dc9cab2 -README.zh.md: 7bb43f7bbce5ef6b128edc9ef2104d35a5887845 +README.md: d8e44d160a01fa2f394aaf781cf90728b0506c77 +README.zh.md: 4a3fed4fa8525be1428a61c8bdc1c3fb8497f263 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index dd9edbc995..d8e44d160a 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -25,7 +25,7 @@ Mount `dsh-subprocess-local` in any composition that runs child processes on the ## Use this package -Mount the provider beside its consumers and start processes exactly as the subprocess service specifies; this package decides only how those processes run on the host. +Mount the provider beside its consumers and start processes exactly as the subprocess service specifies; this package decides only how those processes run on the host. On Windows, non-terminal children and `taskkill` helpers start with their windows hidden so background operations do not take focus. This also hides GUI windows that honor the process startup visibility setting. ### Mounting the provider @@ -50,7 +50,7 @@ Collect mode keeps the last `maxBytes` of a stream in memory — errors and fina ### Shutdown behavior -Normal disposal terminates every running tree and terminal and awaits their exit. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — a synchronous finalization force-terminates everything still owned (SIGKILL to the group, `taskkill /T /F` on Windows) without creating promises or timers. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor. +Normal disposal terminates every running tree and terminal and awaits their exit. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — a synchronous finalization force-terminates everything still owned (SIGKILL to the group, `taskkill /T /F` on Windows) without creating promises or timers. The same exit removes the private per-process spill directory when it holds no completed spill file (completed spill files are retained as full-output recovery artifacts until an external cleanup). Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor. ### What can go wrong @@ -127,7 +127,7 @@ These limits define when the provider is a poor fit or needs special operational - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the PTY root; on Linux, a `setsid` child leaves both the tree and the owned terminal session; the provider adds no continuous process-table monitor. - **In-process cleanup requires a JavaScript-observable exit** — direct `process.exit()`, default uncaught exceptions, and default unhandled rejections emit Node's synchronous `exit` event; an unhandled `SIGTERM`, `SIGINT`, or `SIGHUP`, `SIGKILL`, fatal OOM, `process.abort()`, native crashes, and power loss require an external supervisor, container init, or equivalent OS owner. - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently named secrets (for example `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed variables is noted future work. -- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill directory) accumulate under the OS tmpdir until something external cleans them. +- **Completed spill files are not deleted** — bounded full-output recovery files accumulate under the OS tmpdir until something external cleans them; the private per-process spill directory is removed at a JavaScript-observable exit only when it holds no completed spill file. ### Dev Note diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 7bb43f7bbc..4a3fed4fa8 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -把提供方与它的消费方挂载在同一组合中,并完全按子进程服务的规定启动进程;本包只决定这些进程在宿主机上如何运行。 +把提供方与它的消费方挂载在同一组合中,并完全按子进程服务的规定启动进程;本包只决定这些进程在宿主机上如何运行。在 Windows 上,非终端子进程与 `taskkill` 辅助进程会隐藏窗口,因此后台操作不会抢占焦点。遵循进程启动可见性设置的 GUI 窗口也会被隐藏。 ### 挂载提供方 @@ -50,7 +50,7 @@ kind: "package-reference" ### 关闭行为 -正常 dispose 会终止每棵仍在运行的进程树与终端并等待其退出。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会强制终止所有仍归本包所有的对象(对进程组发送 SIGKILL,Windows 上运行 `taskkill /T /F`),且不创建任何 Promise 或定时器。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电则需要外部 supervisor。 +正常 dispose 会终止每棵仍在运行的进程树与终端并等待其退出。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会强制终止所有仍归本包所有的对象(对进程组发送 SIGKILL,Windows 上运行 `taskkill /T /F`),且不创建任何 Promise 或定时器。同一退出阶段会删除每进程私有 spill 目录(仅当其未持有任何已完成的 spill 文件时;已完成的 spill 文件作为完整输出恢复产物保留,直到外部机制清理)。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电则需要外部 supervisor。 ### 可能出错的地方 @@ -127,7 +127,7 @@ spill 文件以 `0600` 权限、`O_EXCL` 与随机名称在 `0700` 每进程目 - **守护化的终端后代仍可能逃出可观察边界**——在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 PTY 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话;本提供方不新增持续进程表监视器。 - **进程内清理要求退出阶段仍能执行 JavaScript**——直接 `process.exit()`、默认未捕获异常和默认未处理 rejection 会发出 Node 同步 `exit` 事件;未处理的 `SIGTERM`、`SIGINT` 或 `SIGHUP`、`SIGKILL`、fatal OOM、`process.abort()`、native crash 与断电,都需要外部 supervisor、容器 init 或等价的 OS 所有者负责。 - **凭据清除依赖名称启发式规则**——只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 -- **不会删除已完成的 spill 文件**——有界的完整输出恢复文件(以及每进程私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理。 +- **不会删除已完成的 spill 文件**——有界的完整输出恢复文件会在 OS tmpdir 下累积,直到外部机制进行清理;每进程私有 spill 目录仅在未持有任何已完成 spill 文件时于 JavaScript 可观察的退出阶段删除。 ### 开发备注 diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index c52de7fc3e..f3b56fb873 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-local", "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 261f614fba..d5289682f5 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -39,7 +39,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime { private live = new Set() /** Live terminals retained through normal quiescence or host-exit finalization. */ private terminals = new Set() - /** Test hook: spill and platform knobs forwarded to spawnSubprocess. */ + /** Test hook: process, spill, and platform operations forwarded to spawnSubprocess. */ internals: SpawnInternals = {} /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */ terminalInspector: ProcessInspector | undefined diff --git a/packages/subprocess/subprocess-local/src/spawn.ts b/packages/subprocess/subprocess-local/src/spawn.ts index 433ba01791..03f1451c64 100644 --- a/packages/subprocess/subprocess-local/src/spawn.ts +++ b/packages/subprocess/subprocess-local/src/spawn.ts @@ -7,10 +7,10 @@ * @module dsh-subprocess-local/spawn */ -import { type ChildProcess, spawn, spawnSync } from 'node:child_process' +import { type ChildProcess, type SpawnOptions, spawn, spawnSync } from 'node:child_process' import type { Readable } from 'node:stream' import { randomBytes } from 'node:crypto' -import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' +import { closeSync, mkdtempSync, openSync, rmdirSync, unlinkSync, writeSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { setTimeout as sleepMs } from 'node:timers/promises' @@ -26,6 +26,12 @@ import type { } from '@deepseek-ai/dsh-subprocess' import { linuxProcessGroupHasLiveMembers } from './process-inspector.ts' +type SpawnProcess = ( + program: string, + args: readonly string[], + options: SpawnOptions, +) => ChildProcess + /** * Build a child environment: explicit caller entries override the scrubbed * parent base using the target platform's environment-key semantics. A string @@ -46,8 +52,10 @@ export function childEnv(extra?: Readonly): NodeJS.ProcessEnv return Object.fromEntries(entries) } -/** Injectable knobs so tests can exercise spill and platform behavior deterministically. */ +/** Injectable process, spill, and platform operations. */ export interface SpawnInternals { + /** Process spawner (defaults to `node:child_process` `spawn`). */ + spawn?: SpawnProcess /** Directory for spill files (defaults to the OS temp dir). */ spillDir?: string /** Windows tree-termination runner (defaults to `taskkill /PID /T /F`). */ @@ -84,13 +92,27 @@ let defaultSpillDir: string | undefined /** * The default spill location: a private (0700) per-process directory under * the OS tmpdir, created lazily. Predictable world-readable paths would let - * other local users read command output or pre-create symlinks. + * other local users read command output or pre-create symlinks. At a + * JavaScript-observable process exit the directory is removed only when it + * holds no completed spill file (spill files are retained as full-output + * recovery artifacts until an external cleanup). */ function privateSpillDir(): string { defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-subprocess-')) return defaultSpillDir } +// The per-process spill directory is removed at process exit when it holds no +// completed spill file: a directory that never spilled is empty and is safe to +// remove, while a directory holding completed spill files keeps them (their +// content is retained until an external cleanup). A SIGKILLed process cannot +// run this at all; its residue is left to OS temp hygiene. +/* v8 ignore next 4 -- exit listeners run after the coverage dump; removal is verified by the CI /tmp residue measurement. */ +process.once('exit', () => { + if (defaultSpillDir === undefined) return + try { rmdirSync(defaultSpillDir) } catch { /* best-effort: ENOENT/ENOTEMPTY/EBUSY/EPERM must not change the exit code. */ } +}) + /** * Collects one stream with a bounded in-memory tail. With a spill cap, on * first overflow a spill file is created and every chunk (including those @@ -278,7 +300,10 @@ export function taskkillProcessTree(pid: number): void { // Outcome deliberately unchecked: an already-absent tree (status 128), exit // races, and a missing taskkill binary (spawnSync reports, never throws) are // as tolerable here as ESRCH is for a POSIX group signal. - spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) + spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + }) } /** @@ -329,6 +354,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter } const spillDir = internals.spillDir ?? privateSpillDir() const platform = internals.platform ?? process.platform + const spawnProcess = internals.spawn ?? spawn const taskkill = internals.taskkill ?? taskkillProcessTree const linuxGroupHasLiveMembers = internals.linuxProcessGroupHasLiveMembers ?? linuxProcessGroupHasLiveMembers @@ -347,7 +373,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter const stdinMode = spec.stdio.stdin const env = childEnv(spec.env) - const child = spawn(program, args, { + const child = spawnProcess(program, args, { cwd: spec.cwd, env, stdio: [ @@ -358,6 +384,7 @@ export function spawnSubprocess(spec: SubprocessSpawnSpec, internals: SpawnInter // `detached` gives teardown a tree root on POSIX (its own process group); // Windows terminates by root pid through taskkill /T instead. detached: platform !== 'win32', + windowsHide: platform === 'win32', }) const collectStream = (mode: SubprocessOutputMode, stream: Readable | null, label: string): OutputCollector | undefined => { diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts index 6820505c3d..14715c65aa 100644 --- a/packages/subprocess/subprocess-local/src/windows-inspector.ts +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -145,7 +145,10 @@ function taskkillTree(pid: number, force: boolean): void { if (pid <= 0) return // Outcome deliberately unchecked: an already-absent tree, exit races, and a // missing taskkill binary are as tolerable here as ESRCH is for POSIX. - spawnSync('taskkill', ['/PID', String(pid), '/T', ...(force ? ['/F'] : [])], { stdio: 'ignore' }) + spawnSync('taskkill', ['/PID', String(pid), '/T', ...(force ? ['/F'] : [])], { + stdio: 'ignore', + windowsHide: true, + }) } declare const nativePtr: unique symbol diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 1f21dfaaa1..262a6ea56a 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -1,7 +1,8 @@ -import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' +import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { childEnv, killGroup, @@ -12,6 +13,11 @@ import { import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, spawnSync: vi.fn(actual.spawnSync) } +}) + /** * Translate the suite's POSIX command strings into node one-liners on Windows, * where no bash exists; the translated commands keep the same observable @@ -82,6 +88,14 @@ vi.mock('node:fs', async (importOriginal) => { const spillDir = mkdtempSync(join(tmpdir(), 'dsh-subprocess-spec-')) +/** The per-process default spill dir captured by the default-spill test. */ +let defaultSpillDir: string | undefined + +afterAll(() => { + rmSync(spillDir, { recursive: true, force: true }) + if (defaultSpillDir !== undefined) rmSync(defaultSpillDir, { recursive: true, force: true }) +}) + type SpecOverrides = Partial[0]> & { stdoutMaxBytes?: number stderrMaxBytes?: number @@ -631,6 +645,30 @@ describe('stdio dispositions', () => { }) describe('windows tree semantics (injected platform)', () => { + it('hides the child window without changing output, exit, stdio, or tree-root options', async () => { + let options: Parameters[2] + const result = await finish(spawnSubprocess(spec('echo hello'), { + spillDir, + platform: 'win32', + spawn: (program, args, spawnOptions) => { + options = spawnOptions + return nodeSpawn(program, args, spawnOptions) + }, + })) + + expect(options!).toMatchObject({ + windowsHide: true, + detached: false, + stdio: ['ignore', 'pipe', 'pipe'], + }) + expect(result).toMatchObject({ + exitCode: 0, + signal: null, + stdout: { text: 'hello\n', truncated: false }, + stderr: { text: '', truncated: false }, + }) + }) + it('host-exit termination routes through taskkill immediately', async () => { const killed: number[] = [] const running = spawnSubprocess(spec('exec sleep 60', { graceMs: 60_000 }), { @@ -773,6 +811,17 @@ describe.skipIf(process.platform === 'win32')('tree-survivor escalation (termina }) describe('coverage seams', () => { + it('hides the taskkill helper window', () => { + const taskkill = vi.mocked(nodeSpawnSync) + taskkill.mockReturnValueOnce({} as never) + taskkillProcessTree(77) + expect(taskkill).toHaveBeenLastCalledWith( + 'taskkill', + ['/PID', '77', '/T', '/F'], + { stdio: 'ignore', windowsHide: true }, + ) + }) + it('taskkillProcessTree ignores non-positive pids and contains a missing binary', () => { expect(() => { taskkillProcessTree(-1) }).not.toThrow() expect(() => { taskkillProcessTree(0) }).not.toThrow() @@ -1086,6 +1135,7 @@ describe('environment and spill-file hardening', () => { spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), )) const dir = dirname(result.stdout.spillPath!) + defaultSpillDir = dir expect(dir).toMatch(/dsh-subprocess-/) const mode = statSync(dir).mode & 0o777 expect(mode).toBe(0o700) diff --git a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts index de8df8d535..d63e3b8e8d 100644 --- a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { spawnSync as nodeSpawnSync } from 'node:child_process' +import { describe, expect, it, vi } from 'vitest' import { createWindowsProcessInspector, isInvalidHandle, @@ -12,6 +13,11 @@ import type { WindowsProcessState, } from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, spawnSync: vi.fn(actual.spawnSync) } +}) + function fakeInternals() { const entries: ProcessEntry[] = [] const states = new Map() @@ -86,6 +92,26 @@ describe('windowsProcessTree', () => { }) describe('WindowsProcessInspector (injected internals)', () => { + it('hides the default taskkill helper window for both termination tiers', () => { + const taskkill = vi.mocked(nodeSpawnSync) + taskkill.mockReturnValueOnce({} as never).mockReturnValueOnce({} as never) + const inspector = createWindowsProcessInspector() + inspector.signalGroup(77, 'SIGKILL') + inspector.signalGroup(78, 'SIGTERM') + expect(taskkill).toHaveBeenNthCalledWith( + 1, + 'taskkill', + ['/PID', '77', '/T', '/F'], + { stdio: 'ignore', windowsHide: true }, + ) + expect(taskkill).toHaveBeenNthCalledWith( + 2, + 'taskkill', + ['/PID', '78', '/T'], + { stdio: 'ignore', windowsHide: true }, + ) + }) + it('exposes the shell pid as the pseudo foreground group and never proves stdin waits', () => { const fake = fakeInternals() const inspector = new WindowsProcessInspector(fake.internals) diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 5c1cdf3b68..bb5f574ccb 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess", "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index 6637dc0a46..af386bf6d4 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-win32-process", "description": "Low-level Win32 process, stdio, and Job Object primitives for the DeepSeek Harness Windows sandbox", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index bf325bb2bf..34950b167d 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal-bash", "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index 19258166f8..334b16c864 100644 --- a/packages/terminal/terminal/package.json +++ b/packages/terminal/terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal", "description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index 1208f85038..e9a179124b 100644 --- a/packages/terminal/tool-terminal/package.json +++ b/packages/terminal/tool-terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-terminal", "description": "Six model-facing persistent PTY tools with owner isolation and generic background-job integration", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index 5cf1dca3e4..0a166134f6 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop-testkit", "description": "Shared prerequisite mounting for tests that exercise the concrete agent loop", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index e3a08dda63..4437930d49 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/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/test-support/client-runtime/README.md -README.md: 3cc916343c25144720bfd599b87da7b5a2bb6b5d -README.zh.md: cd8a935f16bee9946edad8df41dab37c3b72af69 +README.md: 4ab70ecb6db7ae7d1844d8141b34daf7bd19a3dc +README.zh.md: c3265d7cdcadaf55c0744b13267ac5e64d6b47a6 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 3cc916343c..4ab70ecb6d 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-test-runtime` gives a browser feature spec a real jsdom test bench: it assembles a Cordis context, the renderer-owned slot registry, and the production `UiSession` adapter around typed Session and Workspace Controller doubles. Feature suites exercise declaration, registration, scoping, stores, injection, rendering, updates, and disposal without copying production renderer or adapter logic. Suites publish Session lifecycle state, Workspace state, projection values, and Conversation events through typed fixtures, then use local DOM snapshot roots, scoped Testing Library queries, and fail-loud service checks. It is not part of the product plugin graph (no `dsh.client`); feature packages depend on it in `devDependencies` only. +`dsh-client-test-runtime` gives a browser feature spec a real jsdom test bench: it assembles a Cordis context, the renderer-owned slot registry, and the production `UiSession` adapter around typed Session and Workspace Controller doubles. A default file-upload stub satisfies features that declare the service and rejects if a test starts an upload without replacing it. Feature suites exercise declaration, registration, scoping, stores, injection, rendering, updates, and disposal without copying production renderer or adapter logic. Suites publish Session lifecycle state, Workspace state, projection values, and Conversation events through typed fixtures, then use local DOM snapshot roots, scoped Testing Library queries, and fail-loud service checks. It is not part of the product plugin graph (no `dsh.client`); feature packages depend on it in `devDependencies` only. ## Table of Contents @@ -40,7 +40,7 @@ expect(view.container).toMatchSnapshot() await runtime.dispose() ``` -`mount` prechecks required services and fails loud when one is missing — `provide(name, value)` supplies an extra service first. `storeOf(key, scopeKey)` returns the live store instance the renderer hands a slot's component for identity and action-driven-write assertions. +`mount` prechecks required services and fails loud when one is missing — `provide(name, value)` supplies an extra service first. The runtime provides an unavailable `fileUpload` stub so assemblies can mount; replace `runtime.fileUpload.upload` before mounting when a test exercises upload behavior. `storeOf(key, scopeKey)` returns the live store instance the renderer hands a slot's component for identity and action-driven-write assertions. ### Local DOM snapshots @@ -101,7 +101,7 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p ### Lifecycle -`create()` builds a fresh context, mounts the slot and conversation registries, installs the renderer, and provides the session/workspace doubles. `mount` checks every declared injection against the context before starting the fiber, so a missing provider fails loud instead of suspending forever. `dispose()` unmounts React trees first, then disposes feature fibers, releases the root registration, disposes minted session scopes, and clears persisted store state; every public mutator is act-wrapped, so tests never handle SlotCore microtask batching or React `act` themselves. +`create()` builds a fresh context, mounts the slot and conversation registries, installs the renderer, and provides the session/workspace doubles plus the fail-loud file-upload stub. `mount` checks every declared injection against the context before starting the fiber, so a missing provider fails loud instead of suspending forever. `dispose()` unmounts React trees first, then disposes feature fibers, releases the root registration, disposes minted session scopes, and clears persisted store state; every public mutator is act-wrapped, so tests never handle SlotCore microtask batching or React `act` themselves. diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index cd8a935f16..c3265d7cdc 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-client-test-runtime` 让浏览器功能测试拥有真实的 jsdom 测试台:它把 Cordis 上下文、渲染器拥有的 slot 注册表与生产 `UiSession` 适配器组装在带类型的 Session 和 Workspace Controller 替身周围。功能套件无需复制生产渲染器或适配器逻辑,即可检验声明、注册、作用域、store、注入、渲染、更新与销毁。套件通过带类型 fixture 发布 Session 生命周期状态、Workspace 状态、projection 值与 Conversation 事件,再使用局部 DOM 快照根、限定范围的 Testing Library 查询与自明的服务缺失检查。它不属于产品插件图(无 `dsh.client`);feature 包仅以 `devDependencies` 依赖之。 +`dsh-client-test-runtime` 让浏览器功能测试拥有真实的 jsdom 测试台:它把 Cordis 上下文、渲染器拥有的 slot 注册表与生产 `UiSession` 适配器组装在带类型的 Session 和 Workspace Controller 替身周围。默认文件上传替身可满足声明该服务的功能;测试若没有替换它却发起上传,就会明确失败。功能套件无需复制生产渲染器或适配器逻辑,即可检验声明、注册、作用域、store、注入、渲染、更新与销毁。套件通过带类型 fixture 发布 Session 生命周期状态、Workspace 状态、projection 值与 Conversation 事件,再使用局部 DOM 快照根、限定范围的 Testing Library 查询与自明的服务缺失检查。它不属于产品插件图(无 `dsh.client`);feature 包仅以 `devDependencies` 依赖之。 ## 目录 @@ -40,7 +40,7 @@ expect(view.container).toMatchSnapshot() await runtime.dispose() ``` -`mount` 会预检必需服务,缺失时自明报错——先用 `provide(name, value)` 提供额外服务。`storeOf(key, scopeKey)` 返回渲染器交给 slot 组件的实时 store 实例,用于身份与动作驱动写入断言。 +`mount` 会预检必需服务,缺失时自明报错——先用 `provide(name, value)` 提供额外服务。运行时会提供不可用的 `fileUpload` 替身,使装配可以挂载;测试上传行为时,需要在挂载前替换 `runtime.fileUpload.upload`。`storeOf(key, scopeKey)` 返回渲染器交给 slot 组件的实时 store 实例,用于身份与动作驱动写入断言。 ### 局部 DOM 快照 @@ -101,7 +101,7 @@ expect(view.getByRole('alert')).toHaveTextContent('goal/not-found') ### 生命周期 -`create()` 构建全新上下文,挂载 slot 与会话注册表,安装渲染器,并提供 session/workspace 替身。`mount` 在启动 fiber 前对照上下文检查每个已声明注入,使缺失提供方自明报错而非永久挂起。`dispose()` 先卸载 React 树,再 dispose feature fiber、释放根注册、dispose 已铸 session scope 并清除持久化 store 状态;每个公共修改器都包裹在 act 中,因此测试无需自行处理 SlotCore 微任务批处理或 React `act`。 +`create()` 构建全新上下文,挂载 slot 与会话注册表,安装渲染器,并提供 session/workspace 替身和明确失败的文件上传替身。`mount` 在启动 fiber 前对照上下文检查每个已声明注入,使缺失提供方自明报错而非永久挂起。`dispose()` 先卸载 React 树,再 dispose feature fiber、释放根注册、dispose 已铸 session scope 并清除持久化 store 状态;每个公共修改器都包裹在 act 中,因此测试无需自行处理 SlotCore 微任务批处理或 React `act`。 diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 22eeddb1c9..2b66d23f5f 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-test-runtime", "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + UI renderer with test-owned session/workspace doubles for feature specs", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 60c5c078eb..7d0d37c9cb 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -1,7 +1,7 @@ /** * jsdom slot test runtime: a real small runtime — Cordis `Context`, the * renderer-owned `SlotRegistry`, the `ui-session` adapter, and the UI renderer — assembled around - * test-owned session/workspace doubles, so feature specs exercise + * test-owned session/workspace doubles and a fail-loud file-upload stub, so feature specs exercise * declaration, registration, scope, store, inject, rendering, updates, and * disposal without hand-building the machinery per suite. * @@ -28,6 +28,7 @@ import { createSlotRenderer as createRenderer } from '@deepseek-ai/dsh-client-ui import { apply as applyUiSession, inject as uiSessionInject, } from '@deepseek-ai/dsh-client-ui-session/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ChildrenDecl, ComposedProps, HostObservable, OwnerOf, SlotComponent, SlotMap, SlotRenderer, SlotRendererHost, SnapshotSelectorHook, StoreInstanceLike, @@ -110,6 +111,14 @@ export interface FeatureHandle { dispose(): Promise } +/** Mutable fail-loud file-upload stub installed by {@link SlotTestRuntime}. */ +export interface TestFileUpload { + /** Availability reported to the feature under test. */ + available: boolean + /** Test-supplied upload behavior; the default rejects every call. */ + upload: (sessionId: SessionId, ...args: unknown[]) => Promise +} + /** * Owner-props cell behind the auto frame: one external store the frame * subscribes to, so {@link SlotTestRuntime.renderSlot} and @@ -208,6 +217,8 @@ export class SlotTestRuntime { readonly sessions: TestSessions /** Workspaces double (list observable, recorded intent actions). */ readonly workspaces: TestWorkspaces + /** Mutable file-upload stub; replace `upload` in suites that exercise the capability. */ + readonly fileUpload: TestFileUpload private readonly stabilizer: Stabilizer = async (fn) => { await act(async () => { await fn() }) @@ -229,8 +240,13 @@ export class SlotTestRuntime { this.root = new TestRoot(slots, this.stabilizer) this.sessions = new TestSessions(this.stabilizer, ctx) this.workspaces = new TestWorkspaces(this.stabilizer) + this.fileUpload = { + available: false, + upload: () => Promise.reject(new Error('client test runtime: file upload is not stubbed')), + } ctx.provide('sessions', this.sessions) ctx.provide('workspaces', this.workspaces) + ctx.provide('fileUpload', this.fileUpload as never) this.disposeWorkspaceSource = slots.provideRoot({ hooks: { workspaces: this.workspaces.list } }) // Capturing install: the production renderer does the rendering; the // wrapper only takes the host face for storeOf (no machinery copied). diff --git a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx index 2fb223150a..c3904623ba 100644 --- a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx @@ -38,6 +38,13 @@ function entry(seq: number): SessionLiveEventEntry { } describe('fixture helpers', () => { + it('rejects an upload until a suite replaces the default stub', async () => { + const runtime = await SlotTestRuntime.create() + expect(runtime.fileUpload.available).toBe(false) + await expect(runtime.fileUpload.upload('fixture-session' as SessionId)).rejects.toThrow('file upload is not stubbed') + await runtime.dispose() + }) + it('builds independent Conversation and Chat snapshots with optional overrides', () => { const conversation = conversationSnapshot() expect(conversation).toEqual(EMPTY_CONVERSATION_SNAPSHOT) diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index 135db9ad61..360791d82a 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-support/llm-mock-server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-replay/package.json b/packages/test-support/llm-replay/package.json index 429598e37d..0db1bcbf6d 100644 --- a/packages/test-support/llm-replay/package.json +++ b/packages/test-support/llm-replay/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-replay", "description": "Replay LLM plugin: short-circuits llm/stream with model chunks reconstructed from a recorded session JSONL (keyless snapshot tests)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/loader-smoke/package.json b/packages/test-support/loader-smoke/package.json index 0086b3fe99..bd9a4a6f58 100644 --- a/packages/test-support/loader-smoke/package.json +++ b/packages/test-support/loader-smoke/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-loader-smoke", "description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/session-snapshot/package.json b/packages/test-support/session-snapshot/package.json index 318fa36de9..c864cc9920 100644 --- a/packages/test-support/session-snapshot/package.json +++ b/packages/test-support/session-snapshot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-snapshot", "description": "Session-log snapshot core with an ACP protocol adapter, expected-output normalization, and fixture invariants", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/session-snapshot/tests/harness.spec.ts b/packages/test-support/session-snapshot/tests/harness.spec.ts index 889ccefb67..508fd2b635 100644 --- a/packages/test-support/session-snapshot/tests/harness.spec.ts +++ b/packages/test-support/session-snapshot/tests/harness.spec.ts @@ -895,9 +895,9 @@ describe('runScenario', () => { it('waitForTurnEnd times out for a missing log and an open logged turn', { timeout: 20_000 }, async () => { const missing = await scenario({}) await expect(runScenario( - { steps: [...boot, { op: 'waitForTurnEnd', timeoutMs: 20 }] }, + { steps: [...boot, { op: 'waitForTurnEnd', timeoutMs: 200 }] }, { agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile }, - )).rejects.toThrow(/did not persist turn\/end within 20ms/) + )).rejects.toThrow(/did not persist turn\/end within 200ms/) const open = await scenario({ prompt: 'hang-until-cancel', @@ -915,11 +915,11 @@ describe('runScenario', () => { steps: [ ...boot, { op: 'promptAndCancel', text: 'hang' }, - { op: 'waitForTurnEnd', timeoutMs: 20 }, + { op: 'waitForTurnEnd', timeoutMs: 200 }, ], }, { agent: AGENT, mode: 'replay', fixtureFile: open.fixtureFile }, - )).rejects.toThrow(/did not persist turn\/end within 20ms/) + )).rejects.toThrow(/did not persist turn\/end within 200ms/) }) it('waitForGoalPhase requires the requested durable goal phase', { timeout: 20_000 }, async () => { diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index b32e87e51f..59afa5ef3a 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-todo", "description": "Model-facing todo_write tool over the DeepSeek Harness event-sourced session log", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 7f5732eee3..be08df834a 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-generator", "description": "TypeScript project analyzer and model-driven Typert artifact generator", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 366ae921f1..bbe68f6b84 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-loader", "description": "Loader integration for generated Typert package contributions", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index 1e1cfb198b..d2e2b8c6c7 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-protocol", "description": "Compiler-independent Remote metadata and Typert provider protocols", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 7ed8e19cb1..3b2ed96b42 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-registry", "description": "Runtime registry for generated package reflection and Zod schemas", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index 9910a906da..c58725a44d 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-atomic-write", "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 85432947cb..8347b38484 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-brand", "description": "Stateless branded primitive types for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/crypto/package.json b/packages/util/crypto/package.json index 38e0cf0a63..ad3eb7bf2e 100644 --- a/packages/util/crypto/package.json +++ b/packages/util/crypto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-crypto", "description": "Zero-dependency browser-safe UUID and byte-encoding helpers", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/deque/package.json b/packages/util/deque/package.json index 6888402d6a..c3fbe1fb07 100644 --- a/packages/util/deque/package.json +++ b/packages/util/deque/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deque", "description": "Zero-dependency circular deque with amortized constant-time end operations and bounded vacant storage", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/home-paths/package.json b/packages/util/home-paths/package.json index cd1ded1380..c09a719497 100644 --- a/packages/util/home-paths/package.json +++ b/packages/util/home-paths/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-home-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/http-proxy/package.json b/packages/util/http-proxy/package.json index d75ba94f36..265425e0ab 100644 --- a/packages/util/http-proxy/package.json +++ b/packages/util/http-proxy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-http-proxy", "description": "Process-wide outbound HTTP proxy policy for DeepSeek Harness: resolve it from the launch environment and install it as undici's global dispatcher", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/launch-environment/package.json b/packages/util/launch-environment/package.json index 22e5678645..d8e2461c33 100644 --- a/packages/util/launch-environment/package.json +++ b/packages/util/launch-environment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-launch-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 7292d6e8f8..c70ba86c1b 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-native-command", "description": "Host-native command and path-opening utilities with shell-free execution, cancellation, desktop detection, and WSL handoff", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/output-retention/package.json b/packages/util/output-retention/package.json index 269f2a179a..ed0ffc3158 100644 --- a/packages/util/output-retention/package.json +++ b/packages/util/output-retention/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-output-retention", "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/time/package.json b/packages/util/time/package.json index 0926292844..3dd79c779a 100644 --- a/packages/util/time/package.json +++ b/packages/util/time/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-time", "description": "Zero-dependency time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization only, no formatting)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 6d76c3709a..92b970473e 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-timeout", "description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/values/package.json b/packages/util/values/package.json index 9d76bdfd12..ada3e492cf 100644 --- a/packages/util/values/package.json +++ b/packages/util/values/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-values", "description": "Duplicate-install-safe value primitives for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/workspace-path/README.i18n.yaml b/packages/util/workspace-path/README.i18n.yaml index bd0d7232a7..bfff3060b8 100644 --- a/packages/util/workspace-path/README.i18n.yaml +++ b/packages/util/workspace-path/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/util/workspace-path/README.md -README.md: 72addf2d85c62ef5afbfc4824af8c96c59bfbecb -README.zh.md: 2a115f1a9dbdb23b7121dea41eb6da75b1515358 +README.md: 1b09bda74c8836655789c57fa05d03a2892c16b9 +README.zh.md: 25128e5fec504e1724f5cc7952fed976985912d5 diff --git a/packages/util/workspace-path/README.md b/packages/util/workspace-path/README.md index 72addf2d85..1b09bda74c 100644 --- a/packages/util/workspace-path/README.md +++ b/packages/util/workspace-path/README.md @@ -22,7 +22,7 @@ Browser-safe path helpers shared by Workspace-facing client and controller packa -- **Resolution is lexical** — it recognizes POSIX absolute paths, Windows drive paths, and UNC paths but does not access a filesystem or canonicalize `.` and `..` segments. +- **Resolution is lexical** — it recognizes POSIX absolute paths, Windows drive paths, and UNC paths, preserves the Workspace path's separator when joining a relative path, and does not access a filesystem or canonicalize `.` and `..` segments. - **Home abbreviation is POSIX-only** — Windows paths remain unchanged because a portable browser cannot infer Windows home-path equivalence safely. diff --git a/packages/util/workspace-path/README.zh.md b/packages/util/workspace-path/README.zh.md index 2a115f1a9d..25128e5fec 100644 --- a/packages/util/workspace-path/README.zh.md +++ b/packages/util/workspace-path/README.zh.md @@ -22,7 +22,7 @@ kind: "package-library" -- **路径解析仅处理字面值**——它识别 POSIX 绝对路径、Windows 盘符路径和 UNC 路径,但不访问文件系统,也不规范化 `.` 与 `..` 路径段。 +- **路径解析仅处理字面值**——它识别 POSIX 绝对路径、Windows 盘符路径和 UNC 路径,拼接相对路径时保留 Workspace 路径的分隔符,但不访问文件系统,也不规范化 `.` 与 `..` 路径段。 - **主目录缩写仅支持 POSIX**——Windows 路径保持不变,因为可移植浏览器无法安全推断 Windows 主目录路径等价关系。 diff --git a/packages/util/workspace-path/package.json b/packages/util/workspace-path/package.json index 4ee7bc888f..39209a1017 100644 --- a/packages/util/workspace-path/package.json +++ b/packages/util/workspace-path/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-workspace-path", "description": "Browser-safe Workspace path and display helpers", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/workspace-path/src/index.ts b/packages/util/workspace-path/src/index.ts index e2f57276f3..9fe3435573 100644 --- a/packages/util/workspace-path/src/index.ts +++ b/packages/util/workspace-path/src/index.ts @@ -17,9 +17,10 @@ function isWindowsStylePath(value: string): boolean { export function resolveWorkspacePath(cwd: string | undefined, path: string): string { if (path.startsWith('/') || isWindowsStylePath(path)) return path if (cwd === undefined || cwd === '') return path + const separator = isWindowsStylePath(cwd) && cwd.includes('\\') ? '\\' : '/' const base = cwd.replace(/[/\\]+$/, '') const relative = path.replace(/^[/\\]+/, '') - return `${base}/${relative}` + return `${base}${separator}${relative}` } /** diff --git a/packages/util/workspace-path/tests/index.spec.ts b/packages/util/workspace-path/tests/index.spec.ts index 7dccbe4530..0044e4b088 100644 --- a/packages/util/workspace-path/tests/index.spec.ts +++ b/packages/util/workspace-path/tests/index.spec.ts @@ -13,6 +13,12 @@ describe('Workspace path helpers', () => { expect(resolveWorkspacePath('/w', '\\\\server\\share')).toBe('\\\\server\\share') }) + it('keeps Windows drive-root and directory joins fully qualified', () => { + expect(resolveWorkspacePath('C:\\', 'src\\a.ts')).toBe('C:\\src\\a.ts') + expect(resolveWorkspacePath('C:\\work\\', 'src\\a.ts')).toBe('C:\\work\\src\\a.ts') + expect(resolveWorkspacePath('C:/work/', 'src/a.ts')).toBe('C:/work/src/a.ts') + }) + it('abbreviates only descendants of a POSIX home', () => { expect(abbreviateHomePath('/Users/u', '/Users/u')).toBe('~') expect(abbreviateHomePath('/Users/u/', '/Users/u')).toBe('~') diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index babc8e8614..ab2fa043fc 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-web", "description": "Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-fetch-http/package.json b/packages/web/web-fetch-http/package.json index 57978a3ff5..672dd4d6a2 100644 --- a/packages/web/web-fetch-http/package.json +++ b/packages/web/web-fetch-http/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-fetch-http", "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index f68b3b770f..988473a8d7 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-deepseek", "description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index afcdfa2c09..bfe7f733f4 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-exa", "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 6301eb992c..eec160192f 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-perplexity", "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/web/web/package.json b/packages/web/web/package.json index a4f1c82afb..40433f155f 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web", "description": "Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook-github/package.json b/packages/webhook/webhook-github/package.json index ed0efc2536..93a1c5a539 100644 --- a/packages/webhook/webhook-github/package.json +++ b/packages/webhook/webhook-github/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook-github", "description": "Signed GitHub HTTP webhook adapter for the DeepSeek Harness webhook runtime", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook/package.json b/packages/webhook/webhook/package.json index 69721ea308..24b7b6d7a7 100644 --- a/packages/webhook/webhook/package.json +++ b/packages/webhook/webhook/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook", "description": "Fire-and-forget webhook rule runtime that creates Workspace-backed DeepSeek Harness Sessions", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index ed0c2b5ad9..7ae876e2d3 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ralph", "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 79e47b601e..352c801952 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workflow", "description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflowEngine", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index 42c823c95c..4ef415b2ac 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow-worker-thread", "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 6586121f72..11f84b0c64 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow", "description": "Workflow capability seam: ctx.workflowEngine service, run vocabulary, and workflow/* events", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/workspace/workspace/README.i18n.yaml b/packages/workspace/workspace/README.i18n.yaml index 70a99f420d..5d98564e58 100644 --- a/packages/workspace/workspace/README.i18n.yaml +++ b/packages/workspace/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/workspace/workspace/README.md -README.md: 49ca861742fe9ea5f36edbaeb8d9700c475b1d50 -README.zh.md: 707044970b716577d2f1f95e558c341e217985c4 +README.md: eb3d2a4f89b6023d8a916a41723500186cc10d93 +README.zh.md: 5059a6edd69a82bd8b0a364793ca1b5c941d67e2 diff --git a/packages/workspace/workspace/README.md b/packages/workspace/workspace/README.md index 49ca861742..eb3d2a4f89 100644 --- a/packages/workspace/workspace/README.md +++ b/packages/workspace/workspace/README.md @@ -50,7 +50,7 @@ With these rows mounted, creating a project shows up in the list immediately and ### Creating and ordering projects -Create a project from any directory that exists: give its path and an optional title, and the project appears in the list, newest first. A path that does not exist, or a file instead of a directory, is rejected and nothing changes; creating a project for a directory that already has one returns the existing project unchanged. Rename a project at any time, and move it to any position in the list: +Create a project from any fully qualified directory that exists: filesystem roots such as `C:\` and ordinary directories are valid. Relative paths, Windows drive-relative paths such as `C:work`, missing paths, and files are rejected without creating a project; creating a project for a directory that already has one returns the existing project unchanged. Rename a project at any time, and move it to any position in the list: ```text // Host consumer code, after the composition above is loaded: diff --git a/packages/workspace/workspace/README.zh.md b/packages/workspace/workspace/README.zh.md index 707044970b..5059a6edd6 100644 --- a/packages/workspace/workspace/README.zh.md +++ b/packages/workspace/workspace/README.zh.md @@ -50,7 +50,7 @@ kind: "package-reference" ### 创建与排序项目 -从任何存在的目录创建项目:给出路径和可选标题,项目即出现在列表中,新到旧排列。不存在的路径或文件而非目录会被拒绝,且不会有任何变化;为已有项目的目录再次创建会原样返回现有项目。你可以随时重命名项目,并把它移动到列表中的任意位置: +从任何存在且完整限定的目录创建项目:`C:\` 等文件系统根目录和普通目录都有效。相对路径、`C:work` 等 Windows 盘符相对路径、不存在的路径和文件都会被拒绝,且不会创建项目;为已有项目的目录再次创建会原样返回现有项目。你可以随时重命名项目,并把它移动到列表中的任意位置: ```text // Host consumer code, after the composition above is loaded: diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 2812834028..5714138036 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace", "description": "Workspace entity registry (ctx.workspaceRegistry): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness", - "version": "0.1.2-rc.1", + "version": "0.1.3-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index 5e96de7ee4..5f149adac1 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -7,7 +7,6 @@ import { randomUUID } from 'node:crypto' import { stat } from 'node:fs/promises' -import { basename } from 'node:path' import { Context, Service } from '@deepseek-ai/cordis' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-persistence' @@ -16,7 +15,7 @@ import { WorkspaceEntity } from './entity.ts' import type { WorkspaceEntityHost } from './entity.ts' export { WorkspaceMoveInvalidError } from './entity.ts' -import { realpathNormalize } from './paths.ts' +import { defaultWorkspaceTitle, realpathNormalize } from './paths.ts' import { workspaceDomainSpec } from './spec.ts' import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts' import type { Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts' @@ -140,13 +139,13 @@ export class WorkspaceRegistry extends Service { } /** - * Create or reuse a workspace for an existing directory. The path is - * canonicalized through `fs.realpath`; a nonexistent path rejects with the - * original error and a non-directory rejects. Repeated calls for the same - * canonical path return the existing entity without changing its title. + * Create or reuse a workspace for an existing directory. The fully qualified + * path is canonicalized through `fs.realpath`; a relative, nonexistent, or + * non-directory path rejects. Repeated calls for the same canonical path + * return the existing entity without changing its title. * A newly created workspace is prepended to the durable registry order. * Different canonical paths may share a display title. - * @param path - Existing directory to own, in any path spelling. + * @param path - Existing directory to own, in a fully qualified path spelling. * @param title - Display title used only when a new record is created. * @returns the existing or newly durable workspace. */ @@ -271,7 +270,7 @@ export class WorkspaceRegistry extends Service { * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned * directory returns `undefined`. - * @param path - Existing directory path in any spelling. + * @param path - Existing directory path in a fully qualified spelling. * @returns the workspace owning the canonical path, when one exists. */ async resolveByPath(path: string): Promise { @@ -287,7 +286,7 @@ export class WorkspaceRegistry extends Service { if (entity.path === canonical) return entity } - const workspaceName = title ?? basename(canonical) + const workspaceName = title ?? defaultWorkspaceTitle(canonical) const table = this.requireTable() const state = this.requireState() const id = WorkspaceId(randomUUID()) @@ -459,7 +458,7 @@ export class WorkspaceRegistry extends Service { const createdAt = new Date(group.newestAt).toISOString() const record: WorkspaceRecord = { path: group.path, - title: basename(group.path), + title: defaultWorkspaceTitle(group.path), sessionIds, createdAt, updatedAt: createdAt, diff --git a/packages/workspace/workspace/src/paths.ts b/packages/workspace/workspace/src/paths.ts index 610ded0e06..9d660471aa 100644 --- a/packages/workspace/workspace/src/paths.ts +++ b/packages/workspace/workspace/src/paths.ts @@ -4,19 +4,54 @@ */ import { realpath } from 'node:fs/promises' +import { posix, win32 } from 'node:path' /** - * Canonicalize a directory path via `fs.realpath`: trailing slashes, `..` - * segments, and symlinks are all resolved. This is the ONE uniqueness canon of - * the package — workspace paths are stored canonicalized, uniqueness is - * string equality of canonicalized paths (a symlink to an existing - * workspace's directory collides), and attach-time session `cwd` checks go - * through the same canon. A path that does not exist rejects with the - * original `ENOENT` — this is `create`'s reject path (a workspace must point - * at an existing directory). + * Check whether a path names one fixed Host location without process cwd or + * current-drive resolution. + * @param path - Candidate Workspace path. + * @param platform - Host platform; injectable for deterministic path tests. + * @returns Whether the path is fully qualified on that platform. + */ +export function fullyQualifiedWorkspacePath( + path: string, + platform: NodeJS.Platform = process.platform, +): boolean { + if (platform !== 'win32') return posix.isAbsolute(path) + const root = win32.parse(path).root + return win32.isAbsolute(path) && root !== '\\' && root !== '/' +} + +/** + * Derive a non-empty default title from a canonical Workspace path. + * @param path - Canonical Workspace path. + * @param platform - Host platform; injectable for deterministic path tests. + * @returns The final segment when present, otherwise the complete root spelling. + */ +export function defaultWorkspaceTitle( + path: string, + platform: NodeJS.Platform = process.platform, +): string { + const pathApi = platform === 'win32' ? win32 : posix + return pathApi.basename(path) || pathApi.parse(path).root +} + +/** + * Canonicalize a fully qualified directory path via `fs.realpath`: trailing + * slashes, `..` segments, and symlinks are all resolved. This is the ONE + * uniqueness canon of the package — workspace paths are stored canonicalized, + * uniqueness is string equality of canonicalized paths (a symlink to an + * existing workspace's directory collides), and attach-time session `cwd` + * checks go through the same canon. Relative paths reject before `realpath` can + * resolve them from the Host cwd or current Windows drive. A path that does not + * exist rejects with the original `ENOENT` — this is `create`'s reject path (a + * workspace must point at an existing directory). * @param path - The path to canonicalize. * @returns the canonical absolute path. */ export async function realpathNormalize(path: string): Promise { + if (!fullyQualifiedWorkspacePath(path)) { + throw new TypeError(`Workspace path is not fully qualified: '${path}'`) + } return await realpath(path) } diff --git a/packages/workspace/workspace/src/types.ts b/packages/workspace/workspace/src/types.ts index 4eee465961..6343d37f57 100644 --- a/packages/workspace/workspace/src/types.ts +++ b/packages/workspace/workspace/src/types.ts @@ -39,7 +39,7 @@ export interface Workspace { */ readonly path: string - /** Display title. Defaults to `basename(path)` at create; duplicates are allowed. */ + /** Display title. Defaults to the final path segment, or a filesystem root's own spelling; duplicates are allowed. */ readonly title: string /** ISO-8601 creation instant, stamped at create and never rewritten. */ diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 74aa50d3f0..1a783b0dbe 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -18,6 +18,7 @@ import WorkspaceRegistry, { WorkspaceOrderInvalidError, } from '../src/index.ts' import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' +import { defaultWorkspaceTitle, fullyQualifiedWorkspacePath } from '../src/paths.ts' const DOMAIN_VERSION = 2 @@ -356,6 +357,24 @@ describe('WorkspaceRegistry lifecycle and bootstrap', () => { }) describe('WorkspaceRegistry create and lookup', () => { + it('accepts fully qualified roots and directories without accepting drive-relative paths', () => { + expect(fullyQualifiedWorkspacePath('C:\\', 'win32')).toBe(true) + expect(fullyQualifiedWorkspacePath('C:\\work', 'win32')).toBe(true) + expect(fullyQualifiedWorkspacePath('\\\\server\\share', 'win32')).toBe(true) + expect(defaultWorkspaceTitle('C:\\', 'win32')).toBe('C:\\') + expect(defaultWorkspaceTitle('C:\\work', 'win32')).toBe('work') + expect(defaultWorkspaceTitle('\\\\server\\share', 'win32')).toBe('share') + expect(fullyQualifiedWorkspacePath('C:', 'win32')).toBe(false) + expect(fullyQualifiedWorkspacePath('C:work', 'win32')).toBe(false) + expect(fullyQualifiedWorkspacePath('\\work', 'win32')).toBe(false) + expect(fullyQualifiedWorkspacePath('.', 'win32')).toBe(false) + expect(fullyQualifiedWorkspacePath('/', 'linux')).toBe(true) + expect(fullyQualifiedWorkspacePath('/work', 'darwin')).toBe(true) + expect(defaultWorkspaceTitle('/', 'linux')).toBe('/') + expect(defaultWorkspaceTitle('/work', 'darwin')).toBe('work') + expect(fullyQualifiedWorkspacePath('work', 'linux')).toBe(false) + }) + it('creates newest-first and idempotently reuses a canonical path without retitling', async () => { const firstDir = await makeDir('first') const secondDir = await makeDir('second') @@ -407,6 +426,14 @@ describe('WorkspaceRegistry create and lookup', () => { expect(registry.list()).toEqual([]) }) + it('rejects a resolvable relative path instead of adopting it from the Host cwd', async () => { + const { registry } = await harness() + const fromHostCwd = '.' + await expect(registry.create(fromHostCwd)).rejects.toThrow(/fully qualified/) + await expect(registry.resolveByPath(fromHostCwd)).rejects.toThrow(/fully qualified/) + expect(registry.list()).toEqual([]) + }) + it('rolls back the provisional cache when the record write fails', async () => { const dir = await makeDir('write-failure') const pool = new MemoryMediaPool() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b84daf02d6..909d7abc2d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -705,6 +705,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection + '@deepseek-ai/dsh-client-file-upload': + specifier: workspace:^ + version: link:../../client/file-upload '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -750,6 +753,9 @@ importers: '@deepseek-ai/dsh-user-questions': specifier: workspace:^ version: link:../../interaction/user-questions + zod: + specifier: ^4.4.3 + version: 4.4.3 packages/api/session-controller: dependencies: @@ -787,9 +793,15 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection + '@deepseek-ai/dsh-client-file-upload': + specifier: workspace:^ + version: link:../../client/file-upload '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../client/store + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference @@ -1471,6 +1483,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection + '@deepseek-ai/dsh-client-file-upload': + specifier: workspace:^ + version: link:../../client/file-upload '@deepseek-ai/dsh-client-hmr': specifier: workspace:^ version: link:../../client/hmr @@ -1729,6 +1744,46 @@ importers: specifier: workspace:^ version: link:../../util/values + packages/client/file-upload: + dependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol + 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-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto + packages/client/hmr: dependencies: '@deepseek-ai/schemastery': @@ -2200,6 +2255,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-client-file-upload': + specifier: workspace:^ + version: link:../file-upload '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -4901,6 +4959,9 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection + '@deepseek-ai/dsh-client-file-upload': + specifier: workspace:^ + version: link:../../client/file-upload '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules @@ -7216,6 +7277,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + fs-ext: + specifier: 2.1.1 + version: 2.1.1 koffi: specifier: ^3.1.0 version: 3.1.1 @@ -7232,6 +7296,9 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../session-persistence + '@types/fs-ext': + specifier: 2.0.3 + version: 2.0.3 packages/session/session-projection: dependencies: @@ -12843,6 +12910,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/fs-ext@2.0.3': + resolution: {integrity: sha512-0j2F+laosJF2NTd2DVheQ5GvXo8ln9L175VwLPfbsppE33iYC+6gn6XlOQS0pGvZm2yrQ32/LRZh0As/7rCs2Q==} + '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} @@ -14122,6 +14192,10 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-ext@2.1.1: + resolution: {integrity: sha512-/TrISPOFhCkbgIRWK9lzscRzwPCu0PqtCcvMc9jsHKBgZGoqA0VzhspVht5Zu8lxaXjIYIBWILHpRotYkCCcQA==} + engines: {node: '>= 8.0.0'} + fs-extra@11.3.1: resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} engines: {node: '>=14.14'} @@ -14969,6 +15043,9 @@ packages: multistream@4.1.0: resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -18503,6 +18580,10 @@ snapshots: '@types/express-serve-static-core': 5.1.3 '@types/serve-static': 2.2.0 + '@types/fs-ext@2.0.3': + dependencies: + '@types/node': 22.20.0 + '@types/geojson@7946.0.16': {} '@types/hast@3.0.5': @@ -19926,6 +20007,10 @@ snapshots: fs-constants@1.0.0: {} + fs-ext@2.1.1: + dependencies: + nan: 2.28.0 + fs-extra@11.3.1: dependencies: graceful-fs: 4.2.11 @@ -20955,6 +21040,8 @@ snapshots: once: 1.4.0 readable-stream: 3.6.2 + nan@2.28.0: {} + nanoid@3.3.12: {} napi-build-utils@2.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 89f38a2eac..e04a413095 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -42,6 +42,9 @@ allowBuilds: node-addon-require-builtin: false # JSONL durability calls MoveFileExW with write-through publication on Windows. koffi: true + # The session write lock is flock(2) / LockFileEx; fs-ext compiles its + # binding with node-gyp at install. + fs-ext: true # The Python runtime deploy includes the reviewed workspace postinstall that # restores the executable bit on node-pty's macOS spawn helper. '@deepseek-ai/dsh-subprocess-local@file:packages/subprocess/subprocess-local': true diff --git a/scripts/coverage-partitions.spec.ts b/scripts/coverage-partitions.spec.ts index 6a1774cff4..e30ccf915a 100644 --- a/scripts/coverage-partitions.spec.ts +++ b/scripts/coverage-partitions.spec.ts @@ -1,4 +1,4 @@ -import { access, mkdir, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises' +import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -22,7 +22,12 @@ import { const passed: CoverageCommandResult = { exitCode: 0, signalCode: null } -afterEach(() => vi.restoreAllMocks()) +/** Every temporary root created by this file, removed after each test. */ +const roots: string[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) +}) async function writeBlob(command: CoverageCommand): Promise { if (command.blobPath === undefined) return @@ -31,7 +36,9 @@ async function writeBlob(command: CoverageCommand): Promise { } async function temporaryRoot(): Promise { - return await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-')) + const root = await mkdtemp(join(tmpdir(), 'dsh-coverage-partitions-')) + roots.push(root) + return root } /** Write a Vitest results cache under a temporary root. */ diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index dd3d8e3e33..a11e187bfb 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -75,6 +75,7 @@ export const SERVICE_PAGE: Record = { deepseekLlmApiExtensions: 'llm-streaming.md', dynamicCordisRunner: 'extensions.md', e2b: 'subprocess.md', + fileUploads: 'attachment.md', fileReferences: 'session-reference.md', fs: 'filesystem.md', goals: 'goal.md', @@ -153,6 +154,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract', launchEnvironment: 'not a service: launcher-provided root accessor value (LaunchEnvironmentSnapshot | undefined) — packages/util/launch-environment/README.md owns this launcher contract', connection: 'interface-typed (HostConnectionHandle); implementing class HostConnectionService is declared in rpc-host.ts — packages/client/connection/README.md owns the API', + fileUpload: 'client-side browser upload service — packages/client/file-upload/README.md owns the API', uiRenderer: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API', uiSession: 'client-side Session source adapter — packages/client/ui-session/README.md owns the API', uiConversation: 'client-side Conversation registries and assembler — packages/client/ui-conversation/README.md owns the API', @@ -343,6 +345,11 @@ export const LINK_MAP: Readonly> = { SessionSummary: 'session.md', SessionUpdateQueueRequest: 'session.md', SessionUpdateQueueValue: 'session.md', + EncodedFileUploadRequest: 'attachment.md', + AgentResolver: 'attachment.md', + PromptFileBinding: 'attachment.md', + FileUploadReceiptId: 'attachment.md', + FileUploadValue: 'attachment.md', SessionStartSource: 'core.md', SessionLogSnapshot: 'session-query.md', SessionSurfaceSnapshot: 'session-query.md', @@ -352,10 +359,18 @@ export const LINK_MAP: Readonly> = { ApprovalRequestEvent: 'approval.md', ApprovalService: 'approval.md', AskUserQuestionRequestEvent: 'user-questions.md', + AdmittedPromptContentPart: 'attachment.md', + AttachmentAdmissionPart: 'attachment.md', + AttachmentError: 'attachment.md', + EncodedFileAttachment: 'attachment.md', EncodedImageAttachment: 'attachment.md', + FileAttachmentRef: 'attachment.md', + SaveFileAttachment: 'attachment.md', + SaveFileStreamAttachment: 'attachment.md', ImageAttachmentAccess: 'llm-streaming.md', ImageAttachmentRef: 'attachment.md', ImageRequestPolicy: 'attachment.md', + PromptContentPart: 'attachment.md', RequestImageAttachment: 'attachment.md', SaveImageAttachment: 'attachment.md', StoredImageAttachment: 'attachment.md', @@ -397,8 +412,10 @@ export const LINK_MAP: Readonly> = { CreateGoalResult: 'goal.md', CommandDefinition: 'commands.md', CommandDescriptor: 'commands.md', + CommandFileReceiptResolver: 'commands.md', CommandId: 'commands.md', CommandResult: 'commands.md', + CommandSubmitAttachment: 'commands.md', CommandSurface: 'commands.md', LspProvider: 'lsp.md', LspQueryRequest: 'lsp.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index d8216809e6..cd74be4272 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -107,6 +107,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['api-session-controller', 'tool-fs', 'llm-pi-ai', 'llm-deepseek'], note: 'The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content.', }, + { + key: 'fileUploads', + pkg: 'client-file-upload', + title: 'Agent-scoped staged file uploads', + mode: 'core', + consumers: ['api-session-controller'], + note: 'Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions.', + }, { key: 'llm', pkg: 'llm', diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 5a03ec41c1..abbd3c6b3b 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -74,6 +74,8 @@ const OVERRIDES: Record = { '@modelcontextprotocol/server-filesystem': { license: 'MIT / Apache-2.0', repo: 'https://github.com/modelcontextprotocol/servers' }, // No repository field in the published manifest. 'node-addon-require-builtin': { repo: 'https://www.npmjs.com/package/node-addon-require-builtin' }, + // No `license` field in the published manifest; the tarball's LICENSE.txt is the MIT text. + 'fs-ext': { license: 'MIT' }, } /** diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 4930c80174..ecbfecc038 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -951,6 +951,21 @@ "symbol": "ImageAttachmentLimits", "source": "packages/attachment/attachment/src/types.ts" }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "PromptContentPart", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "AttachmentAdmissionPart", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "AdmittedPromptContentPart", + "source": "packages/attachment/attachment/src/types.ts" + }, { "doc": "docs/subsystems/attachment.md", "symbol": "EncodedImageAttachment", diff --git a/scripts/verify-client-ui-i18n.ts b/scripts/verify-client-ui-i18n.ts index b348d514ab..fdb0962f04 100644 --- a/scripts/verify-client-ui-i18n.ts +++ b/scripts/verify-client-ui-i18n.ts @@ -35,8 +35,11 @@ const COPY_ATTRIBUTE_SUFFIX = /(?:Aria|Copy|Description|Heading|Label|Message|Pl const COPY_NAME = /(?:^|_)(?:aria|copy|description|empty|heading|label|message|placeholder|summary|text|title|tooltip)(?:s|_.*)?$/i const COPY_SUFFIX = /(?:aria|copy|description|empty|heading|label|labels|message|placeholder|summary|text|title|tooltip|tabs)$/i const IMMUTABLE_LANGUAGE_TOKENS = new Set([ + 'B', 'Function', + 'GB', 'K', + 'KB', 'M', 'MB', 'Symbol', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 565693d35f..f514ba8c7e 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -78,6 +78,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-renderer': { kind: 'none', reason: 'Browser-side render assembly; registers nothing model-facing.' }, 'packages/client/ui-session': { kind: 'none', reason: 'Browser-side Session adapter; registers nothing model-facing.' }, 'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/file-upload': { kind: 'none', reason: 'Browser-side request-body transport; registers nothing model-facing.' }, 'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and forwarded events; selected services own any model-visible effect.' }, 'packages/experimental/agent-team-web-profile': { kind: 'indirect', reason: 'The private Web layer delegates model composition to the Host-side Agent Teams profile.' }, 'packages/experimental/client-ui-agent-team': { kind: 'none', reason: 'The browser projection and task controls register no model-facing input.' }, diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index ed88971715..f32b712343 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -29,6 +29,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Full access"': Full access - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/cordis-tool-round/ui.expected.md b/snapshots/web/cordis-tool-round/ui.expected.md index 264eebfaa3..ccb3a4aab1 100644 --- a/snapshots/web/cordis-tool-round/ui.expected.md +++ b/snapshots/web/cordis-tool-round/ui.expected.md @@ -117,6 +117,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index 01883c3735..d432999e64 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -47,6 +47,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index efb2bc2188..6639f443b0 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -39,6 +39,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index 4ff4b16da8..64fe91e663 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -47,6 +47,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index 139650c4b7..5431636953 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -39,6 +39,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/file-upload-round/replay.override.json b/snapshots/web/file-upload-round/replay.override.json new file mode 100644 index 0000000000..388ed168eb --- /dev/null +++ b/snapshots/web/file-upload-round/replay.override.json @@ -0,0 +1,43 @@ +{ + "patches": [ + { + "at": 0, + "entry": { + "kind": "chunks", + "chunks": [ + { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "call_00_ET_x83IHPfS8ouwILXe8u729956", + "name": "read", + "arguments": "{\"file_path\": \"{{fromRequest:saved at \\\"([^\\\"]+)\\\"}}\"}" + } + }, + { + "type": "usage", + "usage": { + "inputTokens": 8243, + "outputTokens": 147, + "totalTokens": 8390, + "cacheReadTokens": 0, + "reasoningTokens": 0 + } + }, + { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + ] + } + } + ] +} diff --git a/snapshots/web/file-upload-round/session.v2.jsonl b/snapshots/web/file-upload-round/session.v2.jsonl new file mode 100644 index 0000000000..0a2dc6ed92 --- /dev/null +++ b/snapshots/web/file-upload-round/session.v2.jsonl @@ -0,0 +1,22 @@ +{"type":"session","version":2,"id":"{{session:1}}","createdAt":1787887026413,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"standard"} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"model/selection","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","reasoningEffort":"high"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"file","attachment":{"attachmentId":"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60","name":"poem.txt","bytes":16}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69,"name":"reference-1.png"}},{"type":"text","text":"Read the attached file with the read tool, reply with exactly the single word it contains, and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"user/message","data":{"content":[{"type":"file","attachment":{"attachmentId":"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60","name":"poem.txt","bytes":16}},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69,"name":"reference-1.png"}},{"type":"text","text":"Read the attached file with the read tool, reply with exactly the single word it contains, and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Read the attached file with","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp","contextWindow":1000000}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:3}}"},"usage":{"inputTokens":8243,"outputTokens":147,"totalTokens":8390,"cacheReadTokens":0,"reasoningTokens":0},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":8243,"outputTokens":147,"totalTokens":8390,"cacheReadTokens":0,"reasoningTokens":0}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_ET_x83IHPfS8ouwILXe8u729956","name":"read","arguments":"{\"file_path\": \"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_ET_x83IHPfS8ouwILXe8u729956"},"content":[{"type":"tool-result","toolCallId":"call_00_ET_x83IHPfS8ouwILXe8u729956","content":[{"type":"text","text":"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt\nfile\n\n1: UPLOAD_ROUND_OK\n\n(End of file - total 1 lines)\n"}],"isError":false}],"role":"user","id":"{{message:4}}"},"meta":{"path":"{{harnessHome}}/attachments/v1/files/29/29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60/poem.txt","offset":1,"lines":[{"number":1,"text":"UPLOAD_ROUND_OK"}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UPLOAD_ROUND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"{{message:5}}"},"usage":{"inputTokens":230,"outputTokens":8,"totalTokens":8942,"cacheReadTokens":8704,"reasoningTokens":0},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[17,17,15,17,15,17],"texts":["U","PL","O","AD","_RO","UND","_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UPLOAD_ROUND_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":230,"outputTokens":8,"totalTokens":8942,"cacheReadTokens":8704,"reasoningTokens":0}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/file-upload-round/snapshot.yml b/snapshots/web/file-upload-round/snapshot.yml new file mode 100644 index 0000000000..b0e3a4a6e3 --- /dev/null +++ b/snapshots/web/file-upload-round/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: file-upload-round +profile: web +composition: web-default +recording: live +header: + class: web-default +replay: + override: true diff --git a/snapshots/web/file-upload-round/trajectory.expected.md b/snapshots/web/file-upload-round/trajectory.expected.md new file mode 100644 index 0000000000..54cde508ec --- /dev/null +++ b/snapshots/web/file-upload-round/trajectory.expected.md @@ -0,0 +1,3 @@ +- row "USER, Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop.": + - cell "Turn 1 USER": USER + - cell "Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop." diff --git a/snapshots/web/file-upload-round/ui.expected.md b/snapshots/web/file-upload-round/ui.expected.md new file mode 100644 index 0000000000..2541ec1c4a --- /dev/null +++ b/snapshots/web/file-upload-round/ui.expected.md @@ -0,0 +1,52 @@ +- banner: + - navigation "Session hierarchy": + - button "Read the attached file with" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt +- text: poem.txt TXT 16B +- button "reference-1.png, click to view original": + - img "reference-1.png" +- text: Read the attached file with the read tool, reply with exactly the single word it contains, and stop. {{clock}} +- button "Copy": + - img +- button "1 tool call": + - text: 1 tool call + - img +- paragraph: UPLOAD_ROUND_OK +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- button "Usage 17.3K tok": + - img + - text: Usage 17.3K tok +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} +- textbox "Message or run a task... / commands, @ files or sessions" +- button "Commands": + - img +- button "Add attachment": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash-Vision-Exp, reasoning effort high": + - text: DeepSeek-V4-Flash-Vision-Exp high + - img +- button "1% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 51% Input 17.2K tok · Output 155 tok diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index efdf765b06..d20f524749 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -51,6 +51,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 155f94151c..33eb60503c 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index 8f434cbd5b..a36083e5fc 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -229,6 +229,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index 6458d97ec7..2cfec96b5d 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -117,6 +117,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md index 8008769d64..77dab589b9 100644 --- a/snapshots/web/lifecycle-chrome/hero.expected.md +++ b/snapshots/web/lifecycle-chrome/hero.expected.md @@ -33,6 +33,8 @@ - paragraph - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/lifecycle-chrome/plan-active.expected.md b/snapshots/web/lifecycle-chrome/plan-active.expected.md index 7eb9ecc43d..c991125908 100644 --- a/snapshots/web/lifecycle-chrome/plan-active.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active.expected.md @@ -32,6 +32,8 @@ - textbox "Describe what you want to build, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Plan mode on, press to turn off": Plan - button "Select model, current DeepSeek-V4-Flash": diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index d5e2baa73d..0a46f8db2d 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -43,6 +43,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index 02668c7ba4..afa563c994 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/cancel-expanded.expected.md b/snapshots/web/live-interactions/cancel-expanded.expected.md index 9e53fe0ffe..a0310ed0eb 100644 --- a/snapshots/web/live-interactions/cancel-expanded.expected.md +++ b/snapshots/web/live-interactions/cancel-expanded.expected.md @@ -40,6 +40,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index 300d0545a5..1ba13db840 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -36,6 +36,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md index b4f2894bbd..6519010337 100644 --- a/snapshots/web/live-interactions/error-auth.expected.md +++ b/snapshots/web/live-interactions/error-auth.expected.md @@ -26,6 +26,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index ec8e57263a..b9dcae1a7a 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -25,6 +25,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md index 74f3dcd3b3..9043e61bfb 100644 --- a/snapshots/web/live-interactions/retry-exhausted.expected.md +++ b/snapshots/web/live-interactions/retry-exhausted.expected.md @@ -28,6 +28,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index 16d9761a90..e858359ac4 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -45,6 +45,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index e729aa627f..44b7ce4879 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index 909214d76c..a3b47457ca 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -26,6 +26,8 @@ - paragraph: Queue this follow-up while the current turn is running. - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index 20a6119243..7fd215050d 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -69,6 +69,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index 90e013e31f..3d08bbcfee 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -40,6 +40,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 73ee4e10ae..a6caedf876 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -61,6 +61,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index 981b606fb1..94a14902f3 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -41,6 +41,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md index 6cbb8be04b..a8c59b89c1 100644 --- a/snapshots/web/ptc-round/ui.expected.md +++ b/snapshots/web/ptc-round/ui.expected.md @@ -56,6 +56,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index 90e2fd340e..6618d36d68 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -53,6 +53,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index 5c01a40d89..3a5306b5f6 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md index 147ebabd2f..a2ede1b5ae 100644 --- a/snapshots/web/question-composer/cancelled.expected.md +++ b/snapshots/web/question-composer/cancelled.expected.md @@ -32,6 +32,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/collapsed.expected.md b/snapshots/web/queue-actions/collapsed.expected.md index 9ce8b0be96..0b3e7b65b0 100644 --- a/snapshots/web/queue-actions/collapsed.expected.md +++ b/snapshots/web/queue-actions/collapsed.expected.md @@ -26,6 +26,8 @@ - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/editing.expected.md b/snapshots/web/queue-actions/editing.expected.md index 4c64c771f0..f32a37c548 100644 --- a/snapshots/web/queue-actions/editing.expected.md +++ b/snapshots/web/queue-actions/editing.expected.md @@ -42,6 +42,8 @@ - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/layout.expected.md b/snapshots/web/queue-actions/layout.expected.md index 666cb935b7..0e0835f2bb 100644 --- a/snapshots/web/queue-actions/layout.expected.md +++ b/snapshots/web/queue-actions/layout.expected.md @@ -42,6 +42,8 @@ - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/preserved-expanded.expected.md b/snapshots/web/queue-actions/preserved-expanded.expected.md index 58159d8fa8..d266a7b506 100644 --- a/snapshots/web/queue-actions/preserved-expanded.expected.md +++ b/snapshots/web/queue-actions/preserved-expanded.expected.md @@ -59,6 +59,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index acccb8ba21..1e031dda80 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -55,6 +55,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queue-actions/ui.expected.md b/snapshots/web/queue-actions/ui.expected.md index 48c85e44f4..e314229b31 100644 --- a/snapshots/web/queue-actions/ui.expected.md +++ b/snapshots/web/queue-actions/ui.expected.md @@ -35,6 +35,8 @@ - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md index 4abc5d808d..d4596fdbde 100644 --- a/snapshots/web/queued-image/delivered.expected.md +++ b/snapshots/web/queued-image/delivered.expected.md @@ -79,6 +79,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/queued-image/queued.expected.md b/snapshots/web/queued-image/queued.expected.md index 462b6d03d1..457affb944 100644 --- a/snapshots/web/queued-image/queued.expected.md +++ b/snapshots/web/queued-image/queued.expected.md @@ -35,6 +35,8 @@ - textbox "Cmd/Ctrl+Enter steers all queued messages" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 7f52c24bbc..f4aa43adab 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -61,6 +61,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index 1724e6fa96..5f5e3424e1 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -65,6 +65,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Read Only"': Read Only - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index 2f67dbd50d..ed96e5de1d 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -59,6 +59,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index 6778257a55..cda9e9baa9 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -41,6 +41,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index 48b96721cc..1a1a06acaa 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -54,6 +54,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Full access"': Full access - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index 16c7f00fd7..87b3b151b2 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -46,6 +46,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index e62b8ab4d4..18502b9d35 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -38,6 +38,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index 2a0549a1d7..4e238d019d 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -47,12 +47,7 @@ - button "Ran for {{duration}}": - img - text: Ran for {{duration}} -- text: {{clock}} -- button "System prompt": - - img - - img - - text: System prompt -- text: Now give the same explanation to a human reader. {{clock}} +- text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy": - img - button "Thought for a while" [expanded]: @@ -78,6 +73,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment" [disabled]: + - img - 'button "Access mode, current: Custom"': Custom - button "6% of context used" - button "Send message" [disabled] diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index 17e68fb4ed..f1dca1801c 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -39,12 +39,7 @@ - button "Ran for {{duration}}": - img - text: Ran for {{duration}} -- text: {{clock}} -- button "System prompt": - - img - - img - - text: System prompt -- text: Now give the same explanation to a human reader. {{clock}} +- text: {{clock}} Now give the same explanation to a human reader. {{clock}} - button "Copy": - img - button "Thought for a while": @@ -66,6 +61,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment" [disabled]: + - img - 'button "Access mode, current: Custom"': Custom - button "6% of context used" - button "Send message" [disabled] diff --git a/snapshots/web/subagent-interrupt/offline-composer.expected.md b/snapshots/web/subagent-interrupt/offline-composer.expected.md index 4455ad4ad6..7c00b9e2c8 100644 --- a/snapshots/web/subagent-interrupt/offline-composer.expected.md +++ b/snapshots/web/subagent-interrupt/offline-composer.expected.md @@ -27,6 +27,8 @@ - textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled] - button "Commands" [disabled]: - img +- button "Add attachment" [disabled]: + - img - 'button "Access mode, current: Custom" [disabled]': Custom - button "Stop generating" - button "Send message" [disabled] diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index 95f6f440f5..b95e74379a 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -38,6 +38,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index 2ddeaa1c4d..0504850e5c 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -51,6 +51,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index 90a45f8028..ccaf11a4c4 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -35,6 +35,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index bfed5ecb1a..e668975ca6 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -37,6 +37,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index 4c8f84e2bd..831043f7f3 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -38,6 +38,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index 8b2f000f9a..550cf32295 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -43,6 +43,8 @@ - textbox "Message or run a task, run / commands, @ files or sessions" - button "Commands": - img +- button "Add attachment": + - img - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/tsconfig.base.json b/tsconfig.base.json index 4f7c66624a..e0749acbb4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -158,6 +158,8 @@ "@deepseek-ai/dsh-client-ui-session": ["./packages/client/ui-session/src"], "@deepseek-ai/dsh-client-ui-session/client": ["./packages/client/ui-session/src/client"], "@deepseek-ai/dsh-client-connection": ["./packages/client/connection/src"], + "@deepseek-ai/dsh-client-file-upload": ["./packages/client/file-upload/src"], + "@deepseek-ai/dsh-client-file-upload/client": ["./packages/client/file-upload/src/client/index.ts"], "@deepseek-ai/dsh-api-remotes": ["./packages/api/remotes/src"], "@deepseek-ai/dsh-api-remotes/client": ["./packages/api/remotes/src/client/index.ts"], "@deepseek-ai/dsh-client-hmr": ["./packages/client/hmr/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 2634de2cdd..c186cfbc47 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -54,6 +54,7 @@ { "path": "./packages/experimental/inspector/tsconfig.client.json" }, { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection/tsconfig.client.json" }, + { "path": "./packages/client/file-upload/tsconfig.client.json" }, { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/session-controller/tsconfig.client.json" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 8ac855ae76..85ae2dac00 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -51,6 +51,7 @@ "apps/web/tests/composer-draft-scroll.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", "apps/web/tests/web-search-round.e2e.ts", + "apps/web/tests/file-upload-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", "apps/web/tests/message-feedback.e2e.ts", "apps/web/tests/message-feedback-layout.e2e.ts", @@ -60,6 +61,7 @@ "apps/web/tests/math-rendering.e2e.ts", "apps/web/tests/markdown-cjk-strong.e2e.ts", "apps/web/tests/markdown-inline-code-links.e2e.ts", + "apps/web/tests/clickable-links-gallery.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/queue-image.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", @@ -143,6 +145,7 @@ { "path": "./packages/util/atomic-write" }, { "path": "./packages/attachment/attachment" }, { "path": "./packages/attachment/attachment-local" }, + { "path": "./packages/client/file-upload/tsconfig.host.json" }, { "path": "./packages/llm/llm" }, { "path": "./packages/llm/deepseek-llm-api-extensions" }, { "path": "./packages/llm/token-meter" }, diff --git a/vitest.config.ts b/vitest.config.ts index 380eb6ad6d..c409a80975 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -93,7 +93,15 @@ const windowsOnlyCoverageExclusions = process.platform !== 'win32' // never measures child processes. Its behavior is pinned end-to-end by // tests/runner.spec.ts, which spawns the real entry through tsx. const windowsRunnerCoverageExclusions = process.platform === 'win32' - ? ['packages/sandbox/sandbox-windows-acl/src/runner.ts'] + ? [ + 'packages/sandbox/sandbox-windows-acl/src/runner.ts', + // The session write lock's POSIX face (fs-ext flock plus inode + // verification) executes only off-Windows: the Linux lanes hold its + // per-file 100%, while the Windows branch is unit-pinned by + // win32.spec's injected bindings and exercised natively by every + // Windows suite through the real backend. + 'packages/session/session-persistence-jsonl/src/lease.ts', + ] : [] // pwsh-local's run/start/lifecycle suites self-skip without a real pwsh