Merge remote-tracking branch 'origin/master' into feat/electron
# Conflicts: # packages/client/connection/README.i18n.yaml # packages/client/connection/README.md # packages/client/connection/README.zh.md
This commit is contained in:
commit
75c7bbb88d
400 files changed
+9873
-1808
No files matched your search
+2
-2
@@ -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
|
||||
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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,无需菜单交互或无关重渲染。
|
||||
|
||||
### 每会话供数贡献与键盘私面
|
||||
|
||||
@@ -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
-2
@@ -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
-4
@@ -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 下无害,但并非「未写入任何对象」。
|
||||
+3
-3
@@ -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-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` 快照记录浏览器上传、模型读取文件与答案呈现。
|
||||
@@ -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: () => {} })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/capability-seams.md
|
||||
capability-seams.md: 83868afe952c5dbc179114ff52228fc1dc8b8fdb
|
||||
capability-seams.zh.md: 064797bbc3e5d71c0bdb0b2afe09560577a1b029
|
||||
capability-seams.md: ecc88e397f41c4d279effa2df9d1b29108756ea5
|
||||
capability-seams.zh.md: 105752ac01007d24788bcfbcb2eb22c07cfdd0f5
|
||||
@@ -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"]
|
||||
@@ -235,6 +237,7 @@ flowchart LR
|
||||
pkg_authorization --> svc_authorization
|
||||
pkg_bash_local --> svc_shell
|
||||
pkg_bash_sandbox --> svc_shell
|
||||
pkg_client_file_upload --> svc_fileUploads
|
||||
pkg_client_modules --> svc_clientModules
|
||||
pkg_code_runtime --> svc_codeRuntime
|
||||
pkg_code_runtime_worker_thread --> svc_codeRuntime
|
||||
@@ -364,6 +367,7 @@ flowchart LR
|
||||
svc_e2b --> pkg_fs_e2b
|
||||
svc_e2b --> pkg_subprocess_e2b
|
||||
svc_fileReferences --> pkg_api_session_controller
|
||||
svc_fileUploads --> pkg_api_session_controller
|
||||
svc_fs --> pkg_tool_fs
|
||||
svc_invariants --> pkg_agent
|
||||
svc_invariants --> pkg_agent_loop
|
||||
@@ -466,6 +470,7 @@ flowchart LR
|
||||
| ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. |
|
||||
| `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | Owns streaming intake, durable storage, and staged receipt lifetime; the Session controller binds receipts to accepted submissions. |
|
||||
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. |
|
||||
| `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Plugins prepare independent top-level fields; the official adapter merges them and commits their delivery state after HTTP acceptance. |
|
||||
| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. |
|
||||
|
||||
@@ -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"]
|
||||
@@ -237,6 +239,7 @@ flowchart LR
|
||||
pkg_authorization --> svc_authorization
|
||||
pkg_bash_local --> svc_shell
|
||||
pkg_bash_sandbox --> svc_shell
|
||||
pkg_client_file_upload --> svc_fileUploads
|
||||
pkg_client_modules --> svc_clientModules
|
||||
pkg_code_runtime --> svc_codeRuntime
|
||||
pkg_code_runtime_worker_thread --> svc_codeRuntime
|
||||
@@ -366,6 +369,7 @@ flowchart LR
|
||||
svc_e2b --> pkg_fs_e2b
|
||||
svc_e2b --> pkg_subprocess_e2b
|
||||
svc_fileReferences --> pkg_api_session_controller
|
||||
svc_fileUploads --> pkg_api_session_controller
|
||||
svc_fs --> pkg_tool_fs
|
||||
svc_invariants --> pkg_agent
|
||||
svc_invariants --> pkg_agent_loop
|
||||
@@ -468,6 +472,7 @@ flowchart LR
|
||||
| ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 |
|
||||
| `ctx.fileUploads` | `core` | [`client-file-upload`](../packages/client/file-upload) | - | [`api-session-controller`](../packages/api/session-controller) | - | 负责流式接收、持久存储和暂存回执生命周期;Session Controller 将回执绑定到已接受的提交。 |
|
||||
| `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 |
|
||||
| `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 插件准备彼此独立的顶层字段;官方适配器会合并这些字段,并在 HTTP 接受后提交其交付状态。 |
|
||||
| `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 |
|
||||
|
||||
@@ -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: 136e42c22f58f4e7102f7fb3b905ab956db55ecd
|
||||
config-catalog.zh.md: 88a6bca44c8e8b396cb8efaf4be5cd704a6518f8
|
||||
config-catalog.md: a0fea049bf6e15ef8c342a329b3a55208e8e29ee
|
||||
config-catalog.zh.md: ba8fbba2cabe52b150e90110bd150bb9f7f13a5b
|
||||
@@ -203,7 +203,7 @@ Source: [`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/in
|
||||
|
||||
## `@deepseek-ai/dsh-api-session-controller`
|
||||
|
||||
Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
|
||||
Requires: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
|
||||
|
||||
```ts config-catalog
|
||||
/** Session Controller deployment policy. */
|
||||
@@ -213,7 +213,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/api/session-controller/src/index.ts:68`](../packages/api/session-controller/src/index.ts)
|
||||
Source: [`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-settings-controller"></a>
|
||||
|
||||
@@ -262,7 +262,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts)
|
||||
Source: [`packages/attachment/attachment-local/src/index.ts:61`](../packages/attachment/attachment-local/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-bash-local"></a>
|
||||
|
||||
@@ -336,7 +336,7 @@ export interface ConnectionConfig {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/client/connection/src/index.ts:70`](../packages/client/connection/src/index.ts)
|
||||
Source: [`packages/client/connection/src/index.ts:71`](../packages/client/connection/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-client-hmr"></a>
|
||||
|
||||
@@ -3325,6 +3325,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
|
||||
- `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts))
|
||||
- `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-file-upload` — requires `agents` · `attachments` · `commands` · `connection` ([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-modules` — requires `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))
|
||||
|
||||
@@ -205,7 +205,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-api-session-controller`
|
||||
|
||||
需要:`agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
|
||||
需要:`agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
|
||||
|
||||
```ts config-catalog
|
||||
/** Session Controller deployment policy. */
|
||||
@@ -215,7 +215,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/api/session-controller/src/index.ts:68`](../packages/api/session-controller/src/index.ts)
|
||||
来源:[`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-api-settings-controller"></a>
|
||||
|
||||
@@ -264,7 +264,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts)
|
||||
来源:[`packages/attachment/attachment-local/src/index.ts:61`](../packages/attachment/attachment-local/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-bash-local"></a>
|
||||
|
||||
@@ -338,7 +338,7 @@ export interface ConnectionConfig {
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/client/connection/src/index.ts:55`](../packages/client/connection/src/index.ts)
|
||||
来源:[`packages/client/connection/src/index.ts:71`](../packages/client/connection/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-client-hmr"></a>
|
||||
|
||||
@@ -3327,6 +3327,7 @@ export interface Config {
|
||||
- `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
|
||||
- `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts))
|
||||
- `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-file-upload` — 需要 `agents` · `attachments` · `commands` · `connection`([`packages/client/file-upload/src/index.ts`](../packages/client/file-upload/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-modules` — 需要 `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,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
|
||||
event-producer-consumer.md: 62eba828604ba1e90d9d7ef22743cf10daf84f70
|
||||
event-producer-consumer.zh.md: f82c379516d9aec00dade2acdf3f8291a340da44
|
||||
event-producer-consumer.md: 1d643f14cabd361e82468cbcec1e2d418e4f079c
|
||||
event-producer-consumer.zh.md: 80857c699e1a2b2e0956f909c26657754b5d909f
|
||||
@@ -22,14 +22,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:561`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:588`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:574`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:87`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
| `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:380`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
|
||||
| `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:386`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
|
||||
| `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:392`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` |
|
||||
@@ -44,11 +44,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:68`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:51`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
|
||||
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:262`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
|
||||
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:333`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:581`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:561`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:588`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:567`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:574`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
|
||||
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
|
||||
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
|
||||
@@ -46,11 +46,11 @@
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:68`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:51`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:61`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
|
||||
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: 6a5e4232f5a4da309b48c2e7c00dadf09e8b308a
|
||||
module-graph.zh.md: 2388a1d9843e5e2fe308e372f9d7db8150b48160
|
||||
module-graph.md: be0bc1db1222ba7b95dff735a339bef680b86cf5
|
||||
module-graph.zh.md: e1fdaae73ac398076598316efcfa45822e378155
|
||||
@@ -132,6 +132,7 @@ flowchart TD
|
||||
end
|
||||
subgraph group_client["packages/client"]
|
||||
pkg_client_connection["client-connection"]
|
||||
pkg_client_file_upload["client-file-upload"]
|
||||
pkg_client_hmr["client-hmr"]
|
||||
pkg_client_locale["client-locale"]
|
||||
pkg_client_modules["client-modules"]
|
||||
@@ -407,6 +408,7 @@ flowchart TD
|
||||
pkg_api_remotes --> pkg_scope
|
||||
pkg_attachment_local --> pkg_attachment
|
||||
pkg_attachment_local --> pkg_home_paths
|
||||
pkg_client_file_upload --> pkg_scope
|
||||
pkg_authorization --> pkg_credentials
|
||||
pkg_authorization --> pkg_invariants
|
||||
pkg_authorization --> pkg_llm
|
||||
@@ -1052,6 +1054,8 @@ flowchart TD
|
||||
pkg_api_session_controller --> pkg_api_gateway
|
||||
pkg_api_session_controller --> pkg_attachment
|
||||
pkg_api_session_controller --> pkg_client_connection
|
||||
pkg_api_session_controller --> pkg_client_file_upload
|
||||
pkg_api_session_controller --> pkg_commands
|
||||
pkg_api_session_controller --> pkg_file_reference
|
||||
pkg_api_session_controller --> pkg_jobs
|
||||
pkg_api_session_controller --> pkg_llm
|
||||
@@ -1261,6 +1265,7 @@ flowchart TD
|
||||
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
|
||||
| [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) |
|
||||
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
|
||||
| [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) |
|
||||
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
|
||||
| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
@@ -1391,7 +1396,7 @@ flowchart TD
|
||||
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
|
||||
@@ -134,6 +134,7 @@ flowchart TD
|
||||
end
|
||||
subgraph group_client["packages/client"]
|
||||
pkg_client_connection["client-connection"]
|
||||
pkg_client_file_upload["client-file-upload"]
|
||||
pkg_client_hmr["client-hmr"]
|
||||
pkg_client_locale["client-locale"]
|
||||
pkg_client_modules["client-modules"]
|
||||
@@ -409,6 +410,7 @@ flowchart TD
|
||||
pkg_api_remotes --> pkg_scope
|
||||
pkg_attachment_local --> pkg_attachment
|
||||
pkg_attachment_local --> pkg_home_paths
|
||||
pkg_client_file_upload --> pkg_scope
|
||||
pkg_authorization --> pkg_credentials
|
||||
pkg_authorization --> pkg_invariants
|
||||
pkg_authorization --> pkg_llm
|
||||
@@ -1054,6 +1056,8 @@ flowchart TD
|
||||
pkg_api_session_controller --> pkg_api_gateway
|
||||
pkg_api_session_controller --> pkg_attachment
|
||||
pkg_api_session_controller --> pkg_client_connection
|
||||
pkg_api_session_controller --> pkg_client_file_upload
|
||||
pkg_api_session_controller --> pkg_commands
|
||||
pkg_api_session_controller --> pkg_file_reference
|
||||
pkg_api_session_controller --> pkg_jobs
|
||||
pkg_api_session_controller --> pkg_llm
|
||||
@@ -1263,6 +1267,7 @@ flowchart TD
|
||||
| [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) |
|
||||
| [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) |
|
||||
| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) |
|
||||
| [`client-file-upload`](../packages/client/file-upload) | `client` | [`scope`](../packages/core/scope) |
|
||||
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
|
||||
| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
@@ -1393,7 +1398,7 @@ flowchart TD
|
||||
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
|
||||
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
|
||||
persistence-catalog.md: 76d91ea8e8edb625f9733b6ad28d225d2ee059a8
|
||||
persistence-catalog.zh.md: 52625069c16094e1b056b9ee9013409fd1690705
|
||||
persistence-catalog.md: 0b52801083498542c814d06171942f33d013efe2
|
||||
persistence-catalog.zh.md: eda29e00c9dfaff6c5d35afba55f2a8cfb95f075
|
||||
@@ -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/*`
|
||||
|
||||
|
||||
@@ -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,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
|
||||
@@ -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 -->
|
||||
@@ -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,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
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -78,8 +78,10 @@
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-questions": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-file-upload": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^"
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import llmRemote from '@deepseek-ai/dsh-llm/remote'
|
||||
import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote'
|
||||
import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote'
|
||||
import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
|
||||
import fileUploadsRemote from '@deepseek-ai/dsh-client-file-upload/remote'
|
||||
import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote'
|
||||
import subagentsRemote from '@deepseek-ai/dsh-subagent/remote'
|
||||
import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote'
|
||||
@@ -24,6 +25,7 @@ export type {} from '@deepseek-ai/dsh-goal/remote'
|
||||
export type {} from '@deepseek-ai/dsh-llm/remote'
|
||||
export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
|
||||
export type {} from '@deepseek-ai/dsh-message-feedback/remote'
|
||||
export type {} from '@deepseek-ai/dsh-client-file-upload/remote'
|
||||
export type {} from '@deepseek-ai/dsh-session-reference/remote'
|
||||
export type {} from '@deepseek-ai/dsh-subagent/remote'
|
||||
export type * from '@deepseek-ai/dsh-subagent/client'
|
||||
@@ -145,7 +147,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
||||
try {
|
||||
for (const contribution of [
|
||||
agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote,
|
||||
pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
|
||||
pluginInventoryRemote, messageFeedbackRemote, fileUploadsRemote, sessionReferencesRemote,
|
||||
subagentsRemote, sessionRemote, workspaceRemote,
|
||||
]) {
|
||||
disposers.push(await ctx.remote.$mount(contribution))
|
||||
|
||||
@@ -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: 1dbbeb34b845273d34b3323b6a4134da277126a0
|
||||
README.zh.md: 99c29a9043623b3d84fd12aeff42ec1781db699c
|
||||
README.md: ec8b70b1724afc462d2d0b5d267603bcab4aceb6
|
||||
README.zh.md: 6fc0c92300262bfdf0fc52deaec1d66313d91ebe
|
||||
@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, and the adapter for Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session.
|
||||
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, and Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -25,11 +25,11 @@ English | [中文](README.zh.md)
|
||||
|
||||
History pages and follow opening snapshots carry one `{ type: 'event', event: SessionWireEvent }` record per durable Session event. The Client retains each accepted record as one durable `SessionEventLikeEntry`; Assistant token boundaries remain inside the compact stream on `assistant/message` or `assistant/attempt`. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
|
||||
|
||||
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
|
||||
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent.
|
||||
|
||||
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
|
||||
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
|
||||
|
||||
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
|
||||
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
|
||||
|
||||
-----
|
||||
|
||||
@@ -59,6 +59,7 @@ No direct effect; model requests remain owned by the Agent and LLM packages.
|
||||
|
||||
- Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart.
|
||||
- A failed follow resumption remains visible to the caller instead of retrying indefinitely.
|
||||
- The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero.
|
||||
- File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata.
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill 发现,以及面向 Agent 的文件引用 adapter。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。
|
||||
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill 发现和面向 Agent 的文件引用。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -25,11 +25,11 @@ kind: "package-reference"
|
||||
|
||||
历史页与 follow opening snapshot 为每个持久 Session event 携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑 stream 内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。
|
||||
|
||||
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
|
||||
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。
|
||||
|
||||
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
|
||||
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`,即轮次跳转加载器,按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web adapter 显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且 Turn 与 Step 匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的 event seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。Client Agent context 提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
|
||||
|
||||
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
|
||||
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
|
||||
|
||||
-----
|
||||
|
||||
@@ -59,6 +59,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
|
||||
|
||||
- Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。
|
||||
- follow 恢复失败会对调用方可见,而不会无限重试。
|
||||
- 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第一个字节重新传输整个文件。
|
||||
- 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。
|
||||
|
||||
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-file-upload": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
@@ -119,6 +121,8 @@
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-file-upload": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs": "workspace:^",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5,11 +5,11 @@ import type { QueuedMessage } from '../contract/snapshot.ts'
|
||||
|
||||
const QUEUE_PREVIEW_CHARS = 200
|
||||
|
||||
// Image blocks are excluded: queue presentation renders them as thumbnails
|
||||
// from `content`, so the text preview covers only what has no visual form.
|
||||
// Attachment blocks are excluded: queue presentation renders them from
|
||||
// `content`, so the text preview covers only what has no visual form.
|
||||
function previewOf(content: readonly ContentBlock[]): string {
|
||||
const flat = content
|
||||
.filter(block => block.type !== 'image')
|
||||
.filter(block => block.type !== 'image' && block.type !== 'file')
|
||||
.map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
|
||||
.join(' ').replace(/\s+/g, ' ').trim()
|
||||
const chars = Array.from(flat)
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* @module @deepseek-ai/dsh-api-session-controller/client/sessions/remotes
|
||||
*/
|
||||
|
||||
import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types'
|
||||
import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type { CommandSubmitAttachment } from '@deepseek-ai/dsh-commands/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, SubagentPromptRequest,
|
||||
@@ -19,7 +19,7 @@ export interface SessionCommandsRemote {
|
||||
execute(
|
||||
agentId: SessionId,
|
||||
line: string,
|
||||
images: readonly EncodedImageAttachment[],
|
||||
attachments: readonly CommandSubmitAttachment[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<RemoteResult<object | undefined>>
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import { SessionLogOffset, SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
} from '../contract/events.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { SessionRemotes } from './remotes.ts'
|
||||
import { ProjectionValueStore } from './projection-store.ts'
|
||||
@@ -210,7 +211,7 @@ export class Session implements SessionFace {
|
||||
: 'transcript',
|
||||
time: Date.now(),
|
||||
text: input.text,
|
||||
images: input.images,
|
||||
attachments: input.attachments,
|
||||
}]
|
||||
this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false })
|
||||
// The blank → engaging edge flips here, ahead of prompt(): the composer
|
||||
@@ -222,7 +223,7 @@ export class Session implements SessionFace {
|
||||
|
||||
/**
|
||||
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
|
||||
* @param content - text plus browser-owned temporary image uploads.
|
||||
* @param content - text, browser-owned temporary image uploads, and staged-file receipts.
|
||||
* @param mode - queue appends after the current turn; steer interrupts it.
|
||||
* @param signal - optional caller cancellation for the complete admission round-trip.
|
||||
* @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo.
|
||||
@@ -252,13 +253,25 @@ export class Session implements SessionFace {
|
||||
content,
|
||||
clientTimeZone,
|
||||
}, signal)
|
||||
} else if (content.some(part => part.type === 'file')) {
|
||||
result = {
|
||||
ok: false,
|
||||
error: new RemoteError(
|
||||
'subagent/attachment-invalid',
|
||||
'subagent continuation does not accept files',
|
||||
{ reason: 'SUBAGENT_FILE_UNSUPPORTED' },
|
||||
),
|
||||
}
|
||||
} else {
|
||||
// The preceding branch rejects file parts before the narrower subagent
|
||||
// wire type is used; this array is not filtered or reordered.
|
||||
const routedContent = content as Exclude<PromptContentPart, { readonly type: 'file' }>[]
|
||||
const routed = await this.remote.subagents.prompt({
|
||||
requestId: randomUUID() as SessionRequestId,
|
||||
parentSessionId: this.address.parentSessionId,
|
||||
childSessionId: this.address.childSessionId,
|
||||
mode: 'continuable',
|
||||
content,
|
||||
content: routedContent,
|
||||
clientTimeZone: resolvedClientTimeZone(),
|
||||
}, signal)
|
||||
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
|
||||
@@ -717,7 +730,7 @@ export class Session implements SessionFace {
|
||||
const data = event.data as { readonly source?: unknown; readonly content?: unknown } | undefined
|
||||
const source = data?.source as { readonly kind?: unknown; readonly rpcId?: unknown } | undefined
|
||||
if (source?.kind !== 'user' || typeof source.rpcId !== 'string') return
|
||||
this.scheduleObservedRetirement(source.rpcId as SessionRequestId, imageRefsIn(data?.content))
|
||||
this.scheduleObservedRetirement(source.rpcId as SessionRequestId, attachmentRefsIn(data?.content))
|
||||
}
|
||||
|
||||
/** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */
|
||||
@@ -725,7 +738,7 @@ export class Session implements SessionFace {
|
||||
if (this.submissionSettlements.size === 0) return
|
||||
for (const item of items) {
|
||||
if (item.rpcId !== undefined) {
|
||||
this.scheduleObservedRetirement(item.rpcId, imageRefsIn(item.message.content))
|
||||
this.scheduleObservedRetirement(item.rpcId, attachmentRefsIn(item.message.content))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -738,7 +751,7 @@ export class Session implements SessionFace {
|
||||
*/
|
||||
private scheduleObservedRetirement(
|
||||
requestId: SessionRequestId,
|
||||
attachments: readonly ImageAttachmentRef[],
|
||||
attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[],
|
||||
): void {
|
||||
const settlement = this.submissionSettlements.get(requestId)
|
||||
if (settlement === undefined || settlement.retiring) return
|
||||
@@ -816,15 +829,16 @@ function scheduleFrame(fn: () => void): void {
|
||||
else setTimeout(fn, 0)
|
||||
}
|
||||
|
||||
/** Image attachment references in one structurally-read content block list, in block order. */
|
||||
function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] {
|
||||
/** Attachment references in one structurally-read content block list, in block order. */
|
||||
function attachmentRefsIn(content: unknown): readonly (ImageAttachmentRef | FileAttachmentRef)[] {
|
||||
if (!Array.isArray(content)) return []
|
||||
const refs: ImageAttachmentRef[] = []
|
||||
const refs: Array<ImageAttachmentRef | FileAttachmentRef> = []
|
||||
for (const block of content) {
|
||||
if (typeof block !== 'object' || block === null) continue
|
||||
const candidate = block as { readonly type?: unknown; readonly attachment?: unknown }
|
||||
if (candidate.type === 'image' && typeof candidate.attachment === 'object' && candidate.attachment !== null) {
|
||||
refs.push(candidate.attachment as ImageAttachmentRef)
|
||||
if ((candidate.type === 'image' || candidate.type === 'file')
|
||||
&& typeof candidate.attachment === 'object' && candidate.attachment !== null) {
|
||||
refs.push(candidate.attachment as ImageAttachmentRef | FileAttachmentRef)
|
||||
}
|
||||
}
|
||||
return refs
|
||||
|
||||
@@ -4,8 +4,12 @@ import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import { AttachmentError, admitPromptContent } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types'
|
||||
import type {} from '@deepseek-ai/dsh-client-file-upload'
|
||||
import {
|
||||
ReasoningEffortId, createUserMessage, expandAssistantStream, freezeMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
@@ -44,6 +48,7 @@ import type {
|
||||
SessionSelectModelValue,
|
||||
SessionUpdateQueueRequest,
|
||||
SessionUpdateQueueValue,
|
||||
SessionRequestId,
|
||||
} from './types.ts'
|
||||
|
||||
interface SessionReadState {
|
||||
@@ -297,6 +302,7 @@ export class SessionCommandController {
|
||||
)
|
||||
}
|
||||
const agent = await this.resolveAgent(request.sessionId)
|
||||
if (hasPromptRequest(agent, request.requestId)) return { accepted: true }
|
||||
const selection = this.agents.selectionFor(agent).current
|
||||
if (!routeServed(this.ctx, selection.provider)) {
|
||||
throw new RemoteError(
|
||||
@@ -324,10 +330,23 @@ export class SessionCommandController {
|
||||
)
|
||||
}
|
||||
}
|
||||
const content = await admitPromptContent(this.ctx.attachments, request.content)
|
||||
const admission = resolvePromptFileReceipts(
|
||||
request.content,
|
||||
receiptId => this.ctx.fileUploads.resolve(agent, receiptId),
|
||||
)
|
||||
const content = await this.ctx.attachments.admitPromptContent(admission.content)
|
||||
const message: UserMessage = createUserMessage({ content, source })
|
||||
if (this.ctx.agents.get(agent.id) !== agent) {
|
||||
throw new RemoteError(
|
||||
'session/not-found',
|
||||
`session "${agent.id}" was disposed during prompt admission`,
|
||||
{ sessionId: agent.id },
|
||||
)
|
||||
}
|
||||
using binding = this.ctx.fileUploads.bindPrompt(agent, admission.receiptIds, request.requestId)
|
||||
if (request.mode === 'steer') agent.steer(message)
|
||||
else agent.followup(message)
|
||||
binding.commit()
|
||||
} catch (error) {
|
||||
if (remoteErrorOf(error) !== undefined) throw error
|
||||
if (error instanceof AttachmentError) {
|
||||
@@ -421,6 +440,12 @@ export class SessionCommandController {
|
||||
}))
|
||||
} else {
|
||||
agent.inbox.remove(request.itemId)
|
||||
if (request.action.kind === 'remove') {
|
||||
const source = message.source
|
||||
if (source.kind === 'user' && 'rpcId' in source) {
|
||||
this.ctx.fileUploads.retirePrompt(agent, source.rpcId)
|
||||
}
|
||||
}
|
||||
if (request.action.kind === 'steer') agent.steer(message)
|
||||
}
|
||||
return { accepted: true }
|
||||
@@ -497,6 +522,39 @@ export class SessionCommandController {
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromptFileReceipts(
|
||||
content: SessionPromptRequest['content'],
|
||||
stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined,
|
||||
): { readonly content: AttachmentAdmissionPart[]; readonly receiptIds: readonly FileUploadReceiptId[] } {
|
||||
const receiptIds = new Set<FileUploadReceiptId>()
|
||||
const resolved = content.map((part): AttachmentAdmissionPart => {
|
||||
if (part.type !== 'file') return part
|
||||
const attachment = stagedFile(part.receiptId)
|
||||
if (attachment === undefined) {
|
||||
throw new RemoteError(
|
||||
'session/attachment-invalid',
|
||||
'File was not uploaded for this session.',
|
||||
{ reason: 'FILE_NOT_STAGED' },
|
||||
)
|
||||
}
|
||||
receiptIds.add(part.receiptId)
|
||||
return { type: 'file', attachment }
|
||||
})
|
||||
return { content: resolved, receiptIds: [...receiptIds] }
|
||||
}
|
||||
|
||||
function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean {
|
||||
const matches = (message: UserMessage): boolean => {
|
||||
const source = message.source
|
||||
return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId
|
||||
}
|
||||
if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true
|
||||
return agent.session.snapshotEvents().some((event) => {
|
||||
if (event.type !== 'user/message') return false
|
||||
const source = event.data.source
|
||||
return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId
|
||||
})
|
||||
}
|
||||
function imageBlockIn(
|
||||
content: unknown,
|
||||
match: (ref: ImageAttachmentRef) => boolean,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-client-file-upload'
|
||||
import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
|
||||
@@ -84,6 +85,7 @@ export class SessionController extends TypertRemoteService {
|
||||
'agentDefaultModel',
|
||||
'agents',
|
||||
'attachments',
|
||||
'fileUploads',
|
||||
'llm',
|
||||
'sessions',
|
||||
'sessionProjections',
|
||||
@@ -115,6 +117,11 @@ export class SessionController extends TypertRemoteService {
|
||||
installModelSelectionProjection(ctx)
|
||||
this.agents = new ApiSessionAgentController(ctx)
|
||||
this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
|
||||
ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => {
|
||||
const result = await this.agents.resolveAgent(sessionId)
|
||||
if ('error' in result) throw result.error
|
||||
return result.agent
|
||||
}), 'session-controller: file-upload Agent resolver')
|
||||
this.controlState = new SessionControlController(ctx)
|
||||
// Registered before history so reverse-order teardown closes every
|
||||
// follower before waiting for already-admitted promotions.
|
||||
|
||||
@@ -67,7 +67,11 @@ export interface SessionProjectionBaseline {
|
||||
export type SessionProjectionValues = Partial<SessionProjectionMap>
|
||||
& Readonly<Record<string, SessionProjectionValue>>
|
||||
|
||||
/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */
|
||||
/**
|
||||
* Browser-submitted prompt content; the Host promotes image bytes to durable
|
||||
* references. File parts carry the opaque receipt returned by a preceding
|
||||
* `uploadFile` call on the same Session.
|
||||
*/
|
||||
export type PromptContentPart =
|
||||
| { readonly type: 'text'; readonly text: string }
|
||||
| {
|
||||
@@ -76,6 +80,7 @@ export type PromptContentPart =
|
||||
readonly data: string
|
||||
readonly name?: string
|
||||
}
|
||||
| { readonly type: 'file'; readonly receiptId: Branded<'file-upload-receipt-id'> }
|
||||
|
||||
/** Complete model selection for one Session. */
|
||||
export interface ModelSelection {
|
||||
|
||||
@@ -65,6 +65,11 @@ async function mount(initialGeneration?: ConnectionGeneration): Promise<Bench> {
|
||||
registerGenerationSource: () => () => {},
|
||||
start: () => ({ stop: () => {} }),
|
||||
}
|
||||
ctx.reflect.provide('connection', connection)
|
||||
ctx.reflect.provide('fileUpload', {
|
||||
available: true,
|
||||
post: () => Promise.reject(new Error('unexpected file upload')),
|
||||
})
|
||||
ctx.reflect.provide('remote', {
|
||||
...remote,
|
||||
$stream: <Item>(options: RemoteStreamOptions<Item>) => (
|
||||
|
||||
@@ -73,8 +73,9 @@ describe('Client Session contracts', () => {
|
||||
expectTypeOf<SessionPageRequest['throughSeq']>().toEqualTypeOf<number>()
|
||||
})
|
||||
|
||||
it('keeps its catalog-visible prompt parts identical to attachment intake', () => {
|
||||
expectTypeOf<SessionPromptContentPart>().toEqualTypeOf<AttachmentPromptContentPart>()
|
||||
it('keeps its text and image prompt parts identical to attachment intake', () => {
|
||||
expectTypeOf<Exclude<SessionPromptContentPart, { type: 'file' }>>()
|
||||
.toEqualTypeOf<AttachmentPromptContentPart>()
|
||||
})
|
||||
|
||||
it('publishes exact replace, prepend, and append event-window changes', () => {
|
||||
|
||||
@@ -112,6 +112,23 @@ describe('Session queue commands', () => {
|
||||
})).toEqual({ accepted: true })
|
||||
expect(steer).toHaveBeenCalledWith(steered)
|
||||
|
||||
const queuedFile = createUserMessage({
|
||||
content: [{
|
||||
type: 'file',
|
||||
attachment: { attachmentId: AttachmentId('file-queued'), name: 'queued.txt', bytes: 6 },
|
||||
}],
|
||||
source: { kind: 'user', rpcId: 'file-rpc' as never },
|
||||
})
|
||||
inbox.append('next-turn', queuedFile)
|
||||
expect(controller.updateQueue({
|
||||
sessionId: agent.id, itemId: queuedFile.id, action: { kind: 'steer' },
|
||||
})).toEqual({ accepted: true })
|
||||
expect(steer).toHaveBeenLastCalledWith(queuedFile)
|
||||
expect(queuedFile).toMatchObject({
|
||||
source: { kind: 'user', rpcId: 'file-rpc' },
|
||||
content: [{ type: 'file', attachment: { name: 'queued.txt', bytes: 6 } }],
|
||||
})
|
||||
|
||||
await expectFailure(Promise.resolve().then(() => controller.cancel({
|
||||
sessionId: SessionId('missing'),
|
||||
})), 'session/not-found')
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
|
||||
import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import CommandRuntime from '@deepseek-ai/dsh-commands'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import FileUploads from '@deepseek-ai/dsh-client-file-upload'
|
||||
import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ApiSessionAgentController } from '../src/agent.ts'
|
||||
import { SessionCommandController } from '../src/commands.ts'
|
||||
import type { SessionRequestId } from '../src/types.ts'
|
||||
|
||||
const SESSION = SessionId('upload-session')
|
||||
|
||||
async function uploadHarness(origin?: 'subagent'): Promise<{
|
||||
ctx: Context
|
||||
controller: SessionCommandController
|
||||
uploads: FileUploads
|
||||
agent: Agent
|
||||
followup: ReturnType<typeof vi.fn>
|
||||
saveFile: ReturnType<typeof vi.fn>
|
||||
saveFileStream: ReturnType<typeof vi.fn>
|
||||
saveImages: ReturnType<typeof vi.fn>
|
||||
disposeAgent: () => void
|
||||
uploadRoute: (request: Request) => Promise<Response>
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandRuntime)
|
||||
const session = ctx.sessions.create(SESSION, {
|
||||
meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) },
|
||||
})
|
||||
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
|
||||
const followup = vi.fn()
|
||||
const agent = {
|
||||
id: session.id,
|
||||
session,
|
||||
inbox,
|
||||
status: 'idle',
|
||||
ctx: undefined,
|
||||
steer: vi.fn(),
|
||||
followup,
|
||||
cancel: vi.fn(),
|
||||
} as unknown as Agent
|
||||
;(agent as { ctx: Context }).ctx = createScope(ctx, agent).ctx
|
||||
const disposeAgent = ctx.agents.register(agent)
|
||||
const saveFile = vi.fn((input: SaveFileAttachment): Promise<FileAttachmentRef> => Promise.resolve({
|
||||
attachmentId: AttachmentId(`sha256:${'cd'.repeat(32)}`),
|
||||
name: input.name ?? 'file',
|
||||
bytes: input.data.byteLength,
|
||||
}))
|
||||
const saveFileStream = vi.fn(async (input: SaveFileStreamAttachment): Promise<FileAttachmentRef> => {
|
||||
let bytes = 0
|
||||
for await (const chunk of input.data) bytes += chunk.byteLength
|
||||
return {
|
||||
attachmentId: AttachmentId(`sha256:${'ef'.repeat(32)}`),
|
||||
name: input.name ?? 'file',
|
||||
bytes,
|
||||
}
|
||||
})
|
||||
const saveImages = vi.fn((): Promise<readonly ImageAttachmentRef[]> =>
|
||||
Promise.reject(new Error('fixture did not expect image persistence')))
|
||||
ctx.provide('attachments', Object.setPrototypeOf(
|
||||
{ saveFile, saveFileStream, saveImages },
|
||||
AttachmentStore.prototype,
|
||||
) as never)
|
||||
let uploadRoute: ((request: Request) => Promise<Response>) | undefined
|
||||
ctx.provide('connection', {
|
||||
fetch: {
|
||||
register: (route: { readonly fetch: (request: Request) => Promise<Response> }) => {
|
||||
uploadRoute = route.fetch
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
} as never)
|
||||
ctx.provide('llm', {
|
||||
listProviders: () => [{ id: 'fixture', name: 'Fixture' }],
|
||||
resolveModelInfo: () => Promise.resolve({ provider: 'fixture', id: 'fixture-model', name: 'Fixture' }),
|
||||
} as never)
|
||||
const selection: ModelSelectionRef = {
|
||||
current: { provider: 'fixture', model: 'fixture-model' },
|
||||
assembled: undefined,
|
||||
}
|
||||
const agents = {
|
||||
resolveAgent: () => Promise.resolve({ agent }),
|
||||
selectionFor: () => selection,
|
||||
serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
|
||||
} as unknown as ApiSessionAgentController
|
||||
const uploads = new FileUploads(ctx)
|
||||
if (uploadRoute === undefined) throw new Error('file upload route was not registered')
|
||||
return {
|
||||
ctx,
|
||||
controller: new SessionCommandController(ctx, agents, '/workspace'),
|
||||
uploads,
|
||||
agent,
|
||||
followup,
|
||||
saveFile,
|
||||
saveFileStream,
|
||||
saveImages,
|
||||
disposeAgent,
|
||||
uploadRoute,
|
||||
}
|
||||
}
|
||||
|
||||
function promptRequest(content: Parameters<SessionCommandController['prompt']>[0]['content']) {
|
||||
return {
|
||||
requestId: 'req-1' as SessionRequestId,
|
||||
sessionId: SESSION,
|
||||
mode: 'queue' as const,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Session file uploads', () => {
|
||||
it('registers an HTTP route bound to the upload service', async () => {
|
||||
const { uploadRoute } = await uploadHarness()
|
||||
await expect(uploadRoute(new Request('http://host/upload')))
|
||||
.resolves.toMatchObject({ status: 405 })
|
||||
})
|
||||
|
||||
it('stages one verbatim upload and preserves its order with an admitted image', async () => {
|
||||
const { ctx, controller, uploads, agent, followup, saveFile, saveImages } = await uploadHarness()
|
||||
const receipt = await uploads.upload(agent, { data: 'AAAA', name: 'notes.pdf' }, new AbortController().signal)
|
||||
expect(saveFile).toHaveBeenCalledTimes(1)
|
||||
expect(receipt.file.name).toBe('notes.pdf')
|
||||
expect(receipt.file.bytes).toBe(3)
|
||||
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
|
||||
expect(uploads.resolve(agent, 'missing' as FileUploadReceiptId)).toBeUndefined()
|
||||
const commandHandler = vi.fn((_invocation: unknown) => ({ kind: 'success' as const }))
|
||||
ctx.commands.register({
|
||||
name: 'files', description: 'Use staged files', input: { hint: '<task>', attachments: true },
|
||||
handler: commandHandler,
|
||||
})
|
||||
await ctx.commands.execute(
|
||||
agent,
|
||||
'/files inspect',
|
||||
[{ type: 'file', receiptId: receipt.receiptId }],
|
||||
new AbortController().signal,
|
||||
)
|
||||
expect(commandHandler.mock.calls[0]?.[0]).toMatchObject({
|
||||
attachments: [{ type: 'file', attachment: receipt.file }],
|
||||
})
|
||||
const image: ImageAttachmentRef = {
|
||||
attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`),
|
||||
mediaType: 'image/png',
|
||||
bytes: 3,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}
|
||||
saveImages.mockResolvedValueOnce([image])
|
||||
await controller.prompt(promptRequest([
|
||||
{ type: 'file', receiptId: receipt.receiptId },
|
||||
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
|
||||
{ type: 'text', text: 'read it' },
|
||||
]))
|
||||
expect(followup).toHaveBeenCalledTimes(1)
|
||||
const message = followup.mock.calls[0]?.[0] as UserMessage
|
||||
expect(message.content).toEqual([
|
||||
{ type: 'file', attachment: receipt.file },
|
||||
{ type: 'image', attachment: image },
|
||||
{ type: 'text', text: 'read it' },
|
||||
])
|
||||
})
|
||||
|
||||
it('stages a bounded byte stream and forwards cancellation to storage', async () => {
|
||||
const { controller, uploads, followup, saveFileStream } = await uploadHarness()
|
||||
const abort = new AbortController()
|
||||
const receipt = await uploads.uploadStream({
|
||||
sessionId: SESSION,
|
||||
data: (async function* (): AsyncIterable<Uint8Array> {
|
||||
yield Uint8Array.of(1, 2)
|
||||
yield Uint8Array.of(3, 4, 5)
|
||||
})(),
|
||||
signal: abort.signal,
|
||||
name: 'huge.bin',
|
||||
})
|
||||
expect(saveFileStream).toHaveBeenCalledWith(expect.objectContaining({
|
||||
signal: abort.signal,
|
||||
name: 'huge.bin',
|
||||
}))
|
||||
expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 })
|
||||
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
|
||||
expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
|
||||
{ type: 'file', attachment: receipt.file },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the stream name optional and maps storage failures through the same error vocabulary', async () => {
|
||||
const { uploads, saveFileStream } = await uploadHarness()
|
||||
const stream = (async function* (): AsyncIterable<Uint8Array> {})()
|
||||
await expect(uploads.uploadStream({ sessionId: SESSION, data: stream }))
|
||||
.resolves.toMatchObject({ file: { name: 'file', bytes: 0 } })
|
||||
expect(saveFileStream).toHaveBeenLastCalledWith({ data: stream })
|
||||
saveFileStream.mockRejectedValueOnce('disk offline')
|
||||
await expect(uploads.uploadStream({
|
||||
sessionId: SESSION,
|
||||
data: (async function* (): AsyncIterable<Uint8Array> { yield Uint8Array.of(1) })(),
|
||||
}))
|
||||
.rejects.toMatchObject({
|
||||
code: 'gateway/internal', message: 'failed to store file upload: disk offline',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a prompt citing a file that was never staged for the session', async () => {
|
||||
const { controller, followup, saveImages } = await uploadHarness()
|
||||
await expect(controller.prompt(promptRequest([
|
||||
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
|
||||
{ type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId },
|
||||
]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
expect(saveImages).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('publishes no receipt when its exact Agent is disposed during storage', async () => {
|
||||
const { uploads, agent, saveFile, disposeAgent } = await uploadHarness()
|
||||
const saved = Promise.withResolvers<FileAttachmentRef>()
|
||||
saveFile.mockReturnValueOnce(saved.promise)
|
||||
const uploading = uploads.upload(agent, { data: 'AAAA', name: 'late.bin' }, new AbortController().signal)
|
||||
await vi.waitFor(() => { expect(saveFile).toHaveBeenCalledOnce() })
|
||||
disposeAgent()
|
||||
saved.resolve({
|
||||
attachmentId: AttachmentId(`sha256:${'ab'.repeat(32)}`), name: 'late.bin', bytes: 3,
|
||||
})
|
||||
await expect(uploading).rejects.toMatchObject({ code: 'session/not-found' })
|
||||
})
|
||||
|
||||
it('resolves a cold ordinary Agent and releases the resolver registration', async () => {
|
||||
const { ctx, uploads, agent, disposeAgent } = await uploadHarness()
|
||||
disposeAgent()
|
||||
const resolveAgent = vi.fn(async () => {
|
||||
ctx.agents.register(agent)
|
||||
return agent
|
||||
})
|
||||
const disposeResolver = uploads.registerAgentResolver(resolveAgent)
|
||||
expect(() => { uploads.registerAgentResolver(resolveAgent) }).toThrow('already registered')
|
||||
await expect(uploads.uploadStream({
|
||||
sessionId: SESSION,
|
||||
data: (async function* (): AsyncIterable<Uint8Array> { yield Uint8Array.of(1) })(),
|
||||
})).resolves.toMatchObject({ file: { bytes: 1 } })
|
||||
expect(resolveAgent).toHaveBeenCalledWith(SESSION)
|
||||
disposeResolver()
|
||||
const replacement = vi.fn(async () => agent)
|
||||
const disposeReplacement = uploads.registerAgentResolver(replacement)
|
||||
disposeResolver()
|
||||
expect(() => { uploads.registerAgentResolver(replacement) }).toThrow('already registered')
|
||||
expect(disposeReplacement).toBeTypeOf('function')
|
||||
disposeReplacement()
|
||||
})
|
||||
|
||||
it('rejects a cold upload when no Agent resolver is registered', async () => {
|
||||
const { uploads, disposeAgent } = await uploadHarness()
|
||||
disposeAgent()
|
||||
await expect(uploads.uploadStream({
|
||||
sessionId: SESSION,
|
||||
data: (async function* (): AsyncIterable<Uint8Array> {})(),
|
||||
})).rejects.toMatchObject({ code: 'session/not-found' })
|
||||
})
|
||||
|
||||
it('rejects subagent uploads and access outside the receiving Agent scope', async () => {
|
||||
const child = await uploadHarness('subagent')
|
||||
await expect(child.uploads.upload(
|
||||
child.agent,
|
||||
{ data: 'AAAA' },
|
||||
new AbortController().signal,
|
||||
)).rejects.toMatchObject({
|
||||
code: 'subagent/attachment-invalid',
|
||||
details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' },
|
||||
})
|
||||
expect(child.saveFile).not.toHaveBeenCalled()
|
||||
|
||||
const ordinary = await uploadHarness()
|
||||
const receipt = await ordinary.uploads.upload(
|
||||
ordinary.agent,
|
||||
{ data: 'AAAA' },
|
||||
new AbortController().signal,
|
||||
)
|
||||
const foreignScope = { ...ordinary.agent, ctx: ordinary.ctx } as Agent
|
||||
expect(() => ordinary.uploads.resolve(foreignScope, receipt.receiptId))
|
||||
.toThrow('operation requires the Agent\'s own scope')
|
||||
})
|
||||
|
||||
it('retires accepted receipts after their rpcId becomes observable', async () => {
|
||||
const { controller, uploads, agent } = await uploadHarness()
|
||||
uploads.retirePrompt(agent, 'not-staged')
|
||||
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
|
||||
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
|
||||
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
|
||||
uploads.retirePrompt(agent, 'other-request')
|
||||
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
|
||||
uploads.retirePrompt(agent, 'req-1')
|
||||
expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retires observed prompt receipts and drops staged state with the Session', async () => {
|
||||
const first = await uploadHarness()
|
||||
const observed = await first.uploads.upload(
|
||||
first.agent,
|
||||
{ data: 'AAAA' },
|
||||
new AbortController().signal,
|
||||
)
|
||||
await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]))
|
||||
first.ctx.emit('session/event', first.agent.session, {
|
||||
type: 'user/message',
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'extension event' }],
|
||||
source: { kind: 'user', rpcId: 1 } as never,
|
||||
}),
|
||||
} as never)
|
||||
expect(first.uploads.resolve(first.agent, observed.receiptId)).toEqual(observed.file)
|
||||
first.agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'observed' }],
|
||||
source: { kind: 'user', rpcId: 'req-1' as SessionRequestId },
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(first.uploads.resolve(first.agent, observed.receiptId)).toBeUndefined()
|
||||
|
||||
const second = await uploadHarness()
|
||||
const abandoned = await second.uploads.upload(
|
||||
second.agent,
|
||||
{ data: 'AAAA' },
|
||||
new AbortController().signal,
|
||||
)
|
||||
second.ctx.emit('session/disposed', second.agent.session)
|
||||
expect(second.uploads.resolve(second.agent, abandoned.receiptId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deduplicates a retried rpcId already present in the Agent inbox', async () => {
|
||||
const { controller, agent, followup } = await uploadHarness()
|
||||
const request = promptRequest([{ type: 'text', text: 'once' }])
|
||||
await controller.prompt(request)
|
||||
agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage)
|
||||
await expect(controller.prompt(request)).resolves.toEqual({ accepted: true })
|
||||
expect(followup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('deduplicates a retried rpcId already present in the durable log', async () => {
|
||||
const { controller, agent, followup } = await uploadHarness()
|
||||
const request = promptRequest([{ type: 'text', text: 'once' }])
|
||||
agent.session.append('turn/start', { turn: 1 })
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'unidentified' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'accepted' }],
|
||||
source: { kind: 'user', rpcId: request.requestId },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
await expect(controller.prompt(request)).resolves.toEqual({ accepted: true })
|
||||
expect(followup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when the Agent disappears during prompt admission', async () => {
|
||||
const { controller, saveImages, disposeAgent } = await uploadHarness()
|
||||
const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
|
||||
saveImages.mockReturnValueOnce(admitted.promise)
|
||||
const prompting = controller.prompt(promptRequest([
|
||||
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
|
||||
]))
|
||||
await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
|
||||
disposeAgent()
|
||||
admitted.resolve([{
|
||||
attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1,
|
||||
}])
|
||||
await expect(prompting).rejects.toMatchObject({ code: 'session/not-found' })
|
||||
})
|
||||
|
||||
it('rejects when a previously bound receipt retires during image admission', async () => {
|
||||
const { controller, uploads, agent, saveImages } = await uploadHarness()
|
||||
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
|
||||
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
|
||||
const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
|
||||
saveImages.mockReturnValueOnce(admitted.promise)
|
||||
const prompting = controller.prompt({
|
||||
...promptRequest([
|
||||
{ type: 'file', receiptId: receipt.receiptId },
|
||||
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
|
||||
]),
|
||||
requestId: 'req-2' as SessionRequestId,
|
||||
})
|
||||
await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
|
||||
uploads.retirePrompt(agent, 'req-1')
|
||||
admitted.resolve([{
|
||||
attachmentId: AttachmentId('admitted-image'), mediaType: 'image/png', bytes: 3, width: 1, height: 1,
|
||||
}])
|
||||
await expect(prompting).rejects.toMatchObject({
|
||||
code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' },
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the prior receipt binding when a later prompt attempt fails', async () => {
|
||||
const { controller, uploads, agent, followup } = await uploadHarness()
|
||||
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
|
||||
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
|
||||
followup.mockImplementationOnce(() => { throw new Error('busy') })
|
||||
await expect(controller.prompt({
|
||||
...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]),
|
||||
requestId: 'req-2' as SessionRequestId,
|
||||
})).rejects.toMatchObject({ code: 'session/agent-busy' })
|
||||
uploads.retirePrompt(agent, 'req-1')
|
||||
expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('restores an unbound receipt after prompt delivery fails', async () => {
|
||||
const { controller, uploads, agent, followup } = await uploadHarness()
|
||||
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
|
||||
followup.mockImplementationOnce(() => { throw new Error('busy') })
|
||||
await expect(controller.prompt(promptRequest([
|
||||
{ type: 'file', receiptId: receipt.receiptId },
|
||||
]))).rejects.toMatchObject({ code: 'session/agent-busy' })
|
||||
uploads.retirePrompt(agent, 'req-1')
|
||||
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
|
||||
})
|
||||
|
||||
it('retires a prompt-bound receipt when its queued occurrence is removed', async () => {
|
||||
const { controller, uploads, agent, followup } = await uploadHarness()
|
||||
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
|
||||
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]))
|
||||
const queued = followup.mock.calls[0]?.[0] as UserMessage
|
||||
agent.inbox.append('next-turn', queued)
|
||||
expect(controller.updateQueue({
|
||||
sessionId: SESSION,
|
||||
itemId: queued.id,
|
||||
action: { kind: 'remove' },
|
||||
})).toEqual({ accepted: true })
|
||||
expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps separate names for identical bytes uploaded more than once', async () => {
|
||||
const { controller, uploads, agent, followup } = await uploadHarness()
|
||||
const first = await uploads.upload(agent, { data: 'AAAA', name: 'first.txt' }, new AbortController().signal)
|
||||
const second = await uploads.upload(agent, { data: 'AAAA', name: 'second.txt' }, new AbortController().signal)
|
||||
expect(first.file.attachmentId).toBe(second.file.attachmentId)
|
||||
expect(first.receiptId).not.toBe(second.receiptId)
|
||||
|
||||
await controller.prompt(promptRequest([
|
||||
{ type: 'file', receiptId: first.receiptId },
|
||||
{ type: 'file', receiptId: second.receiptId },
|
||||
]))
|
||||
|
||||
const message = followup.mock.calls[0]?.[0] as UserMessage
|
||||
expect(message.content).toEqual([
|
||||
{ type: 'file', attachment: first.file },
|
||||
{ type: 'file', attachment: second.file },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps a non-canonical payload to the wire attachment error', async () => {
|
||||
const { uploads, agent, saveFile } = await uploadHarness()
|
||||
await expect(uploads.upload(agent, { data: 'not base64!!' }, new AbortController().signal))
|
||||
.rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'INVALID_FILE_BASE64' } })
|
||||
expect(saveFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps an unexpected storage failure to the internal wire error', async () => {
|
||||
const { uploads, agent, saveFile } = await uploadHarness()
|
||||
saveFile.mockRejectedValueOnce(new Error('disk unavailable'))
|
||||
await expect(uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal))
|
||||
.rejects.toMatchObject({
|
||||
code: 'gateway/internal',
|
||||
message: 'failed to store file upload: Error: disk unavailable',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -42,6 +42,16 @@ describe('SessionController facade', () => {
|
||||
list: () => Promise.resolve([header]),
|
||||
inspect,
|
||||
}) as never)
|
||||
let uploadResolver: ((sessionId: SessionId) => Promise<Agent>) | undefined
|
||||
ctx.provide('fileUploads', {
|
||||
registerAgentResolver: (resolve: (sessionId: SessionId) => Promise<Agent>) => {
|
||||
uploadResolver = resolve
|
||||
return () => {}
|
||||
},
|
||||
resolve: () => undefined,
|
||||
bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }),
|
||||
retirePrompt: () => {},
|
||||
} as never)
|
||||
const controller = createSessionTestController(ctx, defaults)
|
||||
const status = vi.fn()
|
||||
const failure = vi.fn()
|
||||
@@ -65,6 +75,17 @@ describe('SessionController facade', () => {
|
||||
ctx,
|
||||
} as Agent
|
||||
ctx.agents.register(agent)
|
||||
const resolveUploadAgent = (id: SessionId): Promise<Agent> => {
|
||||
if (uploadResolver === undefined) throw new Error('file upload resolver was not registered')
|
||||
return uploadResolver(id)
|
||||
}
|
||||
await expect(resolveUploadAgent(sessionId)).resolves.toBe(agent)
|
||||
const activationError = new RemoteError('session/not-found', 'missing upload session', { sessionId })
|
||||
vi.spyOn(
|
||||
(controller as unknown as { agents: ApiSessionAgentController }).agents,
|
||||
'resolveAgent',
|
||||
).mockResolvedValueOnce({ error: activationError })
|
||||
await expect(resolveUploadAgent(sessionId)).rejects.toBe(activationError)
|
||||
const consumeSelection = vi.spyOn(
|
||||
(controller as unknown as { agents: ApiSessionAgentController }).agents,
|
||||
'consumeSelection',
|
||||
@@ -83,6 +104,10 @@ describe('SessionController facade', () => {
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'browser prompt' }],
|
||||
source: { kind: 'user', rpcId: 'controller-rpc' as never },
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(status).toHaveBeenCalledWith(sessionId, true)
|
||||
expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure'))
|
||||
expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number))
|
||||
@@ -108,7 +133,7 @@ describe('SessionController facade', () => {
|
||||
}, abort.signal)[Symbol.asyncIterator]()
|
||||
await expect(iterator.next()).resolves.toMatchObject({
|
||||
done: false,
|
||||
value: { type: 'snapshot', cursor: 1 },
|
||||
value: { type: 'snapshot', cursor: 2 },
|
||||
})
|
||||
abort.abort()
|
||||
await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
|
||||
|
||||
@@ -73,21 +73,29 @@ describe('Session queue snapshot intake', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('marks mixed-content messages non-editable and keeps image blocks out of the text preview', () => {
|
||||
it('marks mixed-content messages non-editable and keeps attachment blocks out of the text preview', () => {
|
||||
const session = makeSession()
|
||||
session.handleControlFrame(queueFrame([{
|
||||
id: 'q-image',
|
||||
body: '',
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
|
||||
content: [
|
||||
{ type: 'text', text: 'hi' },
|
||||
{ type: 'image', data: 'x' } as never,
|
||||
{ type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } } as never,
|
||||
],
|
||||
}]))
|
||||
const queue = session.getSnapshot().queue
|
||||
expect(typeof queue[0]?.messageId).toBe('string')
|
||||
expect(queue).toMatchObject([
|
||||
{
|
||||
id: 'q-image', placement: 'queued',
|
||||
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }],
|
||||
// Image blocks render as thumbnails from `content`, so the preview
|
||||
// carries only the text; non-image foreign blocks keep their marker.
|
||||
content: [
|
||||
{ type: 'text', text: 'hi' },
|
||||
{ type: 'image', data: 'x' },
|
||||
{ type: 'file', attachment: { attachmentId: 'file-1', name: 'notes.txt', bytes: 5 } },
|
||||
],
|
||||
// Attachment blocks render from `content`, so the preview carries
|
||||
// only text; other foreign blocks keep their marker.
|
||||
preview: 'hi', text: null,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -8,14 +8,14 @@ import { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts'
|
||||
import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
|
||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
|
||||
import {
|
||||
SessionPersistenceRevision,
|
||||
@@ -42,6 +42,10 @@ function promptRequest(
|
||||
}
|
||||
}
|
||||
|
||||
function inboxFor(session: Session): Inbox {
|
||||
return new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
|
||||
}
|
||||
|
||||
function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
return { version: SESSION_FORMAT_VERSION, id: sid(id), createdAt, isSeeded: false, cwd: '/proj', ...extra }
|
||||
}
|
||||
@@ -540,7 +544,9 @@ describe('subagent ownership fence', () => {
|
||||
inheritedEventCount: SessionLogOffset(1),
|
||||
})
|
||||
const followup = vi.fn()
|
||||
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
|
||||
const agent = {
|
||||
id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup,
|
||||
} as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
@@ -559,7 +565,9 @@ describe('subagent ownership fence', () => {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } })
|
||||
const followup = vi.fn()
|
||||
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
|
||||
const agent = {
|
||||
id: session.id, session, inbox: inboxFor(session), status: 'idle', ctx, followup,
|
||||
} as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
const remote = createSessionTestRemote(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
|
||||
@@ -679,6 +687,7 @@ describe('sessions.prompt synchronous rejection', () => {
|
||||
ctx.agents.register({
|
||||
id: session.id,
|
||||
session,
|
||||
inbox: inboxFor(session),
|
||||
status: 'idle',
|
||||
ctx,
|
||||
followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
|
||||
|
||||
@@ -665,7 +665,7 @@ describe('Web session model selection', () => {
|
||||
const savedRef = {
|
||||
attachmentId: 'saved-image', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1,
|
||||
}
|
||||
ctx.provide('attachments', {
|
||||
ctx.provide('attachments', Object.setPrototypeOf({
|
||||
saveImages: () => {
|
||||
if (saveMode === 'error') return Promise.reject(new Error('image store offline'))
|
||||
if (saveMode === 'remote') {
|
||||
@@ -673,7 +673,7 @@ describe('Web session model selection', () => {
|
||||
}
|
||||
return Promise.resolve([savedRef])
|
||||
},
|
||||
} as never)
|
||||
}, AttachmentStore.prototype) as never)
|
||||
const followup = vi.fn()
|
||||
Object.assign(agent, { followup })
|
||||
const remote = createSessionTestRemote(ctx, {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { SessionSeq, type SessionEvent, type SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
@@ -31,8 +31,20 @@ function imageRef(id: string): ImageAttachmentRef {
|
||||
} as unknown as ImageAttachmentRef
|
||||
}
|
||||
|
||||
function fileRef(id: string, name = 'notes.txt'): FileAttachmentRef {
|
||||
return { attachmentId: id, name, bytes: 3 } as unknown as FileAttachmentRef
|
||||
}
|
||||
|
||||
type AttachmentRef = ImageAttachmentRef | FileAttachmentRef
|
||||
|
||||
function attachmentBlock(attachment: AttachmentRef) {
|
||||
return 'mediaType' in attachment
|
||||
? { type: 'image' as const, attachment }
|
||||
: { type: 'file' as const, attachment }
|
||||
}
|
||||
|
||||
/** A durable browser-prompt user/message whose source echoes `rpcId`. */
|
||||
function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionEvent {
|
||||
function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionEvent {
|
||||
return {
|
||||
seq,
|
||||
time: 1_700_000_000_000 + seq,
|
||||
@@ -40,7 +52,7 @@ function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly Im
|
||||
surfaceOp: 'append',
|
||||
data: createUserMessage({
|
||||
content: [
|
||||
...refs.map(attachment => ({ type: 'image' as const, attachment })),
|
||||
...refs.map(attachmentBlock),
|
||||
{ type: 'text' as const, text: '发送' },
|
||||
],
|
||||
source: { kind: 'user', rpcId },
|
||||
@@ -48,14 +60,14 @@ function promptEvent(seq: SessionSeq, rpcId: SessionRequestId, refs: readonly Im
|
||||
} as unknown as SessionEvent
|
||||
}
|
||||
|
||||
function queuedItem(rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionQueuedItem {
|
||||
function queuedItem(rpcId: SessionRequestId, refs: readonly AttachmentRef[] = []): SessionQueuedItem {
|
||||
return {
|
||||
id: 'm-queued' as SessionQueuedItem['id'],
|
||||
placement: 'queued',
|
||||
rpcId,
|
||||
message: {
|
||||
id: 'm-queued' as SessionQueuedItem['id'],
|
||||
content: refs.map(attachment => ({ type: 'image', attachment })) as unknown as SessionQueuedItem['message']['content'],
|
||||
content: refs.map(attachmentBlock) as unknown as SessionQueuedItem['message']['content'],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -72,23 +84,27 @@ describe('beginSubmission', () => {
|
||||
const handle = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '你好',
|
||||
images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
|
||||
attachments: [{
|
||||
type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 },
|
||||
}],
|
||||
})
|
||||
expect(session.getSnapshot().promptAttempted).toBe(true)
|
||||
expect(session.getSnapshot().pendingSubmissions).toMatchObject([{
|
||||
requestId: handle.requestId,
|
||||
placement: 'transcript',
|
||||
text: '你好',
|
||||
images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }],
|
||||
attachments: [{
|
||||
type: 'image', value: { previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 },
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
it('derives and captures the echo placement from running state and delivery mode', () => {
|
||||
const { session } = makeSession()
|
||||
session.beginSubmission({ mode: 'queue', text: '空闲', images: [] })
|
||||
session.beginSubmission({ mode: 'queue', text: '空闲', attachments: [] })
|
||||
session.handleRunning(true)
|
||||
session.beginSubmission({ mode: 'queue', text: '排队', images: [] })
|
||||
session.beginSubmission({ mode: 'steer', text: '纠偏', images: [] })
|
||||
session.beginSubmission({ mode: 'queue', text: '排队', attachments: [] })
|
||||
session.beginSubmission({ mode: 'steer', text: '纠偏', attachments: [] })
|
||||
session.handleRunning(false)
|
||||
expect(session.getSnapshot().pendingSubmissions.map(({ text, placement }) => ({ text, placement }))).toEqual([
|
||||
{ text: '空闲', placement: 'transcript' },
|
||||
@@ -103,7 +119,7 @@ describe('beginSubmission', () => {
|
||||
const handle = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '放弃',
|
||||
images: [],
|
||||
attachments: [],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
})
|
||||
handle.abandon()
|
||||
@@ -121,7 +137,7 @@ describe('prompt-coupled retirement', () => {
|
||||
const handle = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '失败的',
|
||||
images: [],
|
||||
attachments: [],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
})
|
||||
const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId)
|
||||
@@ -133,7 +149,7 @@ describe('prompt-coupled retirement', () => {
|
||||
|
||||
it('sends the echo identity as the prompt requestId', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const handle = session.beginSubmission({ mode: 'queue', text: '带 id', images: [] })
|
||||
const handle = session.beginSubmission({ mode: 'queue', text: '带 id', attachments: [] })
|
||||
await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId)
|
||||
expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }])
|
||||
})
|
||||
@@ -141,7 +157,7 @@ describe('prompt-coupled retirement', () => {
|
||||
it('an unidentified prompt failure leaves registered echoes alone', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onPrompt = () => Promise.resolve(err(new RemoteError('session/agent-busy', '忙', { reason: 'busy' })))
|
||||
session.beginSubmission({ mode: 'queue', text: '还在', images: [] })
|
||||
session.beginSubmission({ mode: 'queue', text: '还在', attachments: [] })
|
||||
await session.prompt([{ type: 'text', text: '另一个' }], 'queue')
|
||||
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
|
||||
})
|
||||
@@ -156,7 +172,7 @@ describe('observed retirement', () => {
|
||||
const handle = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '发送',
|
||||
images: [{ previewUrl: 'blob:p1' }],
|
||||
attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
})
|
||||
const refs = [imageRef('att-1')]
|
||||
@@ -176,7 +192,7 @@ describe('observed retirement', () => {
|
||||
const handle = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '排队',
|
||||
images: [{ previewUrl: 'blob:p1' }],
|
||||
attachments: [{ type: 'image', value: { previewUrl: 'blob:p1' } }],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
})
|
||||
const refs = [imageRef('att-q')]
|
||||
@@ -188,9 +204,31 @@ describe('observed retirement', () => {
|
||||
expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }])
|
||||
})
|
||||
|
||||
it('retires a mixed echo with durable references in original selection order', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => Promise.resolve(ok(historyValue([])))
|
||||
await session.open()
|
||||
const retirements: PendingSubmissionRetirement[] = []
|
||||
const file = fileRef('file-1')
|
||||
const handle = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: 'mixed',
|
||||
attachments: [
|
||||
{ type: 'image', value: { previewUrl: 'blob:first' } },
|
||||
{ type: 'file', value: file },
|
||||
{ type: 'image', value: { previewUrl: 'blob:last' } },
|
||||
],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
})
|
||||
const refs = [imageRef('image-1'), file, imageRef('image-2')]
|
||||
await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId, refs) as never })
|
||||
await settleFrames()
|
||||
expect(retirements).toEqual([{ reason: 'observed', attachments: refs }])
|
||||
})
|
||||
|
||||
it('a full-window install (reconnect resync) retires echoes observed in the window', async () => {
|
||||
const { api, session } = makeSession()
|
||||
const handle = session.beginSubmission({ mode: 'queue', text: '重连', images: [] })
|
||||
const handle = session.beginSubmission({ mode: 'queue', text: '重连', attachments: [] })
|
||||
api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(SessionSeq(12), handle.requestId)])))
|
||||
await session.open()
|
||||
await settleFrames()
|
||||
@@ -205,7 +243,7 @@ describe('observed retirement', () => {
|
||||
const handle = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '先观察',
|
||||
images: [],
|
||||
attachments: [],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
})
|
||||
await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never })
|
||||
@@ -222,7 +260,7 @@ describe('observed retirement', () => {
|
||||
const handle = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '同一请求',
|
||||
images: [],
|
||||
attachments: [],
|
||||
onRetire: retirement => retirements.push(retirement),
|
||||
})
|
||||
session.handleControlFrame({
|
||||
@@ -245,7 +283,7 @@ describe('observed retirement', () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => Promise.resolve(ok(historyValue([])))
|
||||
await session.open()
|
||||
const handle = session.beginSubmission({ mode: 'queue', text: '帧', images: [] })
|
||||
const handle = session.beginSubmission({ mode: 'queue', text: '帧', attachments: [] })
|
||||
await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), handle.requestId) as never })
|
||||
expect(session.getSnapshot().pendingSubmissions).toHaveLength(1)
|
||||
expect(frames).toHaveLength(1)
|
||||
@@ -263,13 +301,13 @@ describe('disposal', () => {
|
||||
const observed = session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '已观察',
|
||||
images: [],
|
||||
attachments: [],
|
||||
onRetire: retirement => retirements.push({ text: '已观察', retirement }),
|
||||
})
|
||||
session.beginSubmission({
|
||||
mode: 'queue',
|
||||
text: '未settle',
|
||||
images: [],
|
||||
attachments: [],
|
||||
onRetire: retirement => retirements.push({ text: '未settle', retirement }),
|
||||
})
|
||||
await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), observed.requestId) as never })
|
||||
|
||||
@@ -15,7 +15,6 @@ const PARENT = 'fk-parent' as SessionId
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function makeSession(
|
||||
api = new FakeApiClient(),
|
||||
options: SessionOptions = {},
|
||||
@@ -479,6 +478,29 @@ describe('prompt and cancel errors', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects staged files instead of dropping them from subagent continuations', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, fakeRemote(api), {
|
||||
address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' },
|
||||
parentAvailable: true,
|
||||
})
|
||||
await session.open()
|
||||
|
||||
const prompted = await session.prompt([
|
||||
{ type: 'file', receiptId: 'receipt' as never },
|
||||
{ type: 'text', text: '继续' },
|
||||
], 'queue')
|
||||
|
||||
expect(prompted).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'subagent/attachment-invalid',
|
||||
details: { reason: 'SUBAGENT_FILE_UNSUPPORTED' },
|
||||
},
|
||||
})
|
||||
expect(api.callsOf('subagents.prompt')).toEqual([])
|
||||
})
|
||||
|
||||
it('sends a one-shot address to the Host under the continuable marker', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onSubagentPrompt = () => Promise.resolve(err(new RemoteError(
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
import { SessionLogOffset } from '@deepseek-ai/dsh-session'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
AdmittedPromptContentPart,
|
||||
AttachmentAdmissionPart,
|
||||
ImageAttachmentLimits,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
SessionPersistenceNotFoundError,
|
||||
@@ -91,6 +96,15 @@ export interface TestSessionRemoteDefaults {
|
||||
|
||||
const installed = new WeakMap<Context, SessionController>()
|
||||
|
||||
const TEST_IMAGE_LIMITS: ImageAttachmentLimits = Object.freeze({
|
||||
maxImageBytes: 5 * 1024 * 1024,
|
||||
maxImagesPerMessage: 20,
|
||||
maxMessageImageBytes: 100 * 1024 * 1024,
|
||||
maxImagePixels: 40_000_000,
|
||||
maxImageDimension: 2000,
|
||||
mediaTypes: Object.freeze(['image/png'] as const),
|
||||
})
|
||||
|
||||
/** Compact header-and-events point read a persistence double declares per session. */
|
||||
interface TestSessionInspection {
|
||||
readonly meta: SessionHeader
|
||||
@@ -233,6 +247,29 @@ function installControllers(
|
||||
},
|
||||
} as never)
|
||||
}
|
||||
if (ctx.get('attachments') === undefined) {
|
||||
ctx.provide('attachments', {
|
||||
imageLimits: TEST_IMAGE_LIMITS,
|
||||
admitPromptContent: async (
|
||||
content: readonly AttachmentAdmissionPart[],
|
||||
): Promise<AdmittedPromptContentPart[]> => {
|
||||
const admitted: AdmittedPromptContentPart[] = []
|
||||
for (const part of content) {
|
||||
if (part.type === 'image') throw new Error('test did not configure image persistence')
|
||||
admitted.push(part)
|
||||
}
|
||||
return admitted
|
||||
},
|
||||
} as never)
|
||||
}
|
||||
if (ctx.get('fileUploads') === undefined) {
|
||||
ctx.provide('fileUploads', {
|
||||
registerAgentResolver: () => () => {},
|
||||
resolve: () => undefined,
|
||||
bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }),
|
||||
retirePrompt: () => {},
|
||||
} as never)
|
||||
}
|
||||
installSessionReadTestServices(ctx)
|
||||
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
|
||||
let controller: SessionController
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
{ "path": "../gateway/tsconfig.client.json" },
|
||||
{ "path": "../../attachment/attachment" },
|
||||
{ "path": "../../client/connection/tsconfig.client.json" },
|
||||
{ "path": "../../client/file-upload/tsconfig.client.json" },
|
||||
{ "path": "../../client/store" },
|
||||
{ "path": "../../context/file-reference" },
|
||||
{ "path": "../../core/session" },
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../context/file-reference" },
|
||||
{ "path": "../../attachment/attachment" },
|
||||
{ "path": "../../client/file-upload/tsconfig.host.json" },
|
||||
{ "path": "../../interaction/permission-presets" },
|
||||
{ "path": "../../jobs/jobs" },
|
||||
{ "path": "../../llm/llm" },
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md
|
||||
README.md: 5e9bda9aa13939c3945d005ea474c3caf8daa661
|
||||
README.zh.md: 88dcbff120c4c61ea776e43fc906923f039ee09b
|
||||
README.md: 364153b7b56daa725003178b6cfad90e3f94bc04
|
||||
README.zh.md: 6ca5c6df8289c9e16bfe608b5b9ae200adf18a6b
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
This package provides the local storage and image-processing backend for attachments: source images are validated, oriented, stripped of metadata and color profiles, normalized to 8-bit sRGB/sRGBA, and saved below `DSH_HOME`; route-specific request versions are derived and cached separately. It is what the shipped `dsh` composition uses, so durable image attachments work without configuration. Identical normalized images are stored only once, concurrent reads of one request variant share work, and stored images stay readable after later admission-limit changes. Storage is local to this machine — other hosts cannot read these images — and objects are never deleted automatically.
|
||||
This package provides the local storage and image-processing backend for attachments: source images are validated, oriented, stripped of metadata and color profiles, normalized to 8-bit sRGB/sRGBA, and saved below `DSH_HOME`; route-specific request versions are derived and cached separately, and generic files are saved byte-for-byte with no admission limits. Streamed file writes and reads use bounded chunks; writes hash into a private staging object before atomic publication, and reads verify the recorded byte length and digest without a whole-file memory copy. It is what the shipped `dsh` composition uses, so durable attachments work without configuration. Identical bytes occupy one canonical object even when uploads use different display names; each model-facing name is a hard link to that object. Concurrent reads of one request variant share work, and stored images stay readable after later admission-limit changes. Storage is local to this machine; other hosts cannot read these objects, and objects are never deleted automatically.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -25,7 +25,7 @@ This package provides the local storage and image-processing backend for attachm
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
In the default composition, attach images to a prompt or command and they are stored on this machine automatically. If you compose your own setup, mounting this one plugin gives you durable image attachments.
|
||||
In the default composition, images and generic files attached to prompts or commands are stored on this machine automatically. If you compose your own setup, mounting this plugin provides durable attachments.
|
||||
|
||||
### Minimal configuration
|
||||
|
||||
@@ -87,12 +87,15 @@ Admission accepts up to 20 images and 200 MiB of source bytes per message; one s
|
||||
|
||||
Request versions live below `<DSH_HOME>/attachments/v1/request-images/`. `readImageRequest` scales without enlargement to a route pixel budget, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, budgets, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history.
|
||||
|
||||
Generic-file bytes have one canonical object at `<DSH_HOME>/attachments/v1/file-objects/<digest-prefix>/<digest>`. Each reference path at `<DSH_HOME>/attachments/v1/files/<digest-prefix>/<digest>/<name>` is a read-only hard link, so different names for equal bytes do not duplicate disk content. `readFileStream` reads the reference path in bounded chunks and verifies the complete digest and recorded byte count before a consumer can finish successfully. A missing, changed, or truncated object fails its consumer instead of producing a complete export with different bytes.
|
||||
|
||||
### Source map
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Plugin entry: `LocalAttachmentStore`, `Config` schema, defaults |
|
||||
| [`src/store.ts`](src/store.ts) | Content-addressed write and verified read: staging, hard-link publish, fsync chain, digest verification |
|
||||
| [`src/file-store.ts`](src/file-store.ts) | Verbatim streamed file writes, verified streamed reads, and safe stored filenames |
|
||||
| [`src/normalization.ts`](src/normalization.ts) + [`src/encoding.ts`](src/encoding.ts) | Provider-independent normalization and bounded format/quality candidates |
|
||||
| [`src/request-image.ts`](src/request-image.ts) | Route-specific request transforms, cache identity, and singleflight |
|
||||
| [`src/image.ts`](src/image.ts) | Full raster decode and metadata verification |
|
||||
@@ -117,7 +120,7 @@ For the full service contract and payload types, read the subsystem reference; f
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through request descriptors. A mapped execution filesystem lets the model see each image's identity, dimensions, media type, read-only process path, writable-copy extension, and normalization warning alongside the request bytes.
|
||||
Indirectly, through request descriptors. A mapped execution filesystem lets the model see each image's identity, dimensions, media type, read-only process path, writable-copy extension, and normalization warning alongside the request bytes. Generic files project as text handles naming their identity and read-only process path; when no mapping exists, the handle states that the execution environment cannot read the file.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
本包提供附件的本地存储与图片处理后端:源图经过校验、方向修正、元数据与色彩配置移除,并规范化为 8-bit sRGB/sRGBA 后保存在 `DSH_HOME` 下;路由专用请求版本另行派生并缓存。随附的 `dsh` 组合使用的就是它,因此持久图片附件无需配置即可工作。相同规范化图片只存一份,同一请求变体的并发读取共享工作,即使后来收紧准入限制,已存图片仍然可读。存储仅限本机——其他主机无法读取这些图片——对象也永远不会自动删除。
|
||||
本包提供附件的本地存储与图片处理后端:源图经过校验、方向修正、元数据与色彩配置移除,并规范化为 8-bit sRGB/sRGBA 后保存在 `DSH_HOME` 下;路由专用请求版本另行派生并缓存,通用文件则不设准入限制,按字节原样保存。流式文件写入与读取都使用有界分块;写入会在私有暂存对象中计算摘要后原子发布,读取会校验记录的字节长度与摘要,两者都不产生整文件内存副本。随附的 `dsh` 组合使用的就是它,因此持久附件无需配置即可工作。即使使用不同显示名称上传,相同字节也只占用一个规范对象;每条模型可见路径都是指向该对象的硬链接。同一请求变体的并发读取共享工作,即使后来收紧准入限制,已存图片仍然可读。存储仅限本机,其他主机无法读取这些对象,对象也永远不会自动删除。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
在默认组合中,把图片附加到提示词或命令,它们会自动保存到本机。自行组合时,挂载这一个插件即可获得持久图片附件。
|
||||
在默认组合中,把图片或通用文件附加到提示词或命令,它们会自动保存到本机。自行组合时,挂载这个插件即可获得持久附件。
|
||||
|
||||
### 最小配置
|
||||
|
||||
@@ -87,12 +87,15 @@ kind: "package-reference"
|
||||
|
||||
请求版本位于 `<DSH_HOME>/attachments/v1/request-images/`。`readImageRequest` 在不放大的前提下缩放到路由像素预算,再通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、预算与固定编码参数;缓存字节会先探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。
|
||||
|
||||
通用文件字节的唯一规范对象位于 `<DSH_HOME>/attachments/v1/file-objects/<digest-prefix>/<digest>`。每条引用路径 `<DSH_HOME>/attachments/v1/files/<digest-prefix>/<digest>/<name>` 都是只读硬链接,所以名称不同但字节相同的文件不会重复占用磁盘。`readFileStream` 以有界分块读取引用路径,并在消费方成功结束前校验完整摘要与记录的字节数。对象缺失、被改写或截断时,消费方会失败,不会得到字节已经变化的完整导出。
|
||||
|
||||
### 源码地图
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | 插件入口:`LocalAttachmentStore`、`Config` schema、默认值 |
|
||||
| [`src/store.ts`](src/store.ts) | 内容寻址写入与校验读取:暂存、硬链接发布、fsync 链、摘要校验 |
|
||||
| [`src/file-store.ts`](src/file-store.ts) | 原样文件的流式写入、校验式流式读取与安全存储文件名 |
|
||||
| [`src/normalization.ts`](src/normalization.ts) + [`src/encoding.ts`](src/encoding.ts) | 提供方无关的规范化与有界格式/质量候选 |
|
||||
| [`src/request-image.ts`](src/request-image.ts) | 路由专用请求变换、缓存身份与 singleflight |
|
||||
| [`src/image.ts`](src/image.ts) | 完整光栅解码与元数据校验 |
|
||||
@@ -117,7 +120,7 @@ kind: "package-reference"
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
本包通过请求描述符间接影响模型。执行文件系统可以映射宿主对象时,模型会随请求字节看到每张图片的身份、尺寸、媒体类型、只读进程路径、可写副本扩展名与规范化警告。
|
||||
本包通过请求描述符间接影响模型。执行文件系统可以映射宿主对象时,模型会随请求字节看到每张图片的身份、尺寸、媒体类型、只读进程路径、可写副本扩展名与规范化警告。通用文件会投影为指出文件身份与只读进程路径的文本句柄;无法映射时,句柄会说明当前执行环境不能读取该文件。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
Loaded 100 of 400 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user