Merge remote-tracking branch 'origin/master' into worktree/process-plugin-streaming-72f9b3

# Conflicts:
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	packages/api/remotes/package.json
#	packages/api/remotes/src/client/index.ts
#	packages/api/session-controller/README.i18n.yaml
#	packages/api/session-controller/README.md
#	packages/api/session-controller/README.zh.md
#	packages/api/session-controller/tsconfig.host.json
#	packages/client/ui-approval/src/client/contract/slots.ts
#	packages/client/ui-chat/src/client/chat/MessageItem.tsx
#	packages/client/ui-chat/src/client/conversation-nodes/assistant.ts
#	packages/client/ui-chat/src/client/conversation-nodes/partial.ts
#	packages/client/ui-chat/src/client/conversation-nodes/tool.ts
#	packages/client/ui-chat/src/client/model/tool-call-tree.ts
This commit is contained in:
Yichen Jiang committed 2026-09-04 17:28:12 +08:00
commit d3b1f2d1bc
461 files changed
+12176 -1942

No files matched your search

@@ -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
@@ -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 `<skill>` serialization); on the bubble side MessageItem decorates both shapes (the legacy `<skill>` 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 `<skill>` 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
@@ -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));编辑破坏匹配形状时实体还原为普通文本。
- 发送即原文(不再 `<skill>` 序列化);气泡侧 MessageItem 双形状装饰(legacy `<skill>` 标签 + 纯文本 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));编辑破坏匹配形状时实体还原为普通文本。
- 发送即原文(不再 `<skill>` 序列化);气泡侧 `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,无需菜单交互或无关重渲染。
### 每会话供数贡献与键盘私面
@@ -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
@@ -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 "<path>": 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.
@@ -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 "<path>": 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` 录制会话携带同一条诊断用于重放。
@@ -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
@@ -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.
@@ -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 成为持久的运行时状态,并重新引入最终输出与流式输出之间的模式边界。解析仍留在呈现层的叶节点中。
@@ -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
@@ -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.
@@ -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` 重试,并保留任何并发创建者写入的文件。
@@ -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
@@ -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.
@@ -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 的行为。精确执行语义保持不变。
@@ -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 <message>` 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".
@@ -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 <message>` 把图片并入其 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 下无害,但并非「未写入任何对象」。
@@ -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
@@ -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/<digest-prefix>/<digest>`; each read-only model-facing path at `DSH_HOME/attachments/v1/files/<digest-prefix>/<digest>/<name>` 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<Uint8Array>`; 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.
@@ -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/<digest-prefix>/<digest>` 下只有一个规范对象;每条模型可见的只读路径 `DSH_HOME/attachments/v1/files/<digest-prefix>/<digest>/<name>` 都是硬链接,因此不同显示名称不会复制内容。该队列可以配置,默认同时运行两项传输。独立的双端 `file-upload` Cordis 包拥有传输与暂存。其 Client `fileUpload` 服务同时接收所属 Session 标识与 `Blob`、精确字节或只能使用一次的 `ReadableStream<Uint8Array>`;它使用该标识组装原始路由请求,或直接调用生成的 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` 快照记录浏览器上传、模型读取文件与答案呈现。
@@ -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/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
@@ -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.
@@ -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 上跳过:那里的锁是任何文件操作都无法放弃的内核对象。
@@ -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
@@ -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.
@@ -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` 地球图形;若将来需要独立的邮件类别,一行即可加上。
+2
View File
@@ -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 |
@@ -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)
+7
View File
@@ -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: () => {} })
})
}
+6 -15
View File
@@ -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({
@@ -147,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')
@@ -197,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.
@@ -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:[email protected]'
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<string, unknown>
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 <path>/<type>/<content> the row falls back to the generic card.
result: [
'<path>docs/guide.md</path>',
'<type>file</type>',
'<content>',
...Array.from({ length: 12 }, (_, index) =>
index === 0 ? '# Link style guide' : `guide line ${String(index + 1)}`),
'</content>',
].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<typeof watchConsole>
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<Page['locator']>, property: string): Promise<string> =>
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)
})
@@ -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<void> {
},
})
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)
@@ -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}}
@@ -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
@@ -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
@@ -51,6 +51,8 @@
- textbox "Message or run a task... / 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
@@ -43,6 +43,8 @@
- textbox "Message or run a task... / 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
@@ -17,6 +17,8 @@
- 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
@@ -51,6 +51,8 @@
- 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
@@ -30,6 +30,8 @@
- 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
@@ -42,6 +42,8 @@
- 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
@@ -46,6 +46,8 @@
- 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
@@ -16,6 +16,8 @@
- 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
@@ -0,0 +1,3 @@
- listbox "Trigger suggestions":
- text: Skills
- option "policy-user-only user-only · Available only to user invocation" [selected]
@@ -43,6 +43,8 @@
- 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
@@ -35,6 +35,8 @@
- 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
@@ -462,6 +462,8 @@
- 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
@@ -34,6 +34,8 @@
- 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
@@ -53,6 +53,8 @@
- 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
@@ -41,6 +41,8 @@
- 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
+283
View File
@@ -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<typeof watchConsole>
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<HTMLElement>('[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<SessionEvent, { type: 'user/message' }> =>
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<SessionEvent, { type: 'tool/call' }> =>
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<SessionEvent, { type: 'tool/result' }> =>
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',
])
})
})
@@ -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)
+12 -16
View File
@@ -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()
})
+46 -12
View File
@@ -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<We
replayFixture,
mode,
`http://${browserHost}:${port}`,
harnessHome,
)
} catch (error) {
failures.push(error)
@@ -953,6 +965,7 @@ function stableSessionFixture(
session: Session,
existing: string,
workspaceCwd: string,
harnessHome: string,
): string {
const prepared = prepareSessionSnapshotFixtureForComparison(
normalizeWebSessionVolatiles(rawSessionLog(session), workspaceCwd),
@@ -965,6 +978,7 @@ function stableSessionFixture(
})
const fresh = scrubSessionSnapshot(stabilized)
.split(session.id).join('{{sessionId}}')
.split(harnessHome).join('{{harnessHome}}')
const stable = redactSessionSnapshotIds(stabilizeFixtureMessageIds([fresh], [existing]))[0]
if (stable === undefined) throw new Error('session harvest produced no stabilized fixture')
return stable
@@ -975,6 +989,7 @@ async function assertReplaySession(
fixturePath: string,
mode: WebSnapshotMode,
webUrl: string,
harnessHome: string,
): Promise<void> {
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<string> {
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
+11 -2
View File
@@ -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'])
})
})
+4
View File
@@ -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()
@@ -27,6 +27,8 @@
- 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 streaming-fence-highlight-test/streaming-fence":
- text: streaming-fence-highlight-test/streaming-fence
+2 -2
View File
@@ -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
+2
View File
@@ -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",
+2 -2
View File
@@ -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: 30b3a2f8fdbb66410ada6ab27551d6018a1ac8eb
capability-seams.zh.md: 834131588213627cefd4a6ea9c69d67f5d345ba7
capability-seams.md: 7e49d8a9846f3b9a4804e041306f8bde36b72757
capability-seams.zh.md: 0107cb210df5c2bc609bfeae00e4021dc970e53b
+5
View File
@@ -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<br/>Agent-scoped staged file uploads"]
pkg_llm["llm"]
svc_llm["ctx.llm<br/>LLM adapter registry"]
pkg_llm_replay["llm-replay"]
@@ -238,6 +240,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
@@ -367,6 +370,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
@@ -471,6 +475,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. |
+5
View File
@@ -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<br/>Agent-scoped staged file uploads"]
pkg_llm["llm"]
svc_llm["ctx.llm<br/>LLM adapter registry"]
pkg_llm_replay["llm-replay"]
@@ -240,6 +242,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
@@ -369,6 +372,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
@@ -473,6 +477,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) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 |
+2 -2
View File
@@ -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: 909d352ff570a24d7f727bac7d9526d43583012e
config-catalog.zh.md: cd31422cb063f15227d0e49572644d5def0b7c06
config-catalog.md: a4bd7a41a81300791427b8b923fe1463ce44404d
config-catalog.zh.md: cde6204dc7dd48fe92f8681f4cfc5efeb905523e
+6 -5
View File
@@ -221,7 +221,7 @@ Source: [`packages/api/job-controller/src/index.ts:33`](../packages/api/job-cont
## `@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. */
@@ -231,7 +231,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)
<a id="deepseek-aidsh-api-settings-controller"></a>
@@ -280,7 +280,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)
<a id="deepseek-aidsh-bash-local"></a>
@@ -354,7 +354,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)
<a id="deepseek-aidsh-client-hmr"></a>
@@ -1870,7 +1870,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)
<a id="deepseek-aidsh-session-projection-cache"></a>
@@ -3353,6 +3353,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))
+5 -4
View File
@@ -225,7 +225,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. */
@@ -235,7 +235,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)
<a id="deepseek-aidsh-api-settings-controller"></a>
@@ -284,7 +284,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)
<a id="deepseek-aidsh-bash-local"></a>
@@ -358,7 +358,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)
<a id="deepseek-aidsh-client-hmr"></a>
@@ -3357,6 +3357,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))
+2 -2
View File
@@ -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: 72ba909afefac13a4b07afb96969638d8dff2ff3
event-producer-consumer.zh.md: 277ea7ba47f0a16934a28e8ed29b0a9536a51829
event-producer-consumer.md: 9cf6a0a168032f929b8f314009a706c1e412fe62
event-producer-consumer.zh.md: 05eb389475b2de550f9353c3ec151e7473373d77
+9 -9
View File
@@ -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:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:547`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:574`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:553`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:560`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:552`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:565`](../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) |
+9 -9
View File
@@ -24,14 +24,14 @@
| `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:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:547`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:574`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:553`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:560`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:552`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:565`](../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` |
@@ -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) |
+2 -2
View File
@@ -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: 5bd3fe46ad6ac22e1248a6452919650d8fdf4a5f
module-graph.zh.md: 08f8aae97afc58cd05a0f152b847aafe02e5ff83
module-graph.md: 586a4147f95c77d5af85eec2e5d75d937f7e151f
module-graph.zh.md: 010c9d53c1ff0ddbaa318d6c0261e9faa7c49b0a
+6 -1
View File
@@ -133,6 +133,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"]
@@ -408,6 +409,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
@@ -1053,6 +1055,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_llm
pkg_api_session_controller --> pkg_native_command
@@ -1262,6 +1266,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) |
@@ -1392,7 +1397,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), [`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), [`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) |
+6 -1
View File
@@ -135,6 +135,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"]
@@ -410,6 +411,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
@@ -1055,6 +1057,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_llm
pkg_api_session_controller --> pkg_native_command
@@ -1264,6 +1268,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) |
@@ -1394,7 +1399,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), [`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), [`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) |
+2 -2
View File
@@ -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: fb3c5cad0e488ccab67f08722345831d68fe9a7a
persistence-catalog.zh.md: 9fe32748fc41959cc9151a4c99ea436428f5638e
persistence-catalog.md: 955fb1246ce0e46c39f20af2b928508eff0841b2
persistence-catalog.zh.md: 2711fa8cb229e37dc159c4d7b46ecaf52bd996f3
+2 -2
View File
@@ -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)
<a id="commandrun--log-only"></a>
@@ -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/*`
+2 -2
View File
@@ -270,7 +270,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
}
```
来源:[`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)
<a id="commandrun--log-only"></a>
@@ -290,7 +290,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
'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/*`
+2 -2
View File
@@ -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
+157 -4
View File
@@ -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 `<DSH_HOME>/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 `<DSH_HOME>/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.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -157,6 +189,30 @@ abstract validateImage(input: SaveImageAttachment): Promise<void>
*/
async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]>
/**
* 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<AdmittedPromptContentPart[]>
/**
* 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<FileAttachmentRef>
/**
* 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<Store
*/
imageHostPath(ref: ImageAttachmentRef): string | 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<FileAttachmentRef>
/**
* 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<FileAttachmentRef>
/**
* 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<Uint8Array>
/**
* 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)
<a id="ctxfileuploads--fileuploads"></a>
### `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<FileUploadValue>
/**
* 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<Uint8Array> readonly signal?: AbortSignal readonly name?: string }): Promise<FileUploadValue>
/**
* 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)
<!-- END GENERATED cordis-surface -->
+157 -4
View File
@@ -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。
未发送的浏览器草稿可以保留在内存中,原生客户端也可以将其暂存于操作系统临时存储。宿主接受用户消息后,会先把消息中的图片移到 `<DSH_HOME>/attachments/v1` 下,再追加用户事件。结构化模型图片输出遵循同样的先持久化、后追加事件规则。
未发送的浏览器草稿可以保留在内存中,原生客户端也可以将其暂存于操作系统临时存储。浏览器通用文件取得暂存 prompt 凭证前会完成持久化。宿主接受用户消息后,会先把消息中的图片移到 `<DSH_HOME>/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 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,不与单个会话的删除绑定。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -157,6 +189,30 @@ abstract validateImage(input: SaveImageAttachment): Promise<void>
*/
async saveImages(inputs: readonly SaveImageAttachment[]): Promise<readonly ImageAttachmentRef[]>
/**
* 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<AdmittedPromptContentPart[]>
/**
* 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<FileAttachmentRef>
/**
* 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<Store
*/
imageHostPath(ref: ImageAttachmentRef): string | 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<FileAttachmentRef>
/**
* 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<FileAttachmentRef>
/**
* 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<Uint8Array>
/**
* 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)
<a id="ctxfileuploads--fileuploads"></a>
### `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<FileUploadValue>
/**
* 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<Uint8Array> readonly signal?: AbortSignal readonly name?: string }): Promise<FileUploadValue>
/**
* 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)
<!-- END GENERATED cordis-surface -->
+2 -2
View File
@@ -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
+24 -15
View File
@@ -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<CommandExecution | undefined>
@Remote async execute( agent: Agent, line: string, submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise<CommandExecution | undefined>
```
Types: [Agent](core.md) · [EncodedImageAttachment](attachment.md)
Types: [Agent](core.md)
Source: [`packages/interaction/commands/src/index.ts`](../../packages/interaction/commands/src/index.ts)
+24 -15
View File
@@ -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<CommandExecution | undefined>
@Remote async execute( agent: Agent, line: string, submittedAttachments: readonly CommandSubmitAttachment[], signal: AbortSignal, ): Promise<CommandExecution | undefined>
```
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)
+2 -2
View File
@@ -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
+12 -1
View File
@@ -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<Prepared
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
```
Types: [FileAttachmentRef](attachment.md)
Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts)
<a id="llm-events"></a>
+12 -1
View File
@@ -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<Prepared
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
```
Types: [FileAttachmentRef](attachment.zh.md)
Source: [`packages/llm/llm/src/index.ts`](../../packages/llm/llm/src/index.ts)
<a id="llm-events"></a>
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
/**
+1 -1
View File
@@ -8,7 +8,7 @@
## `SessionHandle`——通向已存储会话的一条打开通道
每一次日志读写都经由句柄流动,绝不经由按 id 寻址的服务方法:句柄是未来跨进程写租约将要把守的那扇唯一的门。一种句柄类型同时服务两种访问——在 `read` 句柄上执行修改是运行时的 `SessionReadOnlyError`,而非类型层面的拆分——而进程内单写者所有权使得在已有活跃持有者时第二次 `open(id, 'write')` 以 `SessionAlreadyOwnedError` 拒绝。
每一次日志读写都经由句柄流动,绝不经由按 id 寻址的服务方法:句柄是跨进程写租约把守的那扇唯一的门。一种句柄类型同时服务两种访问——在 `read` 句柄上执行修改是运行时的 `SessionReadOnlyError`,而非类型层面的拆分——而进程内单写者所有权使得在已有活跃持有者时第二次 `open(id, 'write')` 以 `SessionAlreadyOwnedError` 拒绝。
```ts type-equiv
/**
+2 -2
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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))。
## 变更系统
+3 -1
View File
@@ -67,6 +67,7 @@
"@deepseek-ai/dsh-api-settings-controller": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-file-upload": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
@@ -81,6 +82,7 @@
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-questions": "workspace:^"
"@deepseek-ai/dsh-user-questions": "workspace:^",
"zod": "^4.4.3"
}
}
+3 -1
View File
@@ -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'
@@ -25,6 +26,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'
@@ -147,7 +149,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
try {
for (const contribution of [
agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote,
pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
pluginInventoryRemote, messageFeedbackRemote, fileUploadsRemote, sessionReferencesRemote,
subagentsRemote, sessionRemote, jobRemote, workspaceRemote,
]) {
disposers.push(await ctx.remote.$mount(contribution))
@@ -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)
})
}
@@ -20,6 +20,9 @@
{
"path": "../../client/connection/tsconfig.client.json"
},
{
"path": "../../client/file-upload/tsconfig.client.json"
},
{
"path": "../../credentials/credentials"
},
@@ -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: 6fd6736aafe9a653bbe574d5ff54a498e3c39060
README.zh.md: 462c7268f2284f5cf3bab8a892a57e0dd6d936fd
README.md: e62aef6aeabd3b84cb942ebd92d2d42eabdfe973
README.zh.md: d761a45b9c7b34b5b0ff55a229d14fb019d15bfa
+5 -4
View File
@@ -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,13 +25,13 @@ 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.
Background-job rows and observation streams belong to [`dsh-api-job-controller`](../job-controller/README.md); the control stream carries queues and projections only.
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.
-----
@@ -60,6 +60,7 @@ No direct effect; model requests remain owned by the Agent and LLM packages.
<a id="known-limitations-and-deferred-work"></a>
- 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.
+5 -4
View File
@@ -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,13 +25,13 @@ 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 与历史操作,不提供文件传输。
后台 job 的名册行与观测流属于 [`dsh-api-job-controller`](../job-controller/README.zh.md);控制流只承载队列与投影。
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 重建会话。
-----
@@ -60,6 +60,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
<a id="known-limitations-and-deferred-work"></a>
- follow 恢复失败会对调用方可见,而不会无限重试。
- 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第一个字节重新传输整个文件。
- 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。
@@ -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-llm": "workspace:^",
"@deepseek-ai/dsh-native-command": "workspace:^",
@@ -115,6 +117,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-llm": "workspace:^",
@@ -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
}
@@ -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. */
@@ -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',
Loaded 100 of 461 files, more files were not shown because too many files have changed in this diff. Show more